From 1a9605cb2dd27058f36ba22aaa126b310555c3e1 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] Add initial MVP --- .gitignore | 484 +++++++++++++++++++++++++++++ Common/SourceLocation.cs | 31 ++ Formats/XmlRoundtripWriter.cs | 258 +++++++++++++++ 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/NullPart.cs | 7 + Parser/Elements/Part.cs | 7 + Parser/XmlFragParser.cs | 25 ++ Parser/XnParser.cs | 251 +++++++++++++++ Program.cs | 80 +++++ README.md | 32 +- Transformation/Bang.cs | 30 ++ Transformation/BangOperation.cs | 31 ++ Transformation/Bangs/AddBang.cs | 28 ++ Transformation/Bangs/AfterBang.cs | 28 ++ Transformation/Bangs/BeforeBang.cs | 28 ++ Transformation/ContextFunctions.cs | 191 ++++++++++++ Transformation/Rule.cs | 47 +++ XNeedle.csproj | 27 ++ 22 files changed, 1638 insertions(+), 15 deletions(-) create mode 100644 .gitignore create mode 100644 Common/SourceLocation.cs create mode 100644 Formats/XmlRoundtripWriter.cs 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/NullPart.cs create mode 100644 Parser/Elements/Part.cs create mode 100644 Parser/XmlFragParser.cs create mode 100644 Parser/XnParser.cs create mode 100644 Program.cs create mode 100644 Transformation/Bang.cs create mode 100644 Transformation/BangOperation.cs create mode 100644 Transformation/Bangs/AddBang.cs create mode 100644 Transformation/Bangs/AfterBang.cs create mode 100644 Transformation/Bangs/BeforeBang.cs create mode 100644 Transformation/ContextFunctions.cs create mode 100644 Transformation/Rule.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/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 = ""; + 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 + + + + + + + + +