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/Common/SourceLocation.cs b/Common/SourceLocation.cs
new file mode 100644
index 0000000..4422cfe
--- /dev/null
+++ b/Common/SourceLocation.cs
@@ -0,0 +1,31 @@
+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);
+ public SourceLocation AsSynthetic() =>
+ new SourceLocation("", Encoding, -1);
+ }
+}
diff --git a/Formats/XmlRoundtripWriter.cs b/Formats/XmlRoundtripWriter.cs
new file mode 100644
index 0000000..cf5918b
--- /dev/null
+++ b/Formats/XmlRoundtripWriter.cs
@@ -0,0 +1,258 @@
+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);
+ }
+
+ ///
+ /// 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.
+ ///
+ internal static Encoding DetectEncoding(byte[] raw)
+ {
+ if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF)
+ return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
+ if (raw.Length >= 2 && raw[0] == 0xFF && raw[1] == 0xFE)
+ return Encoding.Unicode; // UTF-16 LE
+ if (raw.Length >= 2 && raw[0] == 0xFE && raw[1] == 0xFF)
+ return Encoding.BigEndianUnicode; // UTF-16 BE
+
+ // No BOM — probe the XML declaration for an encoding= attribute
+ var probe = Encoding.ASCII.GetString(raw, 0, Math.Min(raw.Length, 256));
+ var match = Regex.Match(probe, @"encoding\s*=\s*[""']([^""']+)[""']",
+ RegexOptions.IgnoreCase);
+ if (match.Success)
+ {
+ try { return Encoding.GetEncoding(match.Groups[1].Value); }
+ catch (ArgumentException) { /* unknown name, fall through */ }
+ }
+
+ return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
+ }
+
+ // ── XmlWriter abstract member delegation ──────────────────────────────
+
+ public override void WriteStartDocument()
+ => _inner.WriteStartDocument();
+ public override void WriteStartDocument(bool standalone)
+ => _inner.WriteStartDocument(standalone);
+ public override void WriteEndDocument()
+ => _inner.WriteEndDocument();
+ public override void WriteDocType(string name, string? pubid, string? sysid, string? subset)
+ => _inner.WriteDocType(name, pubid, sysid, subset);
+ public override void WriteStartElement(string? prefix, string localName, string? ns)
+ => _inner.WriteStartElement(prefix, localName, ns);
+ public override void WriteEndElement()
+ => _inner.WriteEndElement();
+ public override void WriteFullEndElement()
+ => _inner.WriteFullEndElement();
+ public override void WriteStartAttribute(string? prefix, string localName, string? ns)
+ => _inner.WriteStartAttribute(prefix, localName, ns);
+ public override void WriteEndAttribute()
+ => _inner.WriteEndAttribute();
+ public override void WriteCData(string? text)
+ => _inner.WriteCData(text);
+ public override void WriteComment(string? text)
+ => _inner.WriteComment(text);
+ public override void WriteProcessingInstruction(string name, string? text)
+ => _inner.WriteProcessingInstruction(name, text);
+ public override void WriteEntityRef(string name)
+ => _inner.WriteEntityRef(name);
+ public override void WriteCharEntity(char ch)
+ => _inner.WriteCharEntity(ch);
+ public override void WriteWhitespace(string? ws)
+ => _inner.WriteWhitespace(ws);
+ public override void WriteString(string? text)
+ => _inner.WriteString(text);
+ public override void WriteSurrogateCharEntity(char lowChar, char highChar)
+ => _inner.WriteSurrogateCharEntity(lowChar, highChar);
+ public override void WriteChars(char[] buffer, int index, int count)
+ => _inner.WriteChars(buffer, index, count);
+ public override void WriteRaw(string data)
+ => _inner.WriteRaw(data);
+ public override void WriteRaw(char[] buffer, int index, int count)
+ => _inner.WriteRaw(buffer, index, count);
+ public override void WriteBase64(byte[] buffer, int index, int count)
+ => _inner.WriteBase64(buffer, index, count);
+ public override string? LookupPrefix(string ns)
+ => _inner.LookupPrefix(ns);
+ public override void Flush()
+ => _inner.Flush();
+ public override void Close()
+ => _inner.Close();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) _inner.Close();
+ base.Dispose(disposing);
+ }
+
+ // ── FilteringTextWriter ───────────────────────────────────────────────
+
+ ///
+ /// 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/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..9f15c50
--- /dev/null
+++ b/Parser/Elements/Context.cs
@@ -0,0 +1,16 @@
+using System.Xml.Linq;
+
+namespace XNeedle.Parser.Elements
+{
+ public class Context
+ {
+ public string? XPathSelector { get; set; }
+ public XElement? Node { get; set; }
+ public SourceLocation SLoc { get; set; }
+
+ public Context Clone()
+ {
+ return (Context)this.MemberwiseClone();
+ }
+ }
+}
\ 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..a7134f4
--- /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 ctx)
+ {
+ Expr.Compile()(ctx);
+ }
+ }
+}
\ No newline at end of file
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/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..b41a716
--- /dev/null
+++ b/Parser/XmlFragParser.cs
@@ -0,0 +1,25 @@
+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 tagName)
+ {
+ var closing = "" + tagName + ">";
+ return
+ 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/Parser/XnParser.cs b/Parser/XnParser.cs
new file mode 100644
index 0000000..f959107
--- /dev/null
+++ b/Parser/XnParser.cs
@@ -0,0 +1,251 @@
+using System;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
+using Sprache;
+using System.Globalization;
+using XNeedle.Parser.Elements;
+using Part = XNeedle.Parser.Elements.Part;
+using NullPart = XNeedle.Parser.Elements.NullPart;
+using XNeedle.Transformation;
+using Rule = XNeedle.Transformation.Rule;
+using System.Data;
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+
+namespace XNeedle.Parser
+{
+ public class XnParser
+ {
+ static readonly ParameterExpression CtxParam = Expression.Parameter(typeof(Context), "ctx");
+
+ static Parser 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(CtxParam).ToArray());
+ }
+
+ throw new ParseException(string.Format("Function '{0}({1})' does not exist.", name,
+ string.Join(",", parameters.Select(e => e.Type.Name))));
+ }
+
+ static readonly Parser 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 =>
+ {
+ 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 =
+ 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 BangObject =
+ from bang in Parse.Char('!')
+ from name in Parse.AnyChar.Until(Parse.Char('('))
+ from arguments in Argument.DelimitedBy(Parse.Char(',').Token())
+ from bclose in Parse.Char(')')
+ let ctx = new Context{XPathSelector = "//*."}
+ from ws1 in Parse.WhiteSpace.Many().Optional()
+ from open in Parse.Char('{')
+ from ws2 in Parse.WhiteSpace.Many().Optional()
+ from expressions in Parse.Ref(() => BangObject)
+ .Or(Parse.Ref(() => RuleObject))
+ .Or(ExprPart(ctx).Select(e => (Part)e))
+ .DelimitedBy(Parse.Char(';').Token())
+ from tailingsemi in Parse.Char(';').Optional()
+ from ws3 in Parse.WhiteSpace.Many().Optional()
+ from close in Parse.Char('}')
+ select new Bang{Name = new string([.. name]), Arguments = arguments, Body = expressions};
+
+ public static readonly Parser RuleObject =
+ from selector in Selector(XPath).Token()
+ let ctx = new Context{XPathSelector = selector}
+ from ws1 in Parse.WhiteSpace.Many().Optional()
+ from open in Parse.Char('{')
+ from ws2 in Parse.WhiteSpace.Many().Optional()
+ from expressions in Parse.Ref(() => BangObject)
+ .Or(Parse.Ref(() => RuleObject))
+ .Or(ExprPart(ctx).Select(e => (Part)e))
+ .DelimitedBy(Parse.Char(';').Token())
+ from tailingsemi in Parse.Char(';').Optional()
+ from ws3 in Parse.WhiteSpace.Many().Optional()
+ from close in Parse.Char('}')
+ select new Rule{XPathSelector = selector, Body = expressions};
+
+
+ static public string StripComments(string source)
+ {
+ var result = new StringBuilder();
+ foreach (var line in source.Split('\n'))
+ {
+ bool inSingle = false, inDouble = false;
+ int commentIdx = -1;
+ for (int i = 0; i < line.Length; i++)
+ {
+ char c = line[i];
+ if (c == '\'' && !inDouble) inSingle = !inSingle;
+ else if (c == '"' && !inSingle) inDouble = !inDouble;
+ else if (c == '#' && !inSingle && !inDouble)
+ {
+ commentIdx = i;
+ break;
+ }
+ }
+ result.Append(commentIdx >= 0 ? line[..commentIdx] : line);
+ result.Append('\n');
+ }
+ return result.ToString();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..ae34d3e
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Xml;
+using System.Xml.Linq;
+using CommandLine;
+using Sprache;
+using XNeedle.Parser;
+using XNeedle.Parser.Elements;
+using XNeedle.Transformation;
+using XNeedle.Formats;
+
+namespace XNeedle
+{
+ class Program
+ {
+ public class Options
+ {
+ [Value(0, MetaName = "xn", Required = true, HelpText = "XN transformation definition")]
+ public required string XnDefinition { get; set; }
+
+ [Option('i', "input", HelpText = "Input XML file", Required = true)]
+ public required string Input { get; set; }
+
+ [Option('o', "output", HelpText = "Output XML file", Required = true)]
+ public required string Output { get; set; }
+
+ [Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")]
+ public bool Verbose { get; set; }
+ }
+ static void Main(string[] args)
+ {
+ CommandLine.Parser.Default.ParseArguments(args)
+ .WithParsed(RunOptions)
+ .WithNotParsed(HandleParseError);
+ }
+
+ static void RunOptions(Options opts)
+ {
+ // Auto-discover operations via reflection
+ BangOperation.RegisterAll();
+
+ var rawSource = File.ReadAllText(opts.XnDefinition);
+ var xnSource = XnParser.StripComments(rawSource);
+ Rule masterRule;
+ try
+ {
+ masterRule = (Rule)XnParser.RuleObject.Parse(xnSource);
+ }
+ catch (ParseException ex)
+ {
+ var lines = rawSource.Split('\n');
+ int line = ex.Position?.Line ?? -1;
+ int col = ex.Position?.Column ?? -1;
+ bool showPosition = (line != -1) && (col != -1);
+
+ if (showPosition)
+ {
+ Console.Error.WriteLine($"{opts.XnDefinition}({line},{col}): parse error: {ex.Message}");
+ var srcLine = line <= lines.Length ? lines[line - 1].TrimEnd() : "";
+ var pointer = new string(' ', Math.Max(0, col - 1)) + "^";
+ Console.Error.WriteLine($" {srcLine}");
+ Console.Error.WriteLine($" {pointer}");
+ }
+ else
+ {
+ Console.Error.WriteLine($"{opts.XnDefinition}: parse error: {ex.Message}");
+ }
+ return;
+ }
+ var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace);
+ var encoding = XmlRoundtripWriter.DetectEncoding(File.ReadAllBytes(opts.Input));
+ var sloc = new SourceLocation(opts.Input, encoding);
+ masterRule.Transform(doc, sloc);
+ using var writer = XmlRoundtripWriter.Create(opts.Input, opts.Output);
+ doc.WriteTo(writer);
+ Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\"");
+ }
+
+ static void HandleParseError(IEnumerable errs) {}
+ }
+}
\ No newline at end of file
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.
diff --git a/Transformation/Bang.cs b/Transformation/Bang.cs
new file mode 100644
index 0000000..6b933b4
--- /dev/null
+++ b/Transformation/Bang.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using System.Xml;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using XNeedle;
+using XNeedle.Parser.Elements;
+
+namespace XNeedle.Transformation
+{
+ public class Bang : Part
+ {
+ public required string Name { get; set; }
+ public required IEnumerable 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);
+ 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/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
new file mode 100644
index 0000000..008b16f
--- /dev/null
+++ b/Transformation/ContextFunctions.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Linq;
+using System.Text;
+using Sprache;
+using XNeedle.Formats;
+using XNeedle.Parser;
+using XNeedle.Parser.Elements;
+using System.Xml.Linq;
+using System.Xml.XPath;
+
+namespace XNeedle.Transformation
+{
+ public class ContextFunctions
+ {
+ public static string hex(Context ctx, double value)
+ {
+ uint v = (uint)value;
+ return $"#x{v:X8}";
+ }
+
+ public static double GetLevel(Context ctx)
+ {
+ int level = 0;
+ XElement? n = ctx.Node;
+ while (null != (n = n?.Parent))
+ level++;
+ return level;
+ }
+ public static double crc(Context ctx)
+ {
+ if (ctx.Node is null)
+ return 0;
+
+ // Step 1: Render the element to a string, preserving source style
+ var (writer, buffer) = XmlRoundtripWriter.CreateToString(ctx.SLoc.FilePath, ctx.SLoc.Encoding);
+ ctx.Node.WriteTo(writer);
+ writer.Flush();
+ string xml = buffer.ToString();
+
+ // Step 2: Extract all text after the opening tag through and including the closing tag
+ string tagName = ctx.Node.Name.LocalName;
+ string data = XmlFragParser.ChecksumData(tagName).Parse(xml);
+
+ // Step 3: Digest into CRC32 using the source encoding
+ var crc32 = new System.IO.Hashing.Crc32();
+ crc32.Append(ctx.SLoc.Encoding.GetBytes(data));
+ uint result = BitConverter.ToUInt32(crc32.GetCurrentHash());
+
+ return (double)result;
+ }
+
+ public static string text(Context ctx)
+ {
+ if (ctx.Node is null) return "";
+ return ctx.Node.Value;
+ }
+
+ public static string text(Context ctx, string txt)
+ {
+ if (ctx.Node is null) return "";
+ ctx.Node.SetValue(txt);
+ return txt;
+ }
+
+ public static string cdata(Context ctx)
+ {
+ if (ctx.Node is null) return "";
+ var cdataNode = ctx.Node.Nodes().OfType().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 "";
+ var attr = ctx.Node.Attribute(name);
+ if (attr != null)
+ {
+ attr.Value = value;
+ }
+ else
+ {
+ ctx.Node.SetAttributeValue(name, value);
+ }
+ return "";
+ }
+
+ public static string attr(Context ctx, string name)
+ {
+ var attr = ctx.Node?.Attribute(name);
+ if (attr != null)
+ {
+ return attr.Value;
+ }
+ return "";
+ }
+
+ public static string swap(Context ctx, string a_select, string b_select)
+ {
+ if (ctx.Node == null)
+ {
+ throw new ArgumentNullException("!swap needs a reachable node");
+ }
+
+ var a_node = ctx.Node.XPathSelectElement(a_select);
+ var b_node = ctx.Node.XPathSelectElement(b_select);
+
+ if (a_node == null || b_node == null)
+ {
+ throw new InvalidOperationException($"!swap could not find nodes for \"{a_select}\" and/or \"{b_select}\".");
+ }
+
+ var placeholder = new XElement("__placeholder__");
+ a_node.ReplaceWith(placeholder);
+ b_node.ReplaceWith(a_node);
+ placeholder.ReplaceWith(b_node);
+
+ return "";
+ }
+
+ public static string remove(Context ctx, string select)
+ {
+ if (ctx.Node == null)
+ {
+ throw new ArgumentNullException("!remove needs a reachable node");
+ }
+
+ // Use ToList to make the lazy/generator enumeration concrete before applying operations
+ var nodes = ctx.Node.XPathSelectElements(select).ToList();
+
+ foreach (var node in nodes)
+ {
+ node.Remove();
+ }
+
+ return "";
+ }
+
+ public static string rename(Context ctx, string newName)
+ {
+ if (ctx.Node is null) return "";
+ var replacement = new XElement(newName, ctx.Node.Attributes(), ctx.Node.Nodes());
+ ctx.Node.ReplaceWith(replacement);
+ ctx.Node = replacement; // update context to point to new element
+ return "";
+ }
+
+ public static string rename(Context ctx, string select, string newName)
+ {
+ if (ctx.Node is null) return "";
+
+ int atIdx = select.IndexOf('@');
+ if (atIdx >= 0)
+ {
+ // "Node@Attr" — rename an attribute
+ var elementSelect = select[..atIdx].TrimEnd();
+ var attrName = select[(atIdx + 1)..].TrimStart();
+ var target = string.IsNullOrEmpty(elementSelect)
+ ? ctx.Node
+ : ctx.Node.XPathSelectElement(elementSelect);
+ var attr = target?.Attribute(attrName);
+ if (attr is null) return "";
+ attr.Remove();
+ target!.SetAttributeValue(newName, attr.Value);
+ }
+ else
+ {
+ // plain XPath select — rename element
+ var target = ctx.Node.XPathSelectElement(select);
+ if (target is null) return "";
+ var replacement = new XElement(newName, target.Attributes(), target.Nodes());
+ target.ReplaceWith(replacement);
+ }
+ return "";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Transformation/Rule.cs b/Transformation/Rule.cs
new file mode 100644
index 0000000..a51e897
--- /dev/null
+++ b/Transformation/Rule.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using System.Xml;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using XNeedle;
+using XNeedle.Parser.Elements;
+
+namespace XNeedle.Transformation
+{
+ public class Rule : Part
+ {
+ public required string XPathSelector { get; set; }
+ public required IEnumerable Body;
+
+ public void Transform(XDocument xml, SourceLocation sloc)
+ {
+ var context = new Context();
+ context.Node = xml.Root;
+ context.SLoc = sloc;
+ Operate(context);
+ }
+
+ public override void Operate(Context ctx)
+ {
+ if(ctx.Node is null)
+ {
+ return;
+ }
+ var nodes = ctx.Node.XPathSelectElements(XPathSelector);
+ if(nodes is null)
+ {
+ return;
+ }
+ foreach (XElement n in nodes)
+ {
+ var loopCtx = ctx.Clone();
+ loopCtx.Node = n;
+ var lineInfo = (IXmlLineInfo)n;
+ loopCtx.SLoc = ctx.SLoc.WithLineNumber(lineInfo.HasLineInfo() ? lineInfo.LineNumber : -1);
+ foreach (var b in Body)
+ {
+ b.Operate(loopCtx);
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/XNeedle.csproj b/XNeedle.csproj
new file mode 100644
index 0000000..c9fccaa
--- /dev/null
+++ b/XNeedle.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ xn
+ true
+ true
+ linux-x64
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+