From c71b85c8fe0cbceea8ae1d917d112c15421ee52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Thu, 20 Nov 2025 20:02:40 +0100 Subject: [PATCH 01/16] Add initial prototype --- .gitignore | 484 +++++++++++++++++++++++++++++++++++++++++++++++++ Program.cs | 453 +++++++++++++++++++++++++++++++++++++++++++++ XNeedle.csproj | 15 ++ 3 files changed, 952 insertions(+) create mode 100644 .gitignore create mode 100644 Program.cs create mode 100644 XNeedle.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc78471 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f88013b --- /dev/null +++ b/Program.cs @@ -0,0 +1,453 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Sprache; +using System.IO; +using System.Globalization; +using System.Reflection.Metadata; +using System.Xml; +using System.Threading.Tasks.Dataflow; + +public class Context +{ + public required string XPathSelector { get; set; } + public XmlNodeList? Nodes { get; set; } + public XmlNode? Node { get; set; } +} + +public class ContextFunctions +{ + + public static string hex(Context ctx, double value) + { + uint v = (uint)value; + return $"#x{v:X4}"; + } + + public static int GetLevel(XmlNode node) { + int level = 0; + while (null != (node = node.ParentNode)) + level++; + + return level; + } + public static double crc(Context ctx) + { + int crc = 0xAFFE; + + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Indent = true; + settings.ConformanceLevel = ConformanceLevel.Fragment; + + // This isn't working, need to render the _entire_ xml each time :( + using (var sw = new StringWriter()) { + using (var xw = XmlWriter.Create(sw, settings)) { + ctx.Node.WriteContentTo(xw); + } + var data = sw.ToString(); + + var indentLevel = GetLevel(ctx.Node); + var extraIndent = new string(' ', indentLevel); + + data = extraIndent + data.ReplaceLineEndings(Environment.NewLine + extraIndent); + + var crc32 = new System.IO.Hashing.Crc32(); + var bytes = Encoding.UTF8.GetBytes(data); + crc32.Append(bytes); + crc = BitConverter.ToInt32(crc32.GetCurrentHash()); + } + + return (double)crc; + } + + public static string text(Context ctx) + { + Console.WriteLine($"Get T: {ctx.Node.InnerText}"); + return ctx.Node.InnerText; + } + + public static string text(Context ctx, string txt) + { + Console.WriteLine($"Set T: {txt}"); + return ctx.Node.InnerText = txt; + } + + public static string attr(Context ctx, string name, string value) + { + if (ctx.Node.Attributes[name] != null) + { + ctx.Node.Attributes[name].Value = value; + } + else + { + var add = ctx.Node.OwnerDocument.CreateAttribute(name); + add.Value = value; + ctx.Node.Attributes.SetNamedItem(add); + } + return ""; + } + + public static string attr(Context ctx, string name) + { + if (ctx.Node.Attributes[name] != null) + { + return ctx.Node.Attributes[name].Value; + } + return ""; + } +} + +public class Part +{ + public virtual void Operate(Context ctx) {} +} + +public class ExpressionPart : Part +{ + public required Expression> Expr { get; set; } + + public override void Operate(Context xtx) + { + Expr.Compile()(); + } +} + +public class Evaluatable : Part +{ + public virtual string Evaluate(Context ctx) + { + return ""; + } +} + +public class Call : Evaluatable +{ + public required string Identifier { get; set; } + public required IEnumerable Arguments; +} + +public class Arithmeric : Evaluatable +{ + public required Expression> Expr { get; set; } + public override string Evaluate(Context ctx) + { + return Expr.Compile()().ToString(); + } + + public override void Operate(Context xtx) + { + Expr.Compile()(); + } +} + +public class Rule : Part +{ + public required string XPathSelector { get; set; } + public required IEnumerable Body; + public required Context Context; + + public void Transform(XmlDocument xml) + { + Context.Node = xml.DocumentElement; + Operate(Context); + } + + public override void Operate(Context ctx) + { + Context.Nodes = ctx.Node.SelectNodes(Context.XPathSelector); + foreach (XmlNode n in Context.Nodes) + { + Context.Node = n; + foreach (var b in Body) + { + b.Operate(Context); + } + } + } +} + +public static class TrafoParser +{ + static Parser 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 Add = Operator("+", ExpressionType.AddChecked); + static readonly Parser Subtract = Operator("-", ExpressionType.SubtractChecked); + static readonly Parser Multiply = Operator("*", ExpressionType.MultiplyChecked); + static readonly Parser Divide = Operator("/", ExpressionType.Divide); + static readonly Parser Modulo = Operator("%", ExpressionType.Modulo); + static readonly Parser Power = Operator("^", ExpressionType.Power); + /* + static readonly Parser Function = + from name in Parse.Letter.AtLeastOnce().Text() + from lparen in Parse.Char('(') + from expr in Parse.Ref(() => Expr).DelimitedBy(Parse.Char(',').Token()) + from rparen in Parse.Char(')') + select CallFunction(name, expr.ToArray()); + */ + static Parser 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(Expression.Constant(ctx)).ToArray()); + } + + throw new ParseException(string.Format("Function '{0}({1})' does not exist.", name, + string.Join(",", parameters.Select(e => e.Type.Name)))); + } + + static readonly Parser NumberConstant = + Parse.Decimal + .Select(x => Expression.Constant(double.Parse(x))) + .Named("number"); + /* + static readonly Parser Factor = + (from lparen in Parse.Char('(') + from expr in Parse.Ref(() => Expr) + from rparen in Parse.Char(')') + select expr).Named("expression") + .XOr(Constant) + .XOr(Function); + */ + + private static readonly Parser 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 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 readonly Parser Operand = + ((from sign in Parse.Char('-') + from factor in Factor + select Expression.Negate(factor) + ).XOr(Factor)).Token(); + */ + static Parser Operand(Context ctx) + { + return ((from sign in Parse.Char('-') + from factor in Factor(ctx) + select Expression.Negate(factor) + ).XOr(Factor(ctx))).Token(); + } + + //static readonly Parser InnerTerm = Parse.ChainRightOperator(Power, Operand, Expression.MakeBinary); + static Parser InnerTerm(Context ctx) + { + return Parse.ChainRightOperator(Power, Operand(ctx), Expression.MakeBinary); + } + + //static readonly Parser Term = Parse.ChainOperator(Multiply.Or(Divide).Or(Modulo), InnerTerm, Expression.MakeBinary); + static Parser Term(Context ctx) + { + return Parse.ChainOperator(Multiply.Or(Divide).Or(Modulo), InnerTerm(ctx), Expression.MakeBinary); + } + + //static readonly Parser Expr = Parse.ChainOperator(Add.Or(Subtract), Term, Expression.MakeBinary); + + static Parser 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 ArithExpr(Context ctx) + { + return Parse.ChainOperator(Add.Or(Subtract), Term(ctx), Expression.MakeBinary); + } + + static Parser Expr(Context ctx) + { + return ConcatExpr(ctx).Or(ArithExpr(ctx)); + } + + + /*static readonly Parser>> Lambda = + Expr.End().Select(body => Expression.Lambda>(body)); + */ + + static Parser ExprPart(Context ctx) + { + return Expr(ctx).Select(body => new ExpressionPart{Expr = Expression.Lambda>(body)}); + } + + static readonly Parser 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 Selector(Parser selection) + { + return from open in Parse.String("$(\"").Token() + from s in selection + from close in Parse.String("\")").Token() + select s; + } + + public static readonly Parser XPathSelector = Selector(XPath); + + public static readonly Parser 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 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 NumberObject = + Parse.DecimalInvariant + .Select(s => double.Parse(s, CultureInfo.InvariantCulture)) + .Select(v => v.ToString()); + + public static readonly Parser Argument = + from argument in StringObject.Or(NumberObject) + select argument; + + public static readonly Parser 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 ExpressionObject = + from expression in CallObject + */ + + public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine }; + + public static readonly Parser RuleObject = + from c1 in Comment.SingleLineComment.Optional() + 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 c2 in Comment.SingleLineComment.Optional() + //from expressions in RuleObject.Select(n => (Rule)n).DelimitedBy(';') + //from expressions in ExprPart(new Context{XPathSelector = selector}).Or(RuleObject).Select(n => (Part)n).DelimitedBy(Parse.Char(';').Token()) + from expressions in ExprPart(ctx).Or(RuleObject).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, Context = ctx}; +} + + +class Program +{ + static void Main() + { + var input = "$(\"//Device/(Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000'])[1]/..\")"; + input = +@" +$(""/EtherCATInfo/Vendor/Name"") +{ + $(""Element"") { attr(""foo"", ""bar""); Sin(2); } + text(""Fake Beckhoff""); +} +"; + input = +@" +$(""/EtherCATInfo/Vendor/Name"") +{ + text(""Foobar "" .. text()); + $(""Element"") + { + attr(""foo"", ""bar""); + }; +} +"; + + input = @" +$(""//EtherCATInfo/Vendor/Name"") +{ + text(""Fake "" .. text()); + attr(""Crc"", hex(3735928559)); +} +"; + + input = @" +$(""//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']/.."") +{ + $(""Name[@LcId='1033']"") { text(""EL2904, 4 Ch. Danger Output 24V, 0.5A, TwinUNSAFE""); }; + $(""RxPdo/Index[.='#x1600']/.."") + { + $(""Name"") { text(""FSoE Outputs""); }; + $(""Entry/Index[.='#x7000']"") { text(""#x4711""); }; + attr(""fid"", ""1""); + attr(""FaultId"", attr(""fid"")); + attr(""Crc32x"", hex(crc()+1)); + } +} +"; + var parsed = TrafoParser.RuleObject.Parse(input); + + XmlDocument doc = new XmlDocument(); + doc.Load("ELx9xx.xml"); + + parsed.Transform(doc); + + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Indent = true; + XmlWriter writer = XmlWriter.Create("El9xxFi.xml", settings); + + doc.WriteContentTo(writer); + + Console.WriteLine("Done."); + } +} \ No newline at end of file diff --git a/XNeedle.csproj b/XNeedle.csproj new file mode 100644 index 0000000..aad51a1 --- /dev/null +++ b/XNeedle.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + From 6ef074081a9edacf9be4c801355405ac04f6b775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Thu, 23 Apr 2026 18:37:38 +0200 Subject: [PATCH 02/16] Restructured project, removed comment-out code --- Parser/Elements/Arithmeric.cs | 18 ++ Parser/Elements/Call.cs | 10 + Parser/Elements/Context.cs | 16 + Parser/Elements/Evaluatable.cs | 10 + Parser/Elements/ExpressionPart.cs | 14 + Parser/Elements/Part.cs | 7 + Parser/XmlFragParser.cs | 27 ++ Parser/XnParser.cs | 195 ++++++++++++ Program.cs | 467 ++--------------------------- Transformation/ContextFunctions.cs | 122 ++++++++ Transformation/Rule.cs | 41 +++ XNeedle.csproj | 2 +- 12 files changed, 484 insertions(+), 445 deletions(-) create mode 100644 Parser/Elements/Arithmeric.cs create mode 100644 Parser/Elements/Call.cs create mode 100644 Parser/Elements/Context.cs create mode 100644 Parser/Elements/Evaluatable.cs create mode 100644 Parser/Elements/ExpressionPart.cs create mode 100644 Parser/Elements/Part.cs create mode 100644 Parser/XmlFragParser.cs create mode 100644 Parser/XnParser.cs create mode 100644 Transformation/ContextFunctions.cs create mode 100644 Transformation/Rule.cs diff --git a/Parser/Elements/Arithmeric.cs b/Parser/Elements/Arithmeric.cs new file mode 100644 index 0000000..8419678 --- /dev/null +++ b/Parser/Elements/Arithmeric.cs @@ -0,0 +1,18 @@ +using System.Linq.Expressions; + +namespace XNeedle.Parser.Elements +{ + public class Arithmeric : Evaluatable + { + public required Expression> Expr { get; set; } + public override string Evaluate(Context ctx) + { + return Expr.Compile()().ToString(); + } + + public override void Operate(Context xtx) + { + Expr.Compile()(); + } + } +} \ No newline at end of file diff --git a/Parser/Elements/Call.cs b/Parser/Elements/Call.cs new file mode 100644 index 0000000..5710453 --- /dev/null +++ b/Parser/Elements/Call.cs @@ -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 Arguments; + } +} \ No newline at end of file diff --git a/Parser/Elements/Context.cs b/Parser/Elements/Context.cs new file mode 100644 index 0000000..71948ed --- /dev/null +++ b/Parser/Elements/Context.cs @@ -0,0 +1,16 @@ +using System.Xml; + +namespace XNeedle.Parser.Elements +{ + public class Context + { + public string? XPathSelector { get; set; } + public XmlNodeList? Nodes { get; set; } + public XmlNode? Node { get; set; } + + public Context Clone() + { + return (Context)this.MemberwiseClone(); + } + } +} \ No newline at end of file diff --git a/Parser/Elements/Evaluatable.cs b/Parser/Elements/Evaluatable.cs new file mode 100644 index 0000000..bd81d94 --- /dev/null +++ b/Parser/Elements/Evaluatable.cs @@ -0,0 +1,10 @@ +namespace XNeedle.Parser.Elements +{ + public class Evaluatable : Part + { + public virtual string Evaluate(Context ctx) + { + return ""; + } + } +} \ No newline at end of file diff --git a/Parser/Elements/ExpressionPart.cs b/Parser/Elements/ExpressionPart.cs new file mode 100644 index 0000000..d003bb3 --- /dev/null +++ b/Parser/Elements/ExpressionPart.cs @@ -0,0 +1,14 @@ +using System.Linq.Expressions; + +namespace XNeedle.Parser.Elements +{ + public class ExpressionPart : Part + { + public required Expression> Expr { get; set; } + + public override void Operate(Context xtx) + { + Expr.Compile()(); + } + } +} \ No newline at end of file diff --git a/Parser/Elements/Part.cs b/Parser/Elements/Part.cs new file mode 100644 index 0000000..1acb93a --- /dev/null +++ b/Parser/Elements/Part.cs @@ -0,0 +1,7 @@ +namespace XNeedle.Parser.Elements +{ + public class Part + { + public virtual void Operate(Context ctx) {} + } +} \ No newline at end of file diff --git a/Parser/XmlFragParser.cs b/Parser/XmlFragParser.cs new file mode 100644 index 0000000..88acf58 --- /dev/null +++ b/Parser/XmlFragParser.cs @@ -0,0 +1,27 @@ +using Sprache; + +namespace XNeedle.Parser +{ + public class XmlFragParser + { + public static Parser 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 ChecksumData(string opening, string closing) + { + return + from pregarbage in Parse.AnyChar.Many() + from preamble in Parse.String(opening).Token() + from content in Parse.AnyChar.Many() + from epilouge in Parse.String("") + from postgarbage in Parse.AnyChar.Many() + select new string([.. content]) + epilouge; + } + } +} \ No newline at end of file diff --git a/Parser/XnParser.cs b/Parser/XnParser.cs new file mode 100644 index 0000000..7c7c741 --- /dev/null +++ b/Parser/XnParser.cs @@ -0,0 +1,195 @@ +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 XNeedle.Transformation; +using Rule = XNeedle.Transformation.Rule; + +namespace XNeedle.Parser +{ + public class XnParser + { + static Parser 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 Add = Operator("+", ExpressionType.AddChecked); + static readonly Parser Subtract = Operator("-", ExpressionType.SubtractChecked); + static readonly Parser Multiply = Operator("*", ExpressionType.MultiplyChecked); + static readonly Parser Divide = Operator("/", ExpressionType.Divide); + static readonly Parser Modulo = Operator("%", ExpressionType.Modulo); + static readonly Parser Power = Operator("^", ExpressionType.Power); + + static Parser 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(Expression.Constant(ctx)).ToArray()); + } + + throw new ParseException(string.Format("Function '{0}({1})' does not exist.", name, + string.Join(",", parameters.Select(e => e.Type.Name)))); + } + + static readonly Parser NumberConstant = + Parse.Decimal + .Select(x => Expression.Constant(double.Parse(x))) + .Named("number"); + + private static readonly Parser 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 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 Operand(Context ctx) + { + return ((from sign in Parse.Char('-') + from factor in Factor(ctx) + select Expression.Negate(factor) + ).XOr(Factor(ctx))).Token(); + } + static Parser InnerTerm(Context ctx) + { + return Parse.ChainRightOperator(Power, Operand(ctx), Expression.MakeBinary); + } + + static Parser Term(Context ctx) + { + return Parse.ChainOperator(Multiply.Or(Divide).Or(Modulo), InnerTerm(ctx), Expression.MakeBinary); + } + + static Parser 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 ArithExpr(Context ctx) + { + return Parse.ChainOperator(Add.Or(Subtract), Term(ctx), Expression.MakeBinary); + } + + static Parser Expr(Context ctx) + { + return ConcatExpr(ctx).Or(ArithExpr(ctx)); + } + + static Parser ExprPart(Context ctx) + { + return Expr(ctx).Select(body => new ExpressionPart{Expr = Expression.Lambda>(body)}); + } + + static readonly Parser 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 Selector(Parser selection) + { + return from open in Parse.String("$(\"").Token() + from s in selection + from close in Parse.String("\")").Token() + select s; + } + + public static readonly Parser XPathSelector = Selector(XPath); + + public static readonly Parser 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 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 NumberObject = + Parse.DecimalInvariant + .Select(s => double.Parse(s, CultureInfo.InvariantCulture)) + .Select(v => v.ToString()); + + public static readonly Parser Argument = + from argument in StringObject.Or(NumberObject) + select argument; + + public static readonly Parser 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 ExpressionObject = + from expression in CallObject + */ + + public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine }; + + public static readonly Parser RuleObject = + from c1 in Comment.SingleLineComment.Optional() + 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 c2 in Comment.SingleLineComment.Optional() + from expressions in ExprPart(ctx).Or(Parse.Ref(() => RuleObject)).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}; + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index f88013b..9a2e2d2 100644 --- a/Program.cs +++ b/Program.cs @@ -1,453 +1,32 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using Sprache; -using System.IO; -using System.Globalization; -using System.Reflection.Metadata; using System.Xml; -using System.Threading.Tasks.Dataflow; +using Sprache; +using XNeedle.Parser; +using XNeedle.Parser.Elements; +using XNeedle.Transformation; -public class Context +namespace XNeedle { - public required string XPathSelector { get; set; } - public XmlNodeList? Nodes { get; set; } - public XmlNode? Node { get; set; } -} - -public class ContextFunctions -{ - - public static string hex(Context ctx, double value) + class Program { - uint v = (uint)value; - return $"#x{v:X4}"; - } - - public static int GetLevel(XmlNode node) { - int level = 0; - while (null != (node = node.ParentNode)) - level++; - - return level; - } - public static double crc(Context ctx) - { - int crc = 0xAFFE; - - XmlWriterSettings settings = new XmlWriterSettings(); - settings.Indent = true; - settings.ConformanceLevel = ConformanceLevel.Fragment; - - // This isn't working, need to render the _entire_ xml each time :( - using (var sw = new StringWriter()) { - using (var xw = XmlWriter.Create(sw, settings)) { - ctx.Node.WriteContentTo(xw); - } - var data = sw.ToString(); - - var indentLevel = GetLevel(ctx.Node); - var extraIndent = new string(' ', indentLevel); - - data = extraIndent + data.ReplaceLineEndings(Environment.NewLine + extraIndent); - - var crc32 = new System.IO.Hashing.Crc32(); - var bytes = Encoding.UTF8.GetBytes(data); - crc32.Append(bytes); - crc = BitConverter.ToInt32(crc32.GetCurrentHash()); - } - - return (double)crc; - } - - public static string text(Context ctx) - { - Console.WriteLine($"Get T: {ctx.Node.InnerText}"); - return ctx.Node.InnerText; - } - - public static string text(Context ctx, string txt) - { - Console.WriteLine($"Set T: {txt}"); - return ctx.Node.InnerText = txt; - } - - public static string attr(Context ctx, string name, string value) - { - if (ctx.Node.Attributes[name] != null) + static void Main() { - ctx.Node.Attributes[name].Value = value; + var input = "$(\"//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']\")"; + + var rule = (Rule)XnParser.RuleObject.Parse(input); + + XmlDocument doc = new XmlDocument(); + doc.Load("ELx9xx.xml"); + + rule.Transform(doc); + + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Indent = true; + XmlWriter writer = XmlWriter.Create("El9xxFi.xml", settings); + + doc.WriteContentTo(writer); + + Console.WriteLine("Done."); } - else - { - var add = ctx.Node.OwnerDocument.CreateAttribute(name); - add.Value = value; - ctx.Node.Attributes.SetNamedItem(add); - } - return ""; - } - - public static string attr(Context ctx, string name) - { - if (ctx.Node.Attributes[name] != null) - { - return ctx.Node.Attributes[name].Value; - } - return ""; - } -} - -public class Part -{ - public virtual void Operate(Context ctx) {} -} - -public class ExpressionPart : Part -{ - public required Expression> Expr { get; set; } - - public override void Operate(Context xtx) - { - Expr.Compile()(); - } -} - -public class Evaluatable : Part -{ - public virtual string Evaluate(Context ctx) - { - return ""; - } -} - -public class Call : Evaluatable -{ - public required string Identifier { get; set; } - public required IEnumerable Arguments; -} - -public class Arithmeric : Evaluatable -{ - public required Expression> Expr { get; set; } - public override string Evaluate(Context ctx) - { - return Expr.Compile()().ToString(); - } - - public override void Operate(Context xtx) - { - Expr.Compile()(); - } -} - -public class Rule : Part -{ - public required string XPathSelector { get; set; } - public required IEnumerable Body; - public required Context Context; - - public void Transform(XmlDocument xml) - { - Context.Node = xml.DocumentElement; - Operate(Context); - } - - public override void Operate(Context ctx) - { - Context.Nodes = ctx.Node.SelectNodes(Context.XPathSelector); - foreach (XmlNode n in Context.Nodes) - { - Context.Node = n; - foreach (var b in Body) - { - b.Operate(Context); - } - } - } -} - -public static class TrafoParser -{ - static Parser 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 Add = Operator("+", ExpressionType.AddChecked); - static readonly Parser Subtract = Operator("-", ExpressionType.SubtractChecked); - static readonly Parser Multiply = Operator("*", ExpressionType.MultiplyChecked); - static readonly Parser Divide = Operator("/", ExpressionType.Divide); - static readonly Parser Modulo = Operator("%", ExpressionType.Modulo); - static readonly Parser Power = Operator("^", ExpressionType.Power); - /* - static readonly Parser Function = - from name in Parse.Letter.AtLeastOnce().Text() - from lparen in Parse.Char('(') - from expr in Parse.Ref(() => Expr).DelimitedBy(Parse.Char(',').Token()) - from rparen in Parse.Char(')') - select CallFunction(name, expr.ToArray()); - */ - static Parser 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(Expression.Constant(ctx)).ToArray()); - } - - throw new ParseException(string.Format("Function '{0}({1})' does not exist.", name, - string.Join(",", parameters.Select(e => e.Type.Name)))); - } - - static readonly Parser NumberConstant = - Parse.Decimal - .Select(x => Expression.Constant(double.Parse(x))) - .Named("number"); - /* - static readonly Parser Factor = - (from lparen in Parse.Char('(') - from expr in Parse.Ref(() => Expr) - from rparen in Parse.Char(')') - select expr).Named("expression") - .XOr(Constant) - .XOr(Function); - */ - - private static readonly Parser 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 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 readonly Parser Operand = - ((from sign in Parse.Char('-') - from factor in Factor - select Expression.Negate(factor) - ).XOr(Factor)).Token(); - */ - static Parser Operand(Context ctx) - { - return ((from sign in Parse.Char('-') - from factor in Factor(ctx) - select Expression.Negate(factor) - ).XOr(Factor(ctx))).Token(); - } - - //static readonly Parser InnerTerm = Parse.ChainRightOperator(Power, Operand, Expression.MakeBinary); - static Parser InnerTerm(Context ctx) - { - return Parse.ChainRightOperator(Power, Operand(ctx), Expression.MakeBinary); - } - - //static readonly Parser Term = Parse.ChainOperator(Multiply.Or(Divide).Or(Modulo), InnerTerm, Expression.MakeBinary); - static Parser Term(Context ctx) - { - return Parse.ChainOperator(Multiply.Or(Divide).Or(Modulo), InnerTerm(ctx), Expression.MakeBinary); - } - - //static readonly Parser Expr = Parse.ChainOperator(Add.Or(Subtract), Term, Expression.MakeBinary); - - static Parser 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 ArithExpr(Context ctx) - { - return Parse.ChainOperator(Add.Or(Subtract), Term(ctx), Expression.MakeBinary); - } - - static Parser Expr(Context ctx) - { - return ConcatExpr(ctx).Or(ArithExpr(ctx)); - } - - - /*static readonly Parser>> Lambda = - Expr.End().Select(body => Expression.Lambda>(body)); - */ - - static Parser ExprPart(Context ctx) - { - return Expr(ctx).Select(body => new ExpressionPart{Expr = Expression.Lambda>(body)}); - } - - static readonly Parser 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 Selector(Parser selection) - { - return from open in Parse.String("$(\"").Token() - from s in selection - from close in Parse.String("\")").Token() - select s; - } - - public static readonly Parser XPathSelector = Selector(XPath); - - public static readonly Parser 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 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 NumberObject = - Parse.DecimalInvariant - .Select(s => double.Parse(s, CultureInfo.InvariantCulture)) - .Select(v => v.ToString()); - - public static readonly Parser Argument = - from argument in StringObject.Or(NumberObject) - select argument; - - public static readonly Parser 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 ExpressionObject = - from expression in CallObject - */ - - public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine }; - - public static readonly Parser RuleObject = - from c1 in Comment.SingleLineComment.Optional() - 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 c2 in Comment.SingleLineComment.Optional() - //from expressions in RuleObject.Select(n => (Rule)n).DelimitedBy(';') - //from expressions in ExprPart(new Context{XPathSelector = selector}).Or(RuleObject).Select(n => (Part)n).DelimitedBy(Parse.Char(';').Token()) - from expressions in ExprPart(ctx).Or(RuleObject).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, Context = ctx}; -} - - -class Program -{ - static void Main() - { - var input = "$(\"//Device/(Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000'])[1]/..\")"; - input = -@" -$(""/EtherCATInfo/Vendor/Name"") -{ - $(""Element"") { attr(""foo"", ""bar""); Sin(2); } - text(""Fake Beckhoff""); -} -"; - input = -@" -$(""/EtherCATInfo/Vendor/Name"") -{ - text(""Foobar "" .. text()); - $(""Element"") - { - attr(""foo"", ""bar""); - }; -} -"; - - input = @" -$(""//EtherCATInfo/Vendor/Name"") -{ - text(""Fake "" .. text()); - attr(""Crc"", hex(3735928559)); -} -"; - - input = @" -$(""//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']/.."") -{ - $(""Name[@LcId='1033']"") { text(""EL2904, 4 Ch. Danger Output 24V, 0.5A, TwinUNSAFE""); }; - $(""RxPdo/Index[.='#x1600']/.."") - { - $(""Name"") { text(""FSoE Outputs""); }; - $(""Entry/Index[.='#x7000']"") { text(""#x4711""); }; - attr(""fid"", ""1""); - attr(""FaultId"", attr(""fid"")); - attr(""Crc32x"", hex(crc()+1)); - } -} -"; - var parsed = TrafoParser.RuleObject.Parse(input); - - XmlDocument doc = new XmlDocument(); - doc.Load("ELx9xx.xml"); - - parsed.Transform(doc); - - XmlWriterSettings settings = new XmlWriterSettings(); - settings.Indent = true; - XmlWriter writer = XmlWriter.Create("El9xxFi.xml", settings); - - doc.WriteContentTo(writer); - - Console.WriteLine("Done."); } } \ No newline at end of file diff --git a/Transformation/ContextFunctions.cs b/Transformation/ContextFunctions.cs new file mode 100644 index 0000000..517d3ab --- /dev/null +++ b/Transformation/ContextFunctions.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using System.Text; +using System.Xml; +using Sprache; +using XNeedle.Parser; +using XNeedle.Parser.Elements; + +namespace XNeedle.Transformation +{ + public class ContextFunctions + { + public static string hex(Context ctx, double value) + { + uint v = (uint)value; + return $"#x{v:X8}"; + } + + public static int GetLevel(XmlNode node) + { + int level = 0; + XmlNode? currentNode = node; + while (null != (currentNode = currentNode.ParentNode)) + level++; + + return level; + } + public static double crc(Context ctx) + { + if (ctx.Node is null) + { + return 0; + } + int crc = 0xAFFE; + + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Indent = true; + settings.ConformanceLevel = ConformanceLevel.Document; + settings.OmitXmlDeclaration = true; + + // This isn't performant, but the whole document has to be rendered + // for an accurate data stream + string docrender = ""; + string fragrender = ""; + using (var sw = new StringWriter()) + { + using (var xw = XmlWriter.Create(sw, settings)) + { + if (ctx.Node.OwnerDocument is not null) + { + ctx.Node.OwnerDocument.WriteContentTo(xw); + } + } + docrender = sw.ToString(); + } + + settings.Indent = false; + settings.ConformanceLevel = ConformanceLevel.Fragment; + using (var sw = new StringWriter()) + { + using (var xw = XmlWriter.Create(sw, settings)) + { + ctx.Node.WriteTo(xw); + } + fragrender = sw.ToString(); + } + string tagname = ctx.Node.Name; + string anchor = XmlFragParser.OpeningTagAnchor(tagname).Parse(fragrender); + string preamble = $"<{tagname}{anchor}>"; + + //string data = FragXmlParser.ChecksumData(preamble, tagname).TryParse(docrender); + + Console.WriteLine("preamble: " + preamble); + + var crc32 = new System.IO.Hashing.Crc32(); + var bytes = Encoding.UTF8.GetBytes(""); + crc32.Append(bytes); + crc = BitConverter.ToInt32(crc32.GetCurrentHash()); + + return (double)crc; + } + + public static string text(Context ctx) + { + if (ctx.Node is null) return ""; + return ctx.Node.InnerText; + } + + public static string text(Context ctx, string txt) + { + if (ctx.Node is null) return ""; + return ctx.Node.InnerText = txt; + } + + public static string attr(Context ctx, string name, string value) + { + if (ctx.Node is null || ctx.Node.Attributes is null) return ""; + var attr = ctx.Node.Attributes[name]; + if (attr != null) + { + attr.Value = value; + } + else + { + if (ctx.Node.OwnerDocument is null) return ""; + var add = ctx.Node.OwnerDocument.CreateAttribute(name); + add.Value = value; + ctx.Node.Attributes.SetNamedItem(add); + } + return ""; + } + + public static string attr(Context ctx, string name) + { + if (ctx.Node?.Attributes?[name]?.Value is string val) + { + return val; + } + return ""; + } + } +} \ No newline at end of file diff --git a/Transformation/Rule.cs b/Transformation/Rule.cs new file mode 100644 index 0000000..30bfb12 --- /dev/null +++ b/Transformation/Rule.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Xml; +using XNeedle.Parser.Elements; + +namespace XNeedle.Transformation +{ + public class Rule : Part + { + public required string XPathSelector { get; set; } + public required IEnumerable Body; + + public void Transform(XmlDocument xml) + { + var context = new Context(); + context.Node = xml.DocumentElement; + Operate(context); + } + + public override void Operate(Context ctx) + { + if(ctx.Node is null) + { + return; + } + var nodes = ctx.Node.SelectNodes(XPathSelector); + if(nodes is null) + { + return; + } + foreach (XmlNode n in nodes) + { + var loopCtx = ctx.Clone(); + loopCtx.Node = n; + foreach (var b in Body) + { + b.Operate(loopCtx); + } + } + } + } +} \ No newline at end of file diff --git a/XNeedle.csproj b/XNeedle.csproj index aad51a1..2054cd1 100644 --- a/XNeedle.csproj +++ b/XNeedle.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable From 13e375c14521e433fe6c585f24d6c2553ca07bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Thu, 23 Apr 2026 19:55:24 +0200 Subject: [PATCH 03/16] Add command line interface --- Program.cs | 45 +++++++++++++++++++++++++++++++-------------- XNeedle.csproj | 1 + 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/Program.cs b/Program.cs index 9a2e2d2..2ad603a 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,7 @@ using System; using System.Xml; +using System.Xml.Linq; +using CommandLine; using Sprache; using XNeedle.Parser; using XNeedle.Parser.Elements; @@ -9,24 +11,39 @@ namespace XNeedle { class Program { - static void Main() + public class Options { - var input = "$(\"//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']\")"; + [Value(0, MetaName = "xn", Required = true, HelpText = "XN transformation definition")] + public string XnDefinition { get; set; } - var rule = (Rule)XnParser.RuleObject.Parse(input); + [Option('i', "input", HelpText = "Input XML file", Required = true)] + public string Input { get; set; } - XmlDocument doc = new XmlDocument(); - doc.Load("ELx9xx.xml"); + [Option('o', "output", HelpText = "Output XML file", Required = true)] + public string Output { get; set; } - rule.Transform(doc); - - XmlWriterSettings settings = new XmlWriterSettings(); - settings.Indent = true; - XmlWriter writer = XmlWriter.Create("El9xxFi.xml", settings); - - doc.WriteContentTo(writer); - - Console.WriteLine("Done."); + [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(args) + .WithParsed(RunOptions) + .WithNotParsed(HandleParseError); + } + + static void RunOptions(Options opts) + { + var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition)); + var doc = new XmlDocument(); + doc.PreserveWhitespace = true; + doc.Load(opts.Input); + masterRule.Transform(doc); + doc.Save(opts.Output); + + Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\""); + } + + static void HandleParseError(IEnumerable errs) {} } } \ No newline at end of file diff --git a/XNeedle.csproj b/XNeedle.csproj index 2054cd1..9dce10b 100644 --- a/XNeedle.csproj +++ b/XNeedle.csproj @@ -8,6 +8,7 @@ + From cc8738c0dce5770ff3a8aa35d692a84c9481c100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Thu, 23 Apr 2026 20:20:56 +0200 Subject: [PATCH 04/16] Use LINQ XDocument --- Parser/Elements/Context.cs | 5 +-- Program.cs | 28 +++++++++++-- Transformation/ContextFunctions.cs | 63 +++++------------------------- Transformation/Rule.cs | 10 +++-- 4 files changed, 41 insertions(+), 65 deletions(-) diff --git a/Parser/Elements/Context.cs b/Parser/Elements/Context.cs index 71948ed..53d9057 100644 --- a/Parser/Elements/Context.cs +++ b/Parser/Elements/Context.cs @@ -1,12 +1,11 @@ -using System.Xml; +using System.Xml.Linq; namespace XNeedle.Parser.Elements { public class Context { public string? XPathSelector { get; set; } - public XmlNodeList? Nodes { get; set; } - public XmlNode? Node { get; set; } + public XElement? Node { get; set; } public Context Clone() { diff --git a/Program.cs b/Program.cs index 2ad603a..d99dd33 100644 --- a/Program.cs +++ b/Program.cs @@ -35,15 +35,35 @@ namespace XNeedle static void RunOptions(Options opts) { var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition)); - var doc = new XmlDocument(); - doc.PreserveWhitespace = true; - doc.Load(opts.Input); + var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace); masterRule.Transform(doc); - doc.Save(opts.Output); + doc.Save(opts.Output, SaveOptions.DisableFormatting); Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\""); } static void HandleParseError(IEnumerable errs) {} } + + public static class DocumentExtensions + { + public static XmlDocument ToXmlDocument(this XDocument xDocument) + { + var xmlDocument = new XmlDocument(); + using(var xmlReader = xDocument.CreateReader()) + { + xmlDocument.Load(xmlReader); + } + return xmlDocument; + } + + public static XDocument ToXDocument(this XmlDocument xmlDocument) + { + using (var nodeReader = new XmlNodeReader(xmlDocument)) + { + nodeReader.MoveToContent(); + return XDocument.Load(nodeReader); + } + } + } } \ No newline at end of file diff --git a/Transformation/ContextFunctions.cs b/Transformation/ContextFunctions.cs index 517d3ab..ad100ea 100644 --- a/Transformation/ContextFunctions.cs +++ b/Transformation/ContextFunctions.cs @@ -33,88 +33,43 @@ namespace XNeedle.Transformation } int crc = 0xAFFE; - XmlWriterSettings settings = new XmlWriterSettings(); - settings.Indent = true; - settings.ConformanceLevel = ConformanceLevel.Document; - settings.OmitXmlDeclaration = true; - - // This isn't performant, but the whole document has to be rendered - // for an accurate data stream - string docrender = ""; - string fragrender = ""; - using (var sw = new StringWriter()) - { - using (var xw = XmlWriter.Create(sw, settings)) - { - if (ctx.Node.OwnerDocument is not null) - { - ctx.Node.OwnerDocument.WriteContentTo(xw); - } - } - docrender = sw.ToString(); - } - - settings.Indent = false; - settings.ConformanceLevel = ConformanceLevel.Fragment; - using (var sw = new StringWriter()) - { - using (var xw = XmlWriter.Create(sw, settings)) - { - ctx.Node.WriteTo(xw); - } - fragrender = sw.ToString(); - } - string tagname = ctx.Node.Name; - string anchor = XmlFragParser.OpeningTagAnchor(tagname).Parse(fragrender); - string preamble = $"<{tagname}{anchor}>"; - - //string data = FragXmlParser.ChecksumData(preamble, tagname).TryParse(docrender); - - Console.WriteLine("preamble: " + preamble); - - var crc32 = new System.IO.Hashing.Crc32(); - var bytes = Encoding.UTF8.GetBytes(""); - crc32.Append(bytes); - crc = BitConverter.ToInt32(crc32.GetCurrentHash()); - return (double)crc; } public static string text(Context ctx) { if (ctx.Node is null) return ""; - return ctx.Node.InnerText; + return ctx.Node.Value; } public static string text(Context ctx, string txt) { if (ctx.Node is null) return ""; - return ctx.Node.InnerText = txt; + ctx.Node.SetValue(txt); + return txt; } public static string attr(Context ctx, string name, string value) { - if (ctx.Node is null || ctx.Node.Attributes is null) return ""; - var attr = ctx.Node.Attributes[name]; + if (ctx.Node is null || !ctx.Node.HasAttributes) return ""; + var attr = ctx.Node.Attribute(name); if (attr != null) { attr.Value = value; } else { - if (ctx.Node.OwnerDocument is null) return ""; - var add = ctx.Node.OwnerDocument.CreateAttribute(name); - add.Value = value; - ctx.Node.Attributes.SetNamedItem(add); + ctx.Node.SetAttributeValue(name, value); } return ""; } public static string attr(Context ctx, string name) { - if (ctx.Node?.Attributes?[name]?.Value is string val) + var attr = ctx.Node?.Attribute(name); + if (attr != null) { - return val; + return attr.Value; } return ""; } diff --git a/Transformation/Rule.cs b/Transformation/Rule.cs index 30bfb12..961844c 100644 --- a/Transformation/Rule.cs +++ b/Transformation/Rule.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; using XNeedle.Parser.Elements; namespace XNeedle.Transformation @@ -9,10 +11,10 @@ namespace XNeedle.Transformation public required string XPathSelector { get; set; } public required IEnumerable Body; - public void Transform(XmlDocument xml) + public void Transform(XDocument xml) { var context = new Context(); - context.Node = xml.DocumentElement; + context.Node = xml.Root; Operate(context); } @@ -22,12 +24,12 @@ namespace XNeedle.Transformation { return; } - var nodes = ctx.Node.SelectNodes(XPathSelector); + var nodes = ctx.Node.XPathSelectElements(XPathSelector); if(nodes is null) { return; } - foreach (XmlNode n in nodes) + foreach (XElement n in nodes) { var loopCtx = ctx.Clone(); loopCtx.Node = n; From 2d54bec41d46bcae7c53bf4564bd8d99bf7d047c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 04:33:37 +0200 Subject: [PATCH 05/16] Clanker made roundtripping possible --- Formats/XmlRoundtripWriter.cs | 235 ++++++++++++++++++++++++++++++++++ Program.cs | 27 +--- 2 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 Formats/XmlRoundtripWriter.cs diff --git a/Formats/XmlRoundtripWriter.cs b/Formats/XmlRoundtripWriter.cs new file mode 100644 index 0000000..6dcec1f --- /dev/null +++ b/Formats/XmlRoundtripWriter.cs @@ -0,0 +1,235 @@ +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; + +namespace XNeedle.Formats +{ + /// + /// 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 (<Tag/> vs <Tag />), + /// auto-detected from whether the input file uses compact style. + /// + public sealed class XmlRoundtripWriter : XmlWriter + { + private readonly XmlTextWriter _inner; + + public override WriteState WriteState => _inner.WriteState; + + private XmlRoundtripWriter(XmlTextWriter inner) + { + _inner = inner; + } + + /// + /// Creates an XmlRoundtripWriter by inspecting the input file for encoding + /// and self-closing tag style. + /// + 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: 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); + } + + /// + /// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8. + /// + private 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 ─────────────────────────────────────────────── + + /// + /// Wraps a TextWriter and optionally converts " />" to "/>" via a state machine, + /// correctly spanning across multiple Write() calls. + /// + 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); + } + } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index d99dd33..6703d9f 100644 --- a/Program.cs +++ b/Program.cs @@ -6,6 +6,7 @@ using Sprache; using XNeedle.Parser; using XNeedle.Parser.Elements; using XNeedle.Transformation; +using XNeedle.Formats; namespace XNeedle { @@ -37,33 +38,11 @@ namespace XNeedle var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition)); var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace); masterRule.Transform(doc); - doc.Save(opts.Output, SaveOptions.DisableFormatting); - + 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 errs) {} } - - public static class DocumentExtensions - { - public static XmlDocument ToXmlDocument(this XDocument xDocument) - { - var xmlDocument = new XmlDocument(); - using(var xmlReader = xDocument.CreateReader()) - { - xmlDocument.Load(xmlReader); - } - return xmlDocument; - } - - public static XDocument ToXDocument(this XmlDocument xmlDocument) - { - using (var nodeReader = new XmlNodeReader(xmlDocument)) - { - nodeReader.MoveToContent(); - return XDocument.Load(nodeReader); - } - } - } } \ No newline at end of file From 154647083c7615a8278918222beffdfff72462af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 04:41:55 +0200 Subject: [PATCH 06/16] fix: port transformation layer from XmlDocument to XDocument - Thread runtime Context through expression trees via ParameterExpression instead of baking a parse-time constant (Node was always null) - Fix ExpressionPart to compile Func and invoke with live ctx - Coerce double arithmetic expressions to string before lambda wrapping - Remove HasAttributes guard in attr() that blocked new attribute creation - Replace GetLevel(XmlNode) with XElement.Parent-based implementation - Add cdata()/cdata(txt) functions to read/write CDATA nodes in place, preserving the XCData wrapper that SetValue() would destroy Clanker: claude-sonnet_4.6 --- Parser/Elements/ExpressionPart.cs | 6 +++--- Parser/XnParser.cs | 12 ++++++++++-- Transformation/ContextFunctions.cs | 12 ++++-------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Parser/Elements/ExpressionPart.cs b/Parser/Elements/ExpressionPart.cs index d003bb3..a7134f4 100644 --- a/Parser/Elements/ExpressionPart.cs +++ b/Parser/Elements/ExpressionPart.cs @@ -4,11 +4,11 @@ namespace XNeedle.Parser.Elements { public class ExpressionPart : Part { - public required Expression> Expr { get; set; } + public required Expression> Expr { get; set; } - public override void Operate(Context xtx) + public override void Operate(Context ctx) { - Expr.Compile()(); + Expr.Compile()(ctx); } } } \ No newline at end of file diff --git a/Parser/XnParser.cs b/Parser/XnParser.cs index 7c7c741..628ee34 100644 --- a/Parser/XnParser.cs +++ b/Parser/XnParser.cs @@ -13,6 +13,8 @@ namespace XNeedle.Parser { public class XnParser { + static readonly ParameterExpression CtxParam = Expression.Parameter(typeof(Context), "ctx"); + static Parser Operator(string op, ExpressionType opType) { return Parse.String(op).Token().Return(opType); @@ -46,7 +48,7 @@ namespace XNeedle.Parser 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(Expression.Constant(ctx)).ToArray()); + return Expression.Call(ctxMethodInfo, parameters.Prepend(CtxParam).ToArray()); } throw new ParseException(string.Format("Function '{0}({1})' does not exist.", name, @@ -114,7 +116,13 @@ namespace XNeedle.Parser static Parser ExprPart(Context ctx) { - return Expr(ctx).Select(body => new ExpressionPart{Expr = Expression.Lambda>(body)}); + 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>(stringBody, CtxParam) }; + }); } static readonly Parser XPath = diff --git a/Transformation/ContextFunctions.cs b/Transformation/ContextFunctions.cs index ad100ea..c9a4980 100644 --- a/Transformation/ContextFunctions.cs +++ b/Transformation/ContextFunctions.cs @@ -1,7 +1,4 @@ using System; -using System.IO; -using System.Text; -using System.Xml; using Sprache; using XNeedle.Parser; using XNeedle.Parser.Elements; @@ -16,13 +13,12 @@ namespace XNeedle.Transformation return $"#x{v:X8}"; } - public static int GetLevel(XmlNode node) + public static double GetLevel(Context ctx) { int level = 0; - XmlNode? currentNode = node; - while (null != (currentNode = currentNode.ParentNode)) + System.Xml.Linq.XElement? n = ctx.Node; + while (null != (n = n?.Parent)) level++; - return level; } public static double crc(Context ctx) @@ -51,7 +47,7 @@ namespace XNeedle.Transformation public static string attr(Context ctx, string name, string value) { - if (ctx.Node is null || !ctx.Node.HasAttributes) return ""; + if (ctx.Node is null) return ""; var attr = ctx.Node.Attribute(name); if (attr != null) { From c694fe8b178eb989ea9213143d248083fd7a5d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 04:45:42 +0200 Subject: [PATCH 07/16] Add cdata manipulators Clanker: claude-sonnet_4.6 --- Transformation/ContextFunctions.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Transformation/ContextFunctions.cs b/Transformation/ContextFunctions.cs index c9a4980..b4ef284 100644 --- a/Transformation/ContextFunctions.cs +++ b/Transformation/ContextFunctions.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Sprache; using XNeedle.Parser; using XNeedle.Parser.Elements; @@ -45,6 +46,28 @@ namespace XNeedle.Transformation return txt; } + public static string cdata(Context ctx) + { + if (ctx.Node is null) return ""; + var cdataNode = ctx.Node.Nodes().OfType().FirstOrDefault(); + return cdataNode?.Value ?? ""; + } + + public static string cdata(Context ctx, string txt) + { + if (ctx.Node is null) return ""; + var cdataNode = ctx.Node.Nodes().OfType().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 ""; From 1899db41568e1aa1a9be530dea76b8b594a1c556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 05:28:39 +0200 Subject: [PATCH 08/16] feat: implement ETG.2000-compatible CRC32 for XNeedle transforms - Add SourceLocation struct (XNeedle namespace) with FilePath, Encoding, and LineNumber (-1 sentinel when unavailable) - Add SLoc property to Context; Clone() propagates it for free - Expose XmlRoundtripWriter.DetectEncoding as internal static - Add XmlRoundtripWriter.CreateToString(filePath, encoding) returning a (writer, StringBuilder) tuple for in-memory style-preserving rendering - Update XmlFragParser.ChecksumData to accept tagName only; use OpeningTagAnchor to skip the opening tag robustly, and AnyChar.Until() to non-greedily capture content through the closing tag - Thread SourceLocation from Program.cs through Rule.Transform/Operate, refreshing LineNumber per node via IXmlLineInfo - Implement ContextFunctions.crc: render node via CreateToString, extract digest slice via ChecksumData, encode with SLoc.Encoding, return CRC32 as double for arithmetic compatibility Clanker: claude-sonnet_4.6 --- Common/SourceLocation.cs | 29 +++++++++++++++++++++++++++++ Formats/XmlRoundtripWriter.cs | 25 ++++++++++++++++++++++++- Parser/Elements/Context.cs | 1 + Parser/XmlFragParser.cs | 12 +++++------- Program.cs | 4 +++- Transformation/ContextFunctions.cs | 22 ++++++++++++++++++---- Transformation/Rule.cs | 6 +++++- 7 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 Common/SourceLocation.cs diff --git a/Common/SourceLocation.cs b/Common/SourceLocation.cs new file mode 100644 index 0000000..31fafd0 --- /dev/null +++ b/Common/SourceLocation.cs @@ -0,0 +1,29 @@ +using System.Text; + +namespace XNeedle +{ + /// + /// 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). + /// + public struct SourceLocation + { + public string FilePath { get; set; } + public Encoding Encoding { get; set; } + /// + /// 1-based line number of the associated node, or -1 if unknown. + /// + 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); + } +} diff --git a/Formats/XmlRoundtripWriter.cs b/Formats/XmlRoundtripWriter.cs index 6dcec1f..cf5918b 100644 --- a/Formats/XmlRoundtripWriter.cs +++ b/Formats/XmlRoundtripWriter.cs @@ -57,10 +57,33 @@ namespace XNeedle.Formats return new XmlRoundtripWriter(xmlWriter); } + /// + /// 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. + /// + 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); + } + /// /// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8. /// - private static Encoding DetectEncoding(byte[] raw) + internal static Encoding DetectEncoding(byte[] raw) { if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF) return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); diff --git a/Parser/Elements/Context.cs b/Parser/Elements/Context.cs index 53d9057..9f15c50 100644 --- a/Parser/Elements/Context.cs +++ b/Parser/Elements/Context.cs @@ -6,6 +6,7 @@ namespace XNeedle.Parser.Elements { public string? XPathSelector { get; set; } public XElement? Node { get; set; } + public SourceLocation SLoc { get; set; } public Context Clone() { diff --git a/Parser/XmlFragParser.cs b/Parser/XmlFragParser.cs index 88acf58..b41a716 100644 --- a/Parser/XmlFragParser.cs +++ b/Parser/XmlFragParser.cs @@ -13,15 +13,13 @@ namespace XNeedle.Parser from rbraket in Parse.Char('>') select new string([.. any]); } - public static Parser ChecksumData(string opening, string closing) + public static Parser ChecksumData(string tagName) { + var closing = ""; return - from pregarbage in Parse.AnyChar.Many() - from preamble in Parse.String(opening).Token() - from content in Parse.AnyChar.Many() - from epilouge in Parse.String("") - from postgarbage in Parse.AnyChar.Many() - select new string([.. content]) + epilouge; + from preamble in OpeningTagAnchor(tagName) + from content in Parse.AnyChar.Until(Parse.String(closing)) + select new string([.. content]) + closing; } } } \ No newline at end of file diff --git a/Program.cs b/Program.cs index 6703d9f..0e37562 100644 --- a/Program.cs +++ b/Program.cs @@ -37,7 +37,9 @@ namespace XNeedle { var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition)); var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace); - masterRule.Transform(doc); + 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}\""); diff --git a/Transformation/ContextFunctions.cs b/Transformation/ContextFunctions.cs index b4ef284..ed29e44 100644 --- a/Transformation/ContextFunctions.cs +++ b/Transformation/ContextFunctions.cs @@ -1,6 +1,8 @@ using System; using System.Linq; +using System.Text; using Sprache; +using XNeedle.Formats; using XNeedle.Parser; using XNeedle.Parser.Elements; @@ -25,12 +27,24 @@ namespace XNeedle.Transformation public static double crc(Context ctx) { if (ctx.Node is null) - { return 0; - } - int crc = 0xAFFE; - return (double)crc; + // 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) diff --git a/Transformation/Rule.cs b/Transformation/Rule.cs index 961844c..a51e897 100644 --- a/Transformation/Rule.cs +++ b/Transformation/Rule.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Xml; using System.Xml.Linq; using System.Xml.XPath; +using XNeedle; using XNeedle.Parser.Elements; namespace XNeedle.Transformation @@ -11,10 +12,11 @@ namespace XNeedle.Transformation public required string XPathSelector { get; set; } public required IEnumerable Body; - public void Transform(XDocument xml) + public void Transform(XDocument xml, SourceLocation sloc) { var context = new Context(); context.Node = xml.Root; + context.SLoc = sloc; Operate(context); } @@ -33,6 +35,8 @@ namespace XNeedle.Transformation { 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); From fa6d9e3acec8e25aa77f14db23cd781b2d9319bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 06:03:03 +0200 Subject: [PATCH 09/16] Add bang parsing --- Parser/XnParser.cs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Parser/XnParser.cs b/Parser/XnParser.cs index 628ee34..91f224c 100644 --- a/Parser/XnParser.cs +++ b/Parser/XnParser.cs @@ -8,6 +8,8 @@ using XNeedle.Parser.Elements; using Part = XNeedle.Parser.Elements.Part; using XNeedle.Transformation; using Rule = XNeedle.Transformation.Rule; +using System.Data; +using System.ComponentModel.DataAnnotations; namespace XNeedle.Parser { @@ -186,6 +188,24 @@ namespace XNeedle.Parser public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine }; + public static readonly Parser BangObject = + from c1 in Comment.SingleLineComment.Optional() + from bang in Parse.Char('!') + from name in Parse.AnyChar.Until(Parse.Char('(')) + from bopen in 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 c2 in Comment.SingleLineComment.Optional() + from expressions in ExprPart(ctx).Or(Parse.Ref(() => RuleObject)).Or(Parse.Ref(() => BangObject)).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 RuleObject = from c1 in Comment.SingleLineComment.Optional() from selector in Selector(XPath).Token() @@ -194,7 +214,7 @@ namespace XNeedle.Parser from open in Parse.Char('{') from ws2 in Parse.WhiteSpace.Many().Optional() from c2 in Comment.SingleLineComment.Optional() - from expressions in ExprPart(ctx).Or(Parse.Ref(() => RuleObject)).DelimitedBy(Parse.Char(';').Token()) + from expressions in ExprPart(ctx).Or(Parse.Ref(() => RuleObject)).Or(Parse.Ref(() => BangObject)).DelimitedBy(Parse.Char(';').Token()) from tailingsemi in Parse.Char(';').Optional() from ws3 in Parse.WhiteSpace.Many().Optional() from close in Parse.Char('}') From c93864c7e4b6b90682624df277359637db38f1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 06:21:59 +0200 Subject: [PATCH 10/16] Add Bang parser One minor bug found after claude review. Clanker: claude-sonnet_4.6 --- Parser/XnParser.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Parser/XnParser.cs b/Parser/XnParser.cs index 91f224c..cfb2030 100644 --- a/Parser/XnParser.cs +++ b/Parser/XnParser.cs @@ -188,11 +188,10 @@ namespace XNeedle.Parser public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine }; - public static readonly Parser BangObject = + public static readonly Parser BangObject = from c1 in Comment.SingleLineComment.Optional() from bang in Parse.Char('!') from name in Parse.AnyChar.Until(Parse.Char('(')) - from bopen in Parse.Char('(') from arguments in Argument.DelimitedBy(Parse.Char(',').Token()) from bclose in Parse.Char(')') let ctx = new Context{XPathSelector = "//*."} @@ -200,7 +199,10 @@ namespace XNeedle.Parser from open in Parse.Char('{') from ws2 in Parse.WhiteSpace.Many().Optional() from c2 in Comment.SingleLineComment.Optional() - from expressions in ExprPart(ctx).Or(Parse.Ref(() => RuleObject)).Or(Parse.Ref(() => BangObject)).DelimitedBy(Parse.Char(';').Token()) + 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('}') @@ -214,10 +216,14 @@ namespace XNeedle.Parser from open in Parse.Char('{') from ws2 in Parse.WhiteSpace.Many().Optional() from c2 in Comment.SingleLineComment.Optional() - from expressions in ExprPart(ctx).Or(Parse.Ref(() => RuleObject)).Or(Parse.Ref(() => BangObject)).DelimitedBy(Parse.Char(';').Token()) + 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}; + } } \ No newline at end of file From f4a563a9d4d9e845fc0e921e89f95bd0ce28d12a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 19:16:07 +0200 Subject: [PATCH 11/16] Add "!add" bang --- Common/SourceLocation.cs | 2 ++ Program.cs | 3 +++ Transformation/Bang.cs | 31 ++++++++++++++++++++++++++++++ Transformation/BangOperation.cs | 31 ++++++++++++++++++++++++++++++ Transformation/Bangs/AddBang.cs | 28 +++++++++++++++++++++++++++ Transformation/ContextFunctions.cs | 27 +++++++++++++++++++++++++- 6 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 Transformation/Bang.cs create mode 100644 Transformation/BangOperation.cs create mode 100644 Transformation/Bangs/AddBang.cs diff --git a/Common/SourceLocation.cs b/Common/SourceLocation.cs index 31fafd0..4422cfe 100644 --- a/Common/SourceLocation.cs +++ b/Common/SourceLocation.cs @@ -25,5 +25,7 @@ namespace XNeedle public SourceLocation WithLineNumber(int lineNumber) => new SourceLocation(FilePath, Encoding, lineNumber); + public SourceLocation AsSynthetic() => + new SourceLocation("", Encoding, -1); } } diff --git a/Program.cs b/Program.cs index 0e37562..cd3f6a0 100644 --- a/Program.cs +++ b/Program.cs @@ -35,6 +35,9 @@ namespace XNeedle static void RunOptions(Options opts) { + // Auto-discover operations via reflection + BangOperation.RegisterAll(); + var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition)); var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace); var encoding = XmlRoundtripWriter.DetectEncoding(File.ReadAllBytes(opts.Input)); diff --git a/Transformation/Bang.cs b/Transformation/Bang.cs new file mode 100644 index 0000000..f52b627 --- /dev/null +++ b/Transformation/Bang.cs @@ -0,0 +1,31 @@ +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 Arguments; + public required IEnumerable 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); + Console.WriteLine($"Operate Bang: {Name}, {string.Join(",", Arguments)}"); + foreach (var b in Body) + { + b.Operate(bodyctx); + } + } + } +} \ No newline at end of file diff --git a/Transformation/BangOperation.cs b/Transformation/BangOperation.cs new file mode 100644 index 0000000..385d3e8 --- /dev/null +++ b/Transformation/BangOperation.cs @@ -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 Registry = new(); + + public abstract string Name { get; } + + protected BangOperation() + { + Registry[Name] = this; + } + + public abstract Context Execute(Context ctx, IEnumerable 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); + } + } +} \ No newline at end of file diff --git a/Transformation/Bangs/AddBang.cs b/Transformation/Bangs/AddBang.cs new file mode 100644 index 0000000..d68683d --- /dev/null +++ b/Transformation/Bangs/AddBang.cs @@ -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 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}; + } + } +} \ No newline at end of file diff --git a/Transformation/ContextFunctions.cs b/Transformation/ContextFunctions.cs index ed29e44..acefd4a 100644 --- a/Transformation/ContextFunctions.cs +++ b/Transformation/ContextFunctions.cs @@ -5,6 +5,8 @@ using Sprache; using XNeedle.Formats; using XNeedle.Parser; using XNeedle.Parser.Elements; +using System.Xml.Linq; +using System.Xml.XPath; namespace XNeedle.Transformation { @@ -19,7 +21,7 @@ namespace XNeedle.Transformation public static double GetLevel(Context ctx) { int level = 0; - System.Xml.Linq.XElement? n = ctx.Node; + XElement? n = ctx.Node; while (null != (n = n?.Parent)) level++; return level; @@ -106,5 +108,28 @@ namespace XNeedle.Transformation } return ""; } + + public static string swap(Context ctx, string a_select, string b_select) + { + if (ctx.Node == null) + { + throw new ArgumentNullException("!seap 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 ""; + } } } \ No newline at end of file From 9a3f91e41c221629bcc964ccc08f6620bff8582f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 19:50:39 +0200 Subject: [PATCH 12/16] Add more CRUD. --- Transformation/Bangs/AfterBang.cs | 28 +++++++++++++++ Transformation/Bangs/BeforeBang.cs | 28 +++++++++++++++ Transformation/ContextFunctions.cs | 58 +++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 Transformation/Bangs/AfterBang.cs create mode 100644 Transformation/Bangs/BeforeBang.cs diff --git a/Transformation/Bangs/AfterBang.cs b/Transformation/Bangs/AfterBang.cs new file mode 100644 index 0000000..bd08428 --- /dev/null +++ b/Transformation/Bangs/AfterBang.cs @@ -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 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}; + } + } +} \ No newline at end of file diff --git a/Transformation/Bangs/BeforeBang.cs b/Transformation/Bangs/BeforeBang.cs new file mode 100644 index 0000000..4fc98bc --- /dev/null +++ b/Transformation/Bangs/BeforeBang.cs @@ -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 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}; + } + } +} \ No newline at end of file diff --git a/Transformation/ContextFunctions.cs b/Transformation/ContextFunctions.cs index acefd4a..008b16f 100644 --- a/Transformation/ContextFunctions.cs +++ b/Transformation/ContextFunctions.cs @@ -113,7 +113,7 @@ namespace XNeedle.Transformation { if (ctx.Node == null) { - throw new ArgumentNullException("!seap needs a reachable node"); + throw new ArgumentNullException("!swap needs a reachable node"); } var a_node = ctx.Node.XPathSelectElement(a_select); @@ -131,5 +131,61 @@ namespace XNeedle.Transformation 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 ""; + } } } \ No newline at end of file From 6f94002cf4185033a8653401a60e7bb9467aebb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 20:11:08 +0200 Subject: [PATCH 13/16] Fix comment handling Clanker: claude-sonnet_v4.6 --- Parser/XnParser.cs | 32 +++++++++++++++++++++++++++----- Program.cs | 4 +++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Parser/XnParser.cs b/Parser/XnParser.cs index cfb2030..f959107 100644 --- a/Parser/XnParser.cs +++ b/Parser/XnParser.cs @@ -6,10 +6,12 @@ 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 { @@ -189,16 +191,14 @@ namespace XNeedle.Parser public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine }; public static readonly Parser BangObject = - from c1 in Comment.SingleLineComment.Optional() 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 = "//*."} + 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 c2 in Comment.SingleLineComment.Optional() from expressions in Parse.Ref(() => BangObject) .Or(Parse.Ref(() => RuleObject)) .Or(ExprPart(ctx).Select(e => (Part)e)) @@ -209,13 +209,11 @@ namespace XNeedle.Parser select new Bang{Name = new string([.. name]), Arguments = arguments, Body = expressions}; public static readonly Parser RuleObject = - from c1 in Comment.SingleLineComment.Optional() 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 c2 in Comment.SingleLineComment.Optional() from expressions in Parse.Ref(() => BangObject) .Or(Parse.Ref(() => RuleObject)) .Or(ExprPart(ctx).Select(e => (Part)e)) @@ -224,6 +222,30 @@ namespace XNeedle.Parser 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(); + } } } \ No newline at end of file diff --git a/Program.cs b/Program.cs index cd3f6a0..fe57405 100644 --- a/Program.cs +++ b/Program.cs @@ -38,7 +38,9 @@ namespace XNeedle // Auto-discover operations via reflection BangOperation.RegisterAll(); - var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition)); + var xnSource = XnParser.StripComments(File.ReadAllText(opts.XnDefinition)); + Console.WriteLine(xnSource); + var masterRule = (Rule)XnParser.RuleObject.Parse(xnSource); var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace); var encoding = XmlRoundtripWriter.DetectEncoding(File.ReadAllBytes(opts.Input)); var sloc = new SourceLocation(opts.Input, encoding); From 08e5dd8ef5865321f3ed8d56e0a1a1288a379ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 20:20:50 +0200 Subject: [PATCH 14/16] Add error reporting --- Program.cs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/Program.cs b/Program.cs index fe57405..98488c3 100644 --- a/Program.cs +++ b/Program.cs @@ -38,9 +38,34 @@ namespace XNeedle // Auto-discover operations via reflection BangOperation.RegisterAll(); - var xnSource = XnParser.StripComments(File.ReadAllText(opts.XnDefinition)); - Console.WriteLine(xnSource); - var masterRule = (Rule)XnParser.RuleObject.Parse(xnSource); + 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); From 326cf578704ff36ecd090dd09da7784d6f5134e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 20:38:53 +0200 Subject: [PATCH 15/16] Final touches --- Parser/Elements/NullPart.cs | 7 +++++++ Program.cs | 6 +++--- Transformation/Bang.cs | 1 - XNeedle.csproj | 11 +++++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 Parser/Elements/NullPart.cs diff --git a/Parser/Elements/NullPart.cs b/Parser/Elements/NullPart.cs new file mode 100644 index 0000000..7cbc0c4 --- /dev/null +++ b/Parser/Elements/NullPart.cs @@ -0,0 +1,7 @@ +namespace XNeedle.Parser.Elements +{ + public class NullPart : Part + { + public override void Operate(Context ctx) { } + } +} diff --git a/Program.cs b/Program.cs index 98488c3..ae34d3e 100644 --- a/Program.cs +++ b/Program.cs @@ -15,13 +15,13 @@ namespace XNeedle public class Options { [Value(0, MetaName = "xn", Required = true, HelpText = "XN transformation definition")] - public string XnDefinition { get; set; } + public required string XnDefinition { get; set; } [Option('i', "input", HelpText = "Input XML file", Required = true)] - public string Input { get; set; } + public required string Input { get; set; } [Option('o', "output", HelpText = "Output XML file", Required = true)] - public string Output { get; set; } + public required string Output { get; set; } [Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")] public bool Verbose { get; set; } diff --git a/Transformation/Bang.cs b/Transformation/Bang.cs index f52b627..6b933b4 100644 --- a/Transformation/Bang.cs +++ b/Transformation/Bang.cs @@ -21,7 +21,6 @@ namespace XNeedle.Transformation } var bodyctx = bangop.Execute(ctx, Arguments); - Console.WriteLine($"Operate Bang: {Name}, {string.Join(",", Arguments)}"); foreach (var b in Body) { b.Operate(bodyctx); diff --git a/XNeedle.csproj b/XNeedle.csproj index 9dce10b..c9fccaa 100644 --- a/XNeedle.csproj +++ b/XNeedle.csproj @@ -1,5 +1,16 @@  + + Exe + net10.0 + enable + enable + xn + true + true + linux-x64 + + Exe net10.0 From 3bd86319a62e9494f5ade6655a91321133c6b83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20H=C3=B6glinger?= Date: Fri, 24 Apr 2026 20:39:10 +0200 Subject: [PATCH 16/16] Update README.md --- README.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index bdc6e45..24e11e2 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +```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.