Add initial MVP

This commit is contained in:
Dominic Höglinger 2025-11-20 20:02:40 +01:00
parent f926859b1e
commit 1a9605cb2d
22 changed files with 1638 additions and 15 deletions

484
.gitignore vendored Normal file
View File

@ -0,0 +1,484 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

31
Common/SourceLocation.cs Normal file
View File

@ -0,0 +1,31 @@
using System.Text;
namespace XNeedle
{
/// <summary>
/// Carries the origin information for a loaded XML document:
/// the file path, the detected encoding, and the line number of the
/// associated node (-1 when line information is unavailable).
/// </summary>
public struct SourceLocation
{
public string FilePath { get; set; }
public Encoding Encoding { get; set; }
/// <summary>
/// 1-based line number of the associated node, or -1 if unknown.
/// </summary>
public int LineNumber { get; set; }
public SourceLocation(string filePath, Encoding encoding, int lineNumber = -1)
{
FilePath = filePath;
Encoding = encoding;
LineNumber = lineNumber;
}
public SourceLocation WithLineNumber(int lineNumber) =>
new SourceLocation(FilePath, Encoding, lineNumber);
public SourceLocation AsSynthetic() =>
new SourceLocation("<synthetic>", Encoding, -1);
}
}

View File

@ -0,0 +1,258 @@
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
namespace XNeedle.Formats
{
/// <summary>
/// An XmlWriter that round-trips XML as faithfully as possible:
/// - Preserves the source encoding (detected from BOM or XML declaration).
/// - Preserves line endings (they live in the document's whitespace text nodes).
/// - Optionally strips the space before self-closing tags (&lt;Tag/&gt; vs &lt;Tag /&gt;),
/// auto-detected from whether the input file uses compact style.
/// </summary>
public sealed class XmlRoundtripWriter : XmlWriter
{
private readonly XmlTextWriter _inner;
public override WriteState WriteState => _inner.WriteState;
private XmlRoundtripWriter(XmlTextWriter inner)
{
_inner = inner;
}
/// <summary>
/// Creates an XmlRoundtripWriter by inspecting the input file for encoding
/// and self-closing tag style.
/// </summary>
public static XmlRoundtripWriter Create(string inputPath, string outputPath)
{
var rawBytes = File.ReadAllBytes(inputPath);
var encoding = DetectEncoding(rawBytes);
var rawText = encoding.GetString(rawBytes);
// Compact self-closing: <Tag/> has a non-whitespace char immediately before '/>'
var compact = Regex.IsMatch(rawText, @"\S/>");
// Detect DOS line endings
var dosLineEndings = rawText.Contains("\r\n");
// Extract verbatim encoding name from the XML declaration to preserve its exact casing
string? verbatimEncodingName = null;
var encMatch = Regex.Match(rawText, @"encoding\s*=\s*[""']([^""']+)[""']",
RegexOptions.IgnoreCase);
if (encMatch.Success)
verbatimEncodingName = encMatch.Groups[1].Value;
var streamWriter = new StreamWriter(outputPath, false, encoding);
var filterWriter = new FilteringTextWriter(streamWriter, compact, dosLineEndings, verbatimEncodingName);
var xmlWriter = new XmlTextWriter(filterWriter)
{
Formatting = Formatting.None
};
return new XmlRoundtripWriter(xmlWriter);
}
/// <summary>
/// Creates an XmlRoundtripWriter that writes to a StringBuilder for in-memory rendering.
/// Style detection (compact self-closing tags, DOS line endings) is read from the source file.
/// </summary>
public static (XmlRoundtripWriter writer, StringBuilder buffer) CreateToString(string filePath, Encoding encoding)
{
var rawBytes = File.ReadAllBytes(filePath);
var rawText = encoding.GetString(rawBytes);
var compact = Regex.IsMatch(rawText, @"\S/>");
var dosLineEndings = rawText.Contains("\r\n");
var sb = new StringBuilder();
var stringWriter = new StringWriter(sb);
var filterWriter = new FilteringTextWriter(stringWriter, compact, dosLineEndings, verbatimEncodingName: null);
var xmlWriter = new XmlTextWriter(filterWriter)
{
Formatting = Formatting.None
};
return (new XmlRoundtripWriter(xmlWriter), sb);
}
/// <summary>
/// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8.
/// </summary>
internal static Encoding DetectEncoding(byte[] raw)
{
if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF)
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
if (raw.Length >= 2 && raw[0] == 0xFF && raw[1] == 0xFE)
return Encoding.Unicode; // UTF-16 LE
if (raw.Length >= 2 && raw[0] == 0xFE && raw[1] == 0xFF)
return Encoding.BigEndianUnicode; // UTF-16 BE
// No BOM — probe the XML declaration for an encoding= attribute
var probe = Encoding.ASCII.GetString(raw, 0, Math.Min(raw.Length, 256));
var match = Regex.Match(probe, @"encoding\s*=\s*[""']([^""']+)[""']",
RegexOptions.IgnoreCase);
if (match.Success)
{
try { return Encoding.GetEncoding(match.Groups[1].Value); }
catch (ArgumentException) { /* unknown name, fall through */ }
}
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
// ── XmlWriter abstract member delegation ──────────────────────────────
public override void WriteStartDocument()
=> _inner.WriteStartDocument();
public override void WriteStartDocument(bool standalone)
=> _inner.WriteStartDocument(standalone);
public override void WriteEndDocument()
=> _inner.WriteEndDocument();
public override void WriteDocType(string name, string? pubid, string? sysid, string? subset)
=> _inner.WriteDocType(name, pubid, sysid, subset);
public override void WriteStartElement(string? prefix, string localName, string? ns)
=> _inner.WriteStartElement(prefix, localName, ns);
public override void WriteEndElement()
=> _inner.WriteEndElement();
public override void WriteFullEndElement()
=> _inner.WriteFullEndElement();
public override void WriteStartAttribute(string? prefix, string localName, string? ns)
=> _inner.WriteStartAttribute(prefix, localName, ns);
public override void WriteEndAttribute()
=> _inner.WriteEndAttribute();
public override void WriteCData(string? text)
=> _inner.WriteCData(text);
public override void WriteComment(string? text)
=> _inner.WriteComment(text);
public override void WriteProcessingInstruction(string name, string? text)
=> _inner.WriteProcessingInstruction(name, text);
public override void WriteEntityRef(string name)
=> _inner.WriteEntityRef(name);
public override void WriteCharEntity(char ch)
=> _inner.WriteCharEntity(ch);
public override void WriteWhitespace(string? ws)
=> _inner.WriteWhitespace(ws);
public override void WriteString(string? text)
=> _inner.WriteString(text);
public override void WriteSurrogateCharEntity(char lowChar, char highChar)
=> _inner.WriteSurrogateCharEntity(lowChar, highChar);
public override void WriteChars(char[] buffer, int index, int count)
=> _inner.WriteChars(buffer, index, count);
public override void WriteRaw(string data)
=> _inner.WriteRaw(data);
public override void WriteRaw(char[] buffer, int index, int count)
=> _inner.WriteRaw(buffer, index, count);
public override void WriteBase64(byte[] buffer, int index, int count)
=> _inner.WriteBase64(buffer, index, count);
public override string? LookupPrefix(string ns)
=> _inner.LookupPrefix(ns);
public override void Flush()
=> _inner.Flush();
public override void Close()
=> _inner.Close();
protected override void Dispose(bool disposing)
{
if (disposing) _inner.Close();
base.Dispose(disposing);
}
// ── FilteringTextWriter ───────────────────────────────────────────────
/// <summary>
/// Wraps a TextWriter and optionally converts " />" to "/>" via a state machine,
/// correctly spanning across multiple Write() calls.
/// </summary>
private sealed class FilteringTextWriter : TextWriter
{
private readonly TextWriter _inner;
private readonly bool _filter;
private readonly bool _dosLineEndings;
private readonly string? _verbatimEncodingName;
private enum State { Normal, SeenSpace, SeenSpaceSlash }
private State _state = State.Normal;
private bool _lastWasCR = false;
public FilteringTextWriter(TextWriter inner, bool filter, bool dosLineEndings, string? verbatimEncodingName)
{
_inner = inner;
_filter = filter;
_dosLineEndings = dosLineEndings;
_verbatimEncodingName = verbatimEncodingName;
}
public override Encoding Encoding => _inner.Encoding;
public override void Write(char c)
{
ProcessChar(c);
}
public override void Write(string? value)
{
if (value is null) return;
// XmlTextWriter writes the declaration in pieces; the encoding attribute
// arrives as a standalone " encoding="..." " string — match either form.
if (_verbatimEncodingName != null && value.Contains("encoding=", StringComparison.OrdinalIgnoreCase))
value = Regex.Replace(value, @"encoding=[""']([^""']+)[""']",
$"encoding=\"{_verbatimEncodingName}\"", RegexOptions.IgnoreCase);
foreach (var c in value) ProcessChar(c);
}
private void ProcessChar(char c)
{
// Restore DOS line endings stripped by XmlTextWriter's normalisation
if (_dosLineEndings && c == '\n' && !_lastWasCR)
{
Emit('\r');
}
_lastWasCR = (c == '\r');
if (!_filter) { _inner.Write(c); return; }
switch (_state)
{
case State.Normal:
if (c == ' ') _state = State.SeenSpace;
else Emit(c);
break;
case State.SeenSpace:
if (c == '/') _state = State.SeenSpaceSlash;
else { Emit(' '); _state = State.Normal; ProcessChar(c); }
break;
case State.SeenSpaceSlash:
if (c == '>') { _inner.Write("/>"); _state = State.Normal; }
else { Emit(' '); Emit('/'); _state = State.Normal; ProcessChar(c); }
break;
}
}
private void Emit(char c) => _inner.Write(c);
public override void Flush()
{
// Drain any partially-matched state
if (_filter)
{
if (_state == State.SeenSpace) { _inner.Write(' '); _state = State.Normal; }
else if (_state == State.SeenSpaceSlash) { _inner.Write(" /"); _state = State.Normal; }
}
_inner.Flush();
}
protected override void Dispose(bool disposing)
{
if (disposing) { Flush(); _inner.Dispose(); }
base.Dispose(disposing);
}
}
}
}

View File

@ -0,0 +1,18 @@
using System.Linq.Expressions;
namespace XNeedle.Parser.Elements
{
public class Arithmeric : Evaluatable
{
public required Expression<Func<double>> Expr { get; set; }
public override string Evaluate(Context ctx)
{
return Expr.Compile()().ToString();
}
public override void Operate(Context xtx)
{
Expr.Compile()();
}
}
}

10
Parser/Elements/Call.cs Normal file
View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace XNeedle.Parser.Elements
{
public class Call : Evaluatable
{
public required string Identifier { get; set; }
public required IEnumerable<string> Arguments;
}
}

View File

@ -0,0 +1,16 @@
using System.Xml.Linq;
namespace XNeedle.Parser.Elements
{
public class Context
{
public string? XPathSelector { get; set; }
public XElement? Node { get; set; }
public SourceLocation SLoc { get; set; }
public Context Clone()
{
return (Context)this.MemberwiseClone();
}
}
}

View File

@ -0,0 +1,10 @@
namespace XNeedle.Parser.Elements
{
public class Evaluatable : Part
{
public virtual string Evaluate(Context ctx)
{
return "";
}
}
}

View File

@ -0,0 +1,14 @@
using System.Linq.Expressions;
namespace XNeedle.Parser.Elements
{
public class ExpressionPart : Part
{
public required Expression<Func<Context, string>> Expr { get; set; }
public override void Operate(Context ctx)
{
Expr.Compile()(ctx);
}
}
}

View File

@ -0,0 +1,7 @@
namespace XNeedle.Parser.Elements
{
public class NullPart : Part
{
public override void Operate(Context ctx) { }
}
}

7
Parser/Elements/Part.cs Normal file
View File

@ -0,0 +1,7 @@
namespace XNeedle.Parser.Elements
{
public class Part
{
public virtual void Operate(Context ctx) {}
}
}

25
Parser/XmlFragParser.cs Normal file
View File

@ -0,0 +1,25 @@
using Sprache;
namespace XNeedle.Parser
{
public class XmlFragParser
{
public static Parser<string> OpeningTagAnchor(string tag)
{
return
from lbraket in Parse.Char('<')
from tagname in Parse.String(tag)
from any in Parse.CharExcept('>').Many()
from rbraket in Parse.Char('>')
select new string([.. any]);
}
public static Parser<string> ChecksumData(string tagName)
{
var closing = "</" + tagName + ">";
return
from preamble in OpeningTagAnchor(tagName)
from content in Parse.AnyChar.Until(Parse.String(closing))
select new string([.. content]) + closing;
}
}
}

251
Parser/XnParser.cs Normal file
View File

@ -0,0 +1,251 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Sprache;
using System.Globalization;
using XNeedle.Parser.Elements;
using Part = XNeedle.Parser.Elements.Part;
using NullPart = XNeedle.Parser.Elements.NullPart;
using XNeedle.Transformation;
using Rule = XNeedle.Transformation.Rule;
using System.Data;
using System.ComponentModel.DataAnnotations;
using System.Text;
namespace XNeedle.Parser
{
public class XnParser
{
static readonly ParameterExpression CtxParam = Expression.Parameter(typeof(Context), "ctx");
static Parser<ExpressionType> Operator(string op, ExpressionType opType)
{
return Parse.String(op).Token().Return(opType);
}
//static MethodInfo ConcatMethod = typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) });
static readonly Parser<ExpressionType> Add = Operator("+", ExpressionType.AddChecked);
static readonly Parser<ExpressionType> Subtract = Operator("-", ExpressionType.SubtractChecked);
static readonly Parser<ExpressionType> Multiply = Operator("*", ExpressionType.MultiplyChecked);
static readonly Parser<ExpressionType> Divide = Operator("/", ExpressionType.Divide);
static readonly Parser<ExpressionType> Modulo = Operator("%", ExpressionType.Modulo);
static readonly Parser<ExpressionType> Power = Operator("^", ExpressionType.Power);
static Parser<Expression> Function(Context ctx)
{
return
from name in Parse.Letter.AtLeastOnce().Text()
from lparen in Parse.Char('(')
from expr in Parse.Ref(() => Expr(ctx)).DelimitedBy(Parse.Char(',').Token()).Optional()
from rparen in Parse.Char(')')
select CallFunction(ctx, name, expr.GetOrElse(new Expression[0]).ToArray());
}
static Expression CallFunction(Context ctx, string name, Expression[] parameters)
{
var mathMethodInfo = typeof(Math).GetTypeInfo().GetMethod(name, parameters.Select(e => e.Type).ToArray());
if (mathMethodInfo != null)
{
return Expression.Call(mathMethodInfo, parameters);
}
var ctxMethodInfo = typeof(ContextFunctions).GetTypeInfo().GetMethod(name, parameters.Select(e => e.Type).Prepend(typeof(Context)).ToArray());
if (ctxMethodInfo != null)
{
return Expression.Call(ctxMethodInfo, parameters.Prepend(CtxParam).ToArray());
}
throw new ParseException(string.Format("Function '{0}({1})' does not exist.", name,
string.Join(",", parameters.Select(e => e.Type.Name))));
}
static readonly Parser<Expression> NumberConstant =
Parse.Decimal
.Select(x => Expression.Constant(double.Parse(x)))
.Named("number");
private static readonly Parser<Expression> StringConstant =
from open in Parse.Char('"')
from value in Parse.CharExcept('"').Many().Text()
from close in Parse.Char('"')
select Expression.Constant(new string(value));
static Parser<Expression> Factor(Context ctx)
{
return (from lparen in Parse.Char('(')
from expr in Parse.Ref(() => Expr(ctx))
from rparen in Parse.Char(')')
select expr).Named("expression")
.XOr(NumberConstant)
.XOr(StringConstant)
.XOr(Function(ctx));
}
static Parser<Expression> Operand(Context ctx)
{
return ((from sign in Parse.Char('-')
from factor in Factor(ctx)
select Expression.Negate(factor)
).XOr(Factor(ctx))).Token();
}
static Parser<Expression> InnerTerm(Context ctx)
{
return Parse.ChainRightOperator(Power, Operand(ctx), Expression.MakeBinary);
}
static Parser<Expression> Term(Context ctx)
{
return Parse.ChainOperator(Multiply.Or(Divide).Or(Modulo), InnerTerm(ctx), Expression.MakeBinary);
}
static Parser<Expression> ConcatExpr(Context ctx)
{
var concatMethod = typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) });
return
from left in StringConstant.Or(Function(ctx))
from op in Parse.String("..").Token()
from right in StringConstant.Or(Function(ctx))
select Expression.Add(left, right, concatMethod);
}
static Parser<Expression> ArithExpr(Context ctx)
{
return Parse.ChainOperator(Add.Or(Subtract), Term(ctx), Expression.MakeBinary);
}
static Parser<Expression> Expr(Context ctx)
{
return ConcatExpr(ctx).Or(ArithExpr(ctx));
}
static Parser<ExpressionPart> ExprPart(Context ctx)
{
return Expr(ctx).Select(body =>
{
Expression stringBody = body.Type == typeof(string)
? body
: Expression.Call(body, typeof(object).GetMethod("ToString", Type.EmptyTypes)!);
return new ExpressionPart { Expr = Expression.Lambda<Func<Context, string>>(stringBody, CtxParam) };
});
}
static readonly Parser<string> XPath =
from path in Parse.LetterOrDigit
.XOr(Parse.Char('/'))
.XOr(Parse.Char(' '))
.XOr(Parse.Char('['))
.XOr(Parse.Char(']'))
.XOr(Parse.Char('='))
.XOr(Parse.Char('.'))
.XOr(Parse.Char('_'))
.XOr(Parse.Char('*'))
.XOr(Parse.Char('@'))
.XOr(Parse.Char('#'))
.XOr(Parse.Char('('))
.XOr(Parse.Char(')'))
.XOr(Parse.Char('\''))
.Many()
select new string(path.ToArray());
static Parser<T> Selector<T>(Parser<T> selection)
{
return from open in Parse.String("$(\"").Token()
from s in selection
from close in Parse.String("\")").Token()
select s;
}
public static readonly Parser<string> XPathSelector = Selector(XPath);
public static readonly Parser<string> Identifier =
from first in Parse.Letter.Once()
from rest in Parse.LetterOrDigit.XOr(Parse.Char('-')).XOr(Parse.Char('_')).Many()
select new string(first.Concat(rest).ToArray());
private static readonly Parser<string> StringObject =
from open in Parse.Char('"')
from value in Parse.CharExcept('"').Many().Text()
from close in Parse.Char('"')
select new string(value);
private static readonly Parser<string> NumberObject =
Parse.DecimalInvariant
.Select(s => double.Parse(s, CultureInfo.InvariantCulture))
.Select(v => v.ToString());
public static readonly Parser<string> Argument =
from argument in StringObject.Or(NumberObject)
select argument;
public static readonly Parser<Call> CallObject =
from identifier in Identifier
from open in Parse.Char('(')
from arguments in Argument.DelimitedBy(Parse.Char(',').Token())
from close in Parse.Char(')')
select new Call{Identifier = new string(identifier.ToArray()), Arguments = arguments};
/*public static readonly Parser<Expression> ExpressionObject =
from expression in CallObject
*/
public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine };
public static readonly Parser<Part> BangObject =
from bang in Parse.Char('!')
from name in Parse.AnyChar.Until(Parse.Char('('))
from arguments in Argument.DelimitedBy(Parse.Char(',').Token())
from bclose in Parse.Char(')')
let ctx = new Context{XPathSelector = "//*."}
from ws1 in Parse.WhiteSpace.Many().Optional()
from open in Parse.Char('{')
from ws2 in Parse.WhiteSpace.Many().Optional()
from expressions in Parse.Ref(() => BangObject)
.Or(Parse.Ref(() => RuleObject))
.Or(ExprPart(ctx).Select(e => (Part)e))
.DelimitedBy(Parse.Char(';').Token())
from tailingsemi in Parse.Char(';').Optional()
from ws3 in Parse.WhiteSpace.Many().Optional()
from close in Parse.Char('}')
select new Bang{Name = new string([.. name]), Arguments = arguments, Body = expressions};
public static readonly Parser<Part> RuleObject =
from selector in Selector(XPath).Token()
let ctx = new Context{XPathSelector = selector}
from ws1 in Parse.WhiteSpace.Many().Optional()
from open in Parse.Char('{')
from ws2 in Parse.WhiteSpace.Many().Optional()
from expressions in Parse.Ref(() => BangObject)
.Or(Parse.Ref(() => RuleObject))
.Or(ExprPart(ctx).Select(e => (Part)e))
.DelimitedBy(Parse.Char(';').Token())
from tailingsemi in Parse.Char(';').Optional()
from ws3 in Parse.WhiteSpace.Many().Optional()
from close in Parse.Char('}')
select new Rule{XPathSelector = selector, Body = expressions};
static public string StripComments(string source)
{
var result = new StringBuilder();
foreach (var line in source.Split('\n'))
{
bool inSingle = false, inDouble = false;
int commentIdx = -1;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '\'' && !inDouble) inSingle = !inSingle;
else if (c == '"' && !inSingle) inDouble = !inDouble;
else if (c == '#' && !inSingle && !inDouble)
{
commentIdx = i;
break;
}
}
result.Append(commentIdx >= 0 ? line[..commentIdx] : line);
result.Append('\n');
}
return result.ToString();
}
}
}

80
Program.cs Normal file
View File

@ -0,0 +1,80 @@
using System;
using System.Xml;
using System.Xml.Linq;
using CommandLine;
using Sprache;
using XNeedle.Parser;
using XNeedle.Parser.Elements;
using XNeedle.Transformation;
using XNeedle.Formats;
namespace XNeedle
{
class Program
{
public class Options
{
[Value(0, MetaName = "xn", Required = true, HelpText = "XN transformation definition")]
public required string XnDefinition { get; set; }
[Option('i', "input", HelpText = "Input XML file", Required = true)]
public required string Input { get; set; }
[Option('o', "output", HelpText = "Output XML file", Required = true)]
public required string Output { get; set; }
[Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")]
public bool Verbose { get; set; }
}
static void Main(string[] args)
{
CommandLine.Parser.Default.ParseArguments<Options>(args)
.WithParsed(RunOptions)
.WithNotParsed(HandleParseError);
}
static void RunOptions(Options opts)
{
// Auto-discover operations via reflection
BangOperation.RegisterAll();
var rawSource = File.ReadAllText(opts.XnDefinition);
var xnSource = XnParser.StripComments(rawSource);
Rule masterRule;
try
{
masterRule = (Rule)XnParser.RuleObject.Parse(xnSource);
}
catch (ParseException ex)
{
var lines = rawSource.Split('\n');
int line = ex.Position?.Line ?? -1;
int col = ex.Position?.Column ?? -1;
bool showPosition = (line != -1) && (col != -1);
if (showPosition)
{
Console.Error.WriteLine($"{opts.XnDefinition}({line},{col}): parse error: {ex.Message}");
var srcLine = line <= lines.Length ? lines[line - 1].TrimEnd() : "";
var pointer = new string(' ', Math.Max(0, col - 1)) + "^";
Console.Error.WriteLine($" {srcLine}");
Console.Error.WriteLine($" {pointer}");
}
else
{
Console.Error.WriteLine($"{opts.XnDefinition}: parse error: {ex.Message}");
}
return;
}
var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace);
var encoding = XmlRoundtripWriter.DetectEncoding(File.ReadAllBytes(opts.Input));
var sloc = new SourceLocation(opts.Input, encoding);
masterRule.Transform(doc, sloc);
using var writer = XmlRoundtripWriter.Create(opts.Input, opts.Output);
doc.WriteTo(writer);
Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\"");
}
static void HandleParseError(IEnumerable<Error> errs) {}
}
}

View File

@ -12,7 +12,7 @@ To live up to its name, XPath is utilized to specify precise locations
combined with a good enough set of instructions to do CRUD operations.
As the main application lies with injecting faults into ESI files,
additional functions for calcating checksums are included.
additional functions for calculating checksums are included.
This allows recalculation of CRC32 secured sections of a given document,
such as modules in an ESI file.
@ -28,7 +28,7 @@ and values.
This example prepends the existing inner text of a given node with "Faulty '",
and appends the evaluated string "': 2+2=10".
```
text("Faulty '" .. text() .. "': " .. string(1+1**2) .. "+2=10");
text("Faulty '" .. text() .. "': " .. string(1+1^2) .. "+2=10");
```
2. Rules
@ -40,10 +40,10 @@ A block however can consist of multiple expressions, nested rules or bangs.
This example sets the text and an attribute based on their respective selectors.
```
$(""//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']/.."")
$("//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']/..")
{
$("Type") { attr("ProductCode", hexlit(0xDEADBEEF)); }
$(""Name[@LcId='1033']"") { text(""LE2904, 4 Ch. Danger Output 24V, 0.5A, TwinUNSAFE""); };
$("Type") { attr("ProductCode", hex(0xDEADBEEF)); }
$("Name[@LcId='1033']") { cdata("LE2904, 4 Ch. Danger Output 24V, 0.5A, TwinUNSAFE"); };
}
```
@ -55,7 +55,7 @@ They are prefixed with, as the name implies, an exclamation mark.
This example adds, removes and swaps nodes.
```
$(RxPdo/Index[.="#x1600"]/..)
$("RxPdo/Index[.="#x1600"]/..")
{
# Modify name
$(Name) { text("FSoE Outputs"); }
@ -81,19 +81,21 @@ $(RxPdo/Index[.="#x1600"]/..)
}
}
# Swap Entries
!swap("Entry[3]", "Entry[4]");
swap("Entry[3]", "Entry[4]");
}
```
## Project Specifications
4. Comments
The project will be written in C# using .NET 8 and released under a BSD 3-Clause permissive license.
Comments are prefixed with the '#' character.
Libraries which will be utilized:
- Sprache: Monadic parser for reading in XNeedle specifications
- System.IO.Hashing: For calculating checksums to use in the resulting document
- Command Line Parser: For parsing command line options
## Timeline
## Running the command
A feature complete release is expected in Q2 of 2026.
```bash
xn sample.xn -i input.xml -o output.xml
```
The file `sample.xn` is the transformation specification as outlined above.
For input and output, see `input.xml` and `output.xml` respectively.
Refer to the help text using the `--help` switch for more details.

30
Transformation/Bang.cs Normal file
View File

@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
using XNeedle;
using XNeedle.Parser.Elements;
namespace XNeedle.Transformation
{
public class Bang : Part
{
public required string Name { get; set; }
public required IEnumerable<string> Arguments;
public required IEnumerable<Part> Body;
public override void Operate(Context ctx)
{
var bangop = BangOperation.Resolve(Name);
if (bangop == null)
{
throw new ArgumentException($"!{Name} is not a valid bang.");
}
var bodyctx = bangop.Execute(ctx, Arguments);
foreach (var b in Body)
{
b.Operate(bodyctx);
}
}
}
}

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.CompilerServices;
using XNeedle.Parser.Elements;
namespace XNeedle.Transformation
{
public abstract class BangOperation
{
private static readonly Dictionary<string, BangOperation> Registry = new();
public abstract string Name { get; }
protected BangOperation()
{
Registry[Name] = this;
}
public abstract Context Execute(Context ctx, IEnumerable<string> arguments);
public static BangOperation? Resolve(string name) =>
Registry.TryGetValue(name, out var op) ? op : null;
public static void RegisterAll()
{
foreach (var type in Assembly.GetExecutingAssembly().GetTypes())
if (!type.IsAbstract && type.IsSubclassOf(typeof(BangOperation)))
Activator.CreateInstance(type);
}
}
}

View File

@ -0,0 +1,28 @@
using System.Xml.Linq;
using XNeedle.Parser.Elements;
namespace XNeedle.Transformation.Bangs
{
public class AddBang : BangOperation
{
public override string Name => "add";
public override Context Execute(Context ctx, IEnumerable<string> arguments)
{
if (ctx.Node == null)
{
throw new ArgumentNullException("!add needs a reachable node");
}
if (arguments.Count() < 1)
{
throw new ArgumentException("!add needs at least the tag name as argument.");
}
var tagname = arguments.ElementAt(0);
var element = new XElement(tagname, "");
ctx.Node.Add(element);
return new Context{ Node = element, SLoc = ctx.SLoc.AsSynthetic(), XPathSelector = tagname};
}
}
}

View File

@ -0,0 +1,28 @@
using System.Xml.Linq;
using XNeedle.Parser.Elements;
namespace XNeedle.Transformation.Bangs
{
public class AfterBang : BangOperation
{
public override string Name => "after";
public override Context Execute(Context ctx, IEnumerable<string> arguments)
{
if (ctx.Node == null)
{
throw new ArgumentNullException("!after needs a reachable node");
}
if (arguments.Count() < 1)
{
throw new ArgumentException("!after needs at least the tag name as argument.");
}
var tagname = arguments.ElementAt(0);
var element = new XElement(tagname, "");
ctx.Node.AddAfterSelf(element);
return new Context{ Node = element, SLoc = ctx.SLoc.AsSynthetic(), XPathSelector = tagname};
}
}
}

View File

@ -0,0 +1,28 @@
using System.Xml.Linq;
using XNeedle.Parser.Elements;
namespace XNeedle.Transformation.Bangs
{
public class BeforeBang : BangOperation
{
public override string Name => "before";
public override Context Execute(Context ctx, IEnumerable<string> arguments)
{
if (ctx.Node == null)
{
throw new ArgumentNullException("!before needs a reachable node");
}
if (arguments.Count() < 1)
{
throw new ArgumentException("!before needs at least the tag name as argument.");
}
var tagname = arguments.ElementAt(0);
var element = new XElement(tagname, "");
ctx.Node.AddBeforeSelf(element);
return new Context{ Node = element, SLoc = ctx.SLoc.AsSynthetic(), XPathSelector = tagname};
}
}
}

View File

@ -0,0 +1,191 @@
using System;
using System.Linq;
using System.Text;
using Sprache;
using XNeedle.Formats;
using XNeedle.Parser;
using XNeedle.Parser.Elements;
using System.Xml.Linq;
using System.Xml.XPath;
namespace XNeedle.Transformation
{
public class ContextFunctions
{
public static string hex(Context ctx, double value)
{
uint v = (uint)value;
return $"#x{v:X8}";
}
public static double GetLevel(Context ctx)
{
int level = 0;
XElement? n = ctx.Node;
while (null != (n = n?.Parent))
level++;
return level;
}
public static double crc(Context ctx)
{
if (ctx.Node is null)
return 0;
// Step 1: Render the element to a string, preserving source style
var (writer, buffer) = XmlRoundtripWriter.CreateToString(ctx.SLoc.FilePath, ctx.SLoc.Encoding);
ctx.Node.WriteTo(writer);
writer.Flush();
string xml = buffer.ToString();
// Step 2: Extract all text after the opening tag through and including the closing tag
string tagName = ctx.Node.Name.LocalName;
string data = XmlFragParser.ChecksumData(tagName).Parse(xml);
// Step 3: Digest into CRC32 using the source encoding
var crc32 = new System.IO.Hashing.Crc32();
crc32.Append(ctx.SLoc.Encoding.GetBytes(data));
uint result = BitConverter.ToUInt32(crc32.GetCurrentHash());
return (double)result;
}
public static string text(Context ctx)
{
if (ctx.Node is null) return "";
return ctx.Node.Value;
}
public static string text(Context ctx, string txt)
{
if (ctx.Node is null) return "";
ctx.Node.SetValue(txt);
return txt;
}
public static string cdata(Context ctx)
{
if (ctx.Node is null) return "";
var cdataNode = ctx.Node.Nodes().OfType<System.Xml.Linq.XCData>().FirstOrDefault();
return cdataNode?.Value ?? "";
}
public static string cdata(Context ctx, string txt)
{
if (ctx.Node is null) return "";
var cdataNode = ctx.Node.Nodes().OfType<System.Xml.Linq.XCData>().FirstOrDefault();
if (cdataNode != null)
{
cdataNode.Value = txt;
}
else
{
ctx.Node.Add(new System.Xml.Linq.XCData(txt));
}
return txt;
}
public static string attr(Context ctx, string name, string value)
{
if (ctx.Node is null) return "";
var attr = ctx.Node.Attribute(name);
if (attr != null)
{
attr.Value = value;
}
else
{
ctx.Node.SetAttributeValue(name, value);
}
return "";
}
public static string attr(Context ctx, string name)
{
var attr = ctx.Node?.Attribute(name);
if (attr != null)
{
return attr.Value;
}
return "";
}
public static string swap(Context ctx, string a_select, string b_select)
{
if (ctx.Node == null)
{
throw new ArgumentNullException("!swap needs a reachable node");
}
var a_node = ctx.Node.XPathSelectElement(a_select);
var b_node = ctx.Node.XPathSelectElement(b_select);
if (a_node == null || b_node == null)
{
throw new InvalidOperationException($"!swap could not find nodes for \"{a_select}\" and/or \"{b_select}\".");
}
var placeholder = new XElement("__placeholder__");
a_node.ReplaceWith(placeholder);
b_node.ReplaceWith(a_node);
placeholder.ReplaceWith(b_node);
return "";
}
public static string remove(Context ctx, string select)
{
if (ctx.Node == null)
{
throw new ArgumentNullException("!remove needs a reachable node");
}
// Use ToList to make the lazy/generator enumeration concrete before applying operations
var nodes = ctx.Node.XPathSelectElements(select).ToList();
foreach (var node in nodes)
{
node.Remove();
}
return "";
}
public static string rename(Context ctx, string newName)
{
if (ctx.Node is null) return "";
var replacement = new XElement(newName, ctx.Node.Attributes(), ctx.Node.Nodes());
ctx.Node.ReplaceWith(replacement);
ctx.Node = replacement; // update context to point to new element
return "";
}
public static string rename(Context ctx, string select, string newName)
{
if (ctx.Node is null) return "";
int atIdx = select.IndexOf('@');
if (atIdx >= 0)
{
// "Node@Attr" — rename an attribute
var elementSelect = select[..atIdx].TrimEnd();
var attrName = select[(atIdx + 1)..].TrimStart();
var target = string.IsNullOrEmpty(elementSelect)
? ctx.Node
: ctx.Node.XPathSelectElement(elementSelect);
var attr = target?.Attribute(attrName);
if (attr is null) return "";
attr.Remove();
target!.SetAttributeValue(newName, attr.Value);
}
else
{
// plain XPath select — rename element
var target = ctx.Node.XPathSelectElement(select);
if (target is null) return "";
var replacement = new XElement(newName, target.Attributes(), target.Nodes());
target.ReplaceWith(replacement);
}
return "";
}
}
}

47
Transformation/Rule.cs Normal file
View File

@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
using XNeedle;
using XNeedle.Parser.Elements;
namespace XNeedle.Transformation
{
public class Rule : Part
{
public required string XPathSelector { get; set; }
public required IEnumerable<Part> Body;
public void Transform(XDocument xml, SourceLocation sloc)
{
var context = new Context();
context.Node = xml.Root;
context.SLoc = sloc;
Operate(context);
}
public override void Operate(Context ctx)
{
if(ctx.Node is null)
{
return;
}
var nodes = ctx.Node.XPathSelectElements(XPathSelector);
if(nodes is null)
{
return;
}
foreach (XElement n in nodes)
{
var loopCtx = ctx.Clone();
loopCtx.Node = n;
var lineInfo = (IXmlLineInfo)n;
loopCtx.SLoc = ctx.SLoc.WithLineNumber(lineInfo.HasLineInfo() ? lineInfo.LineNumber : -1);
foreach (var b in Body)
{
b.Operate(loopCtx);
}
}
}
}
}

27
XNeedle.csproj Normal file
View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>xn</AssemblyName>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Sprache" Version="3.0.0-develop-00049" />
<PackageReference Include="System.IO.Hashing" Version="10.0.0" />
</ItemGroup>
</Project>