Compare commits

...

8 Commits

Author SHA1 Message Date
2d8c52ece0 Fix integer operations (#6)
Previously the arithmetic attempted bitwise binary ops for double types,
add explicit casts for these kind of binary ops.

Reviewed-on: #6
2026-04-27 03:27:28 +00:00
efc5301728 Add documentation, fix README.md (#5)
Reviewed-on: #5
2026-04-26 18:08:49 +00:00
4629002ef0 Enhance parser and add a few functions, fix hex parsing (#4)
1. Unify all numbers under double type
A bit lazy, but is easily maintainable. Precision is enough for most use cases.

2. Add more functions
Add `num`, `parselit`, `str`.

3. Fix hex parser error
Previously the "0x" was greedy and matches single zeros, which then raised a parsing error because it couldn't match a hex number.

Reviewed-on: #4
2026-04-26 17:42:51 +00:00
103b9dbb98 Merge pull request 'Rename to XN, restructure repository' (#3) from rename into master
Reviewed-on: #3
2026-04-25 19:17:02 +00:00
2bbf339466 Rename to XN, restructure repository 2026-04-25 21:14:19 +02:00
01e08f1902 Update grammar, expand CRC32 function (#2)
XN grammar and transformation engine are updated, including support for bitwise operations, improved function parameter handling, and enhanced error reporting.

Reviewed-on: dominic/XNeedle#2
2026-04-25 10:26:05 +00:00
0de7edee70 Add CONTRIBUTORS.md, update year in LICENSE.txt 2026-04-24 21:05:30 +02:00
1a9605cb2d Add initial MVP 2026-04-24 20:51:36 +02:00
29 changed files with 2184 additions and 25 deletions

484
.gitignore vendored Normal file
View File

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

14
CONTRIBUTORS.md Normal file
View File

@ -0,0 +1,14 @@
# Contributors
## Original Author
**Dominic Hoegliner**
Author and maintainer of XN.
All intellectual property rights retained by the original author.
---
Contributions via pull request are welcome.
By submitting a contribution, you agree that your contribution
will be licensed under the same BSD 3-Clause License as this project,
and that the original authorship of Dominic Hoegliner is acknowledged.

View File

@ -1,4 +1,4 @@
Copyright 2025 DOMINIC HOEGLINER
Copyright 2026 Dominic Hoeglinger
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

View File

@ -1,24 +1,25 @@
# XNeedle
# XN
A simple tool for injecting faults into XML documents.
XML Needle (XN) is a simple tool for injecting faults into XML documents.
## Description
The purpose of the tool is to hide a metaphorical "needle in a haystack"
in large XML files, such as an EtherCAT SubDevice Information or ESI file.
Therefore it is a FIT (fault injection test) tool, but can be used off-label.
Therefore it is a FIT (fault injection test) tool,
but can be used for general purpose manipulation.
To live up to its name, XPath is utilized to specify precise locations
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.
## Language Specification
The XNeedle language consists of three distinct parts.
The XN language consists of three distinct parts.
1. Expressions
@ -40,34 +41,34 @@ 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"); };
}
```
3. Bangs
Bangs are special function calls which take either a string or XPath as parameters,
and make structural changes to the document.
and make addition changes to the document.
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"); }
$("Name") { text("FSoE Outputs"); }
# Select the Entry/Index #x7000 and change it to #x9001
$(Entry/Index[.="#x7000"]) { text("#x9001"); }
$("Entry/Index[.='#x7000']") { text("#x9001"); }
# Select the Entry with Index #x7001 and SubIndex 4, remove it
$(Entry/Index[.="#x7001"]/../SubIndex[.="4"]/..) { remove(); }
$("Entry/Index[.='#x7001']/../SubIndex[.="4"]/..") { remove(); }
# Select the 3rd Entry
$(Entry[3])
$("Entry[3]")
{
# Add a tag before the above selection
!before("Entry")
@ -81,19 +82,21 @@ $(RxPdo/Index[.="#x1600"]/..)
}
}
# Swap Entries
!swap("Entry[3]", "Entry[4]");
swap("Entry[3]", "Entry[4]");
}
```
## Project Specifications
4. Comments
The project will be written in C# using .NET 8 and released under a BSD 3-Clause permissive license.
Comments are prefixed with the '#' character.
Libraries which will be utilized:
- Sprache: Monadic parser for reading in XNeedle specifications
- System.IO.Hashing: For calculating checksums to use in the resulting document
- Command Line Parser: For parsing command line options
## Timeline
## Running the command
A feature complete release is expected in Q2 of 2026.
```bash
xn sample.xn -i input.xml -o output.xml
```
The file `sample.xn` is the transformation specification as outlined above.
For input and output, see `input.xml` and `output.xml` respectively.
Refer to the help text using the `--help` switch for more details.

76
doc/01_syntax.md Normal file
View File

@ -0,0 +1,76 @@
# XN Syntax
## Comments
Comments are single line only prefixed with the pound sign ('#').
## Rules
Rules consists of two parts, a selector, and a block containing statements.
A selector is an XPath string surrounded with braces prefixed with a dollar sign.
The XPath selects zero or more document nodes which will serve as the context for the statement block.
## Bangs
Bangs are function calls combined with a statement block.
They are prefixed with an exclaimation mark.
A bang function always produces context nodes for the statement block.
## Statement Block
A statement block are multiple statements terminated with a semicolon,
surrounded by curly braces.
## Statements
A statement can be an expression, a function call, a rule or a bang.
To note is that a nested rule will use its parents context to refine the selection further.
## Expressions
An expression can consist of function calls, operators, strings or numbers.
Operators supported:
| Operator | Function |
|----------|----------------------|
| + | addition |
| - | subtraction |
| * | multiplication |
| / | division |
| ** | power |
| & | bitwise and |
| | | bitwise or |
| ^ | bitwise exclusive or |
| .. | string concatenate |
## Example
```
# Select the RxPdo with Index 0x1600
$("RxPdo/Index[.="#x1600"]/..")
{
# Function call
swap("Entry[3]", "Entry[4]");
# Of that RxPdo, select the 3rd Entry via a nested rule
$("Entry[3]")
{
# This bang has "RxPdo/Index[.="#x1600"]/../Entry[3]" as its context
!before("Entry")
{
# Function call contains an expression, which evaluates here to "!#xFFFF"
!add("Index") { text("!"..hexlit(2**16-1)); };
!add("SubIndex") { text("3"); }
!add("BitLen") { text("256"); }
!add("Name") { text("Test"); }
!add("DataType") { text("UINT"); }
}
}
}
# One .xn can contain multiple rules
$("//Module[@Crc32]")
{
attr("Crc32", hexlit(crc32()));
};
```

204
doc/02_functions.md Normal file
View File

@ -0,0 +1,204 @@
# Function Reference
## `num(value:string, numbase:number = 10):number`
Converts a string to a number in respect of the input base.
Example:
```xn
# Add 1 to all LcId
$("//Name[@LcId]")
{
attr("LcId", str(num(attr("LcId")) + 1));
};
```
## `str(value:number, numbase:number = 10):string`
Converts a number to a string in any base.
Example:
```xn
# Set ProfileNo to "#o377"
$("//ProfileNo")
{
text("#o"..str(2**8-1, 8));
};
```
## `hexlit(value:number):string`
Produces a hex literal from an integer with lowercase digits.
Example:
```xn
# Set all module Crc32 to #x00c0ffee
$("//Module[@Crc32]")
{
attr("Crc32", hexlit(0xC0FFEE));
};
```
## `parselit(literal:string, base:number = 16):string`
Parses a literal number to an integer.
Example:
```xn
# Increase all RevisionNo by 1
$("//Device/Type[@RevisionNo]")
{
attr("RevisionNo", hexlit(parselit(attr("RevisionNo")) + 1));
};
```
## `lower(text:string):string`
Converts a string to all lower case text.
Example:
```xn
# Convert all device names to lower case
$("//Device/Type")
{
$("Name") { cdata(lower(cdata())); };
}
```
## `upper(text:string):string`
Converts a string to all upper case text.
Example:
```xn
# Convert all Name tag text in Index nodes from RxPdo to upper case.
$("//RxPdo/Index/Name")
{
$("Name") { text(upper(text())); };
}
```
## `crc32(poly:number = 0x04C11DB7, init:number = 0, xorOut:number = 0, reflected:bool = false):number`
Calculates the CRC32 checksum from the rendered document starting after the tag close of the context
node up to and including the closing tag.
Example:
For this example `[IND]` is whitespace for indent,
`[CR]` for carriage return and `[LF]` for line feed.
Rendered document:
```xml
<root>[CR][LF]
[IND]<node attribute="yes">[CR][LF]
[IND][IND]<child/>
[IND][IND]Text
[IND]</node>
</root>
XN
```xn
# Calculate CRC of node and store in "crc" attribute as uppercase hexlit
$("//Node") { attr("crc", upper(hexlit(crc32())); }
```
Digested fragment:
```txt
[CR][LF]
[IND][IND]<child/>
[IND][IND]Text
[IND]</node>
```
## `text():string` / `text(value:string):string`
Get or set the text of the context node.
Example:
```xn
# Prefix all Name tag text in Index nodes from TxPdo with "Fault".
$("//TxPdo/Index/Name")
{
$("Name") { text("Fault" .. text()); };
}
```
## `cdata():string` / `cdata(value:string):string`
Get or set the CDATA of the context node.
Example:
```xn
# For Device with this ProductCode and Revision, modify its CDATA text
$("//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']/..")
{
$("Name[@LcId='1033']") { cdata("LE2904, 4 Ch. Danger Output 24V, 0.5A, TwinUNSAFE"); };
}
```
## `attr(name:string):string` / `attr(name:string, value:string):string`
Get or set an attribute of the context node by name.
Example:
```xn
# For Device with this ProductCode and Revision, set the revision to #xc0deaffe
$("//Device/Type[@ProductCode='#x0B583052' and @RevisionNo='#x00110000']")
{
attr("RevisionNo", hexlit(0xC0DEAFFE));
}
```
## `swap(xpath_a:string, xpath_b:string):string = ""`
Swaps two nodes by XPath in the current cotext node.
Example:
```xn
# Swap first and second Entry in RxPdo of Index 0x1600
$("//RxPdo/Index[.='#x1600']/..") { swap("Entry[1]", "Entry[2]"); };
```
## `remove():string = ""`
Removes the context node from the document.
Example:
```xn
# Remove all modules
$("//Module") { remove(); };
```
## `rename(name:string):string = ""`
Renames the context node.
Example:
```xn
# Rename all Device nodes to DeviceX
$("//Device") { rename("DeviceX"); };
```
## `rename(xpath:string, name:string):string = ""`
Renames any nodes matching the XPath in the context node.
Example:
```xn
# Rename all Device nodes Name tags to Label
$("//Device") { rename("Name", "Label"); };
```
## `require(message:string):string = ""`
If the context node is not present, emit an error message.
Example:
```xn
# Emit an error if no Module tags are in the document
$("//Module") { require("No Module definitions!"); };
```

53
doc/03_bangs.md Normal file
View File

@ -0,0 +1,53 @@
# Bang Reference
## `!add(name:string)`
Appends a new node into the context.
Example:
```xn
# Add 1 to all LcId
$("//RxPdo/Index[.="#x1600"]/..")
{
# Add a new Entry with attribute "added" set to "true"
!add("Entry")
{
attr("added", "true");
}
};
```
## `!before(name:string)`
Appends a new node before the context.
Example:
```xn
# Add 1 to all LcId
$("//RxPdo/Index[.="#x1600"]/..")
{
# Add a new TxPdo with attribute "added" set to "true" before the above node selection
!before("TxPdo")
{
attr("added", "true");
}
};
```
## `!after(name:string)`
Appends a new node after the context.
Example:
```xn
# Add 1 to all LcId
$("//RxPdo/Index[.="#x1600"]/..")
{
# Add a new TxPdo with attribute "added" set to "true" after the above node selection
!after("TxPdo")
{
attr("added", "true");
}
};
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

300
src/Parser/XnParser.cs Normal file
View File

@ -0,0 +1,300 @@
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;
using System.Security.AccessControl;
namespace XNeedle.Parser
{
public class XnParser
{
static readonly ParameterExpression CtxParam = Expression.Parameter(typeof(Context), "ctx");
static Parser<ExpressionType> Operator(string op, ExpressionType opType)
{
return Parse.String(op).Token().Return(opType);
}
//static MethodInfo ConcatMethod = typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) });
static readonly Parser<ExpressionType> Add = Operator("+", ExpressionType.AddChecked);
static readonly Parser<ExpressionType> Subtract = Operator("-", ExpressionType.SubtractChecked);
static readonly Parser<ExpressionType> Multiply = Operator("*", ExpressionType.MultiplyChecked);
static readonly Parser<ExpressionType> Divide = Operator("/", ExpressionType.Divide);
static readonly Parser<ExpressionType> Modulo = Operator("%", ExpressionType.Modulo);
static readonly Parser<ExpressionType> Power = Operator("**", ExpressionType.Power);
static readonly Parser<ExpressionType> BitwiseXor = Operator("^", ExpressionType.ExclusiveOr);
static readonly Parser<ExpressionType> BitwiseAnd = Operator("&", ExpressionType.And);
static readonly Parser<ExpressionType> BitwiseOr = Operator("|", ExpressionType.Or);
static Parser<Expression> Function(Context ctx)
{
return
from name in Identifier
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);
// Find by name and fill in optional parameters with their default values
var ctxMethodInfo = typeof(ContextFunctions).GetTypeInfo()
.GetMethods()
.FirstOrDefault(m =>
{
if (m.Name != name) return false;
var mp = m.GetParameters();
if (mp.Length == 0 || mp[0].ParameterType != typeof(Context)) return false;
int required = mp.Skip(1).Count(p => !p.HasDefaultValue);
return parameters.Length >= required && parameters.Length <= mp.Length - 1;
});
if (ctxMethodInfo != null)
{
var mp = ctxMethodInfo.GetParameters();
var args = mp.Skip(1).Select((p, i) =>
i < parameters.Length
? parameters[i]
: Expression.Constant(p.DefaultValue, p.ParameterType)
).ToArray();
return Expression.Call(ctxMethodInfo, args.Prepend(CtxParam).ToArray());
}
throw new ParseException(string.Format("Function '{0}({1})' does not exist.", name,
string.Join(",", parameters.Select(e => e.Type.Name))));
}
// This needs to match as one atomic token, else with a separate String("0x") the 0 is consumed,
// which triggers a parsing error on single zero numbers
public static readonly Parser<string> HexNumber =
Parse.Regex(@"0x[0-9a-fA-F]+").Token()
.Select(s => s.Substring(2));
public static readonly Parser<string> BooleanLiteral =
from value in Parse.String("true").XOr(Parse.String("false")).Text()
select value;
static readonly Parser<Expression> NumberConstant =
HexNumber.Select(x => Expression.Constant((double)int.Parse(x, System.Globalization.NumberStyles.HexNumber)))
.XOr(BooleanLiteral.Select(x => Expression.Constant(x == "true" ? 1.0 : 0.0)))
.XOr(Parse.Decimal.Select(x => Expression.Constant(double.Parse(x))))
.Named("number");
private static readonly Parser<Expression> StringConstant =
from open in Parse.Char('"')
from value in Parse.CharExcept('"').Many().Text()
from close in Parse.Char('"')
select Expression.Constant(new string(value));
static Parser<Expression> Factor(Context ctx)
{
return (from lparen in Parse.Char('(')
from expr in Parse.Ref(() => Expr(ctx))
from rparen in Parse.Char(')')
select expr).Named("expression")
.XOr(NumberConstant)
.XOr(StringConstant)
.XOr(Function(ctx));
}
static Parser<Expression> Operand(Context ctx)
{
return ((from sign in Parse.Char('-')
from factor in Factor(ctx)
select Expression.Negate(factor)
).XOr(Factor(ctx))).Token();
}
static Parser<Expression> InnerTerm(Context ctx)
{
return Parse.ChainRightOperator(Power, Operand(ctx), Expression.MakeBinary);
}
static Parser<Expression> Term(Context ctx)
{
return Parse.ChainOperator(Multiply.Or(Divide), InnerTerm(ctx), Expression.MakeBinary);
}
static Parser<Expression> IntegerTerm(Context ctx)
{
// Integer operations can't deal with doubles, so we need to make unary casts to integer and back
return Parse.ChainOperator(Multiply.Or(Divide).Or(Modulo).Or(BitwiseXor).Or(BitwiseAnd).Or(BitwiseOr),
InnerTerm(ctx), (bt,left,right)=>Expression.MakeUnary(
ExpressionType.Convert, Expression.MakeBinary(bt,
Expression.MakeUnary(ExpressionType.Convert, left, typeof(long)),
Expression.MakeUnary(ExpressionType.Convert, right, typeof(long))),
typeof(double)));
}
static Parser<Expression> ConcatExpr(Context ctx)
{
var concatMethod = typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) });
return
from left in StringConstant.Or(Function(ctx))
from op in Parse.String("..").Token()
from right in StringConstant.Or(Function(ctx))
select Expression.Add(left, right, concatMethod);
}
static Parser<Expression> ArithExpr(Context ctx)
{
return Parse.ChainOperator(Add.Or(Subtract), IntegerTerm(ctx).Or(Term(ctx)), Expression.MakeBinary);
}
static Parser<Expression> Expr(Context ctx)
{
return ConcatExpr(ctx).Or(ArithExpr(ctx));
}
static Parser<ExpressionPart> ExprPart(Context ctx)
{
return Expr(ctx).Select(body =>
{
Expression stringBody = body.Type == typeof(string)
? body
: Expression.Call(body, typeof(object).GetMethod("ToString", Type.EmptyTypes)!);
return new ExpressionPart { Expr = Expression.Lambda<Func<Context, string>>(stringBody, CtxParam) };
});
}
static readonly Parser<string> XPath =
from path in Parse.LetterOrDigit
.XOr(Parse.Char('/'))
.XOr(Parse.Char(' '))
.XOr(Parse.Char('['))
.XOr(Parse.Char(']'))
.XOr(Parse.Char('='))
.XOr(Parse.Char('.'))
.XOr(Parse.Char('_'))
.XOr(Parse.Char('*'))
.XOr(Parse.Char('@'))
.XOr(Parse.Char('#'))
.XOr(Parse.Char('('))
.XOr(Parse.Char(')'))
.XOr(Parse.Char('\''))
.Many()
select new string(path.ToArray());
static Parser<T> Selector<T>(Parser<T> selection)
{
return from open in Parse.String("$(\"").Token()
from s in selection
from close in Parse.String("\")").Token()
select s;
}
public static readonly Parser<string> XPathSelector = Selector(XPath);
public static readonly Parser<string> Identifier =
from first in Parse.Letter.Once()
from rest in Parse.LetterOrDigit.XOr(Parse.Char('-')).XOr(Parse.Char('_')).Many()
select new string(first.Concat(rest).ToArray());
private static readonly Parser<string> StringObject =
from open in Parse.Char('"')
from value in Parse.CharExcept('"').Many().Text()
from close in Parse.Char('"')
select new string(value);
private static readonly Parser<string> NumberObject =
Parse.DecimalInvariant
.Select(s => double.Parse(s, CultureInfo.InvariantCulture))
.Select(v => v.ToString());
public static readonly Parser<string> Argument =
from argument in StringObject.Or(NumberObject)
select argument;
public static readonly Parser<Call> CallObject =
from identifier in Identifier
from open in Parse.Char('(')
from arguments in Argument.DelimitedBy(Parse.Char(',').Token())
from close in Parse.Char(')')
select new Call{Identifier = new string(identifier.ToArray()), Arguments = arguments};
public static CommentParser Comment = new CommentParser { Single = "#", NewLine = Environment.NewLine };
public static readonly Parser<Part> BangObject =
from bang in Parse.Char('!')
from name in Parse.AnyChar.Until(Parse.Char('('))
from arguments in Argument.DelimitedBy(Parse.Char(',').Token())
from bclose in Parse.Char(')')
let ctx = new Context{XPathSelector = "//*."}
from ws1 in Parse.WhiteSpace.Many().Optional()
from open in Parse.Char('{')
from ws2 in Parse.WhiteSpace.Many().Optional()
from expressions in (
from expr in Parse.Ref(() => BangObject)
.Or(Parse.Ref(() => RuleObject))
.Or(ExprPart(ctx).Select(e => (Part)e))
from semi in Parse.Char(';').Token()
select expr
).Many()
from ws3 in Parse.WhiteSpace.Many().Optional()
from close in Parse.Char('}')
select new Bang{Name = new string([.. name]), Arguments = arguments, Body = expressions};
public static readonly Parser<Part> RuleObject =
from selector in Selector(XPath).Token()
let ctx = new Context{XPathSelector = selector}
from ws1 in Parse.WhiteSpace.Many().Optional()
from open in Parse.Char('{')
from ws2 in Parse.WhiteSpace.Many().Optional()
from expressions in (
from expr in Parse.Ref(() => BangObject)
.Or(Parse.Ref(() => RuleObject))
.Or(ExprPart(ctx).Select(e => (Part)e))
from semi in Parse.Char(';').Token()
select expr
).Many()
from ws3 in Parse.WhiteSpace.Many().Optional()
from close in Parse.Char('}')
select new Rule{XPathSelector = selector, Body = expressions};
public static readonly Parser<IEnumerable<Part>> Rules =
(from rule in RuleObject.Token()
from semi in Parse.Char(';').Token()
select rule)
.Many()
.End();
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();
}
}
}

97
src/Program.cs Normal file
View File

@ -0,0 +1,97 @@
using System;
using System.Xml;
using System.Xml.Linq;
using CommandLine;
using Sprache;
using XNeedle.Parser;
using XNeedle.Parser.Elements;
using XNeedle.Transformation;
using XNeedle.Formats;
namespace XNeedle
{
class Program
{
public class Options
{
[Value(0, MetaName = "xn", Required = true, HelpText = "XN transformation definition")]
public required string XnDefinition { get; set; }
[Option('i', "input", HelpText = "Input XML file", Required = true)]
public required string Input { get; set; }
[Option('o', "output", HelpText = "Output XML file", Required = true)]
public required string Output { get; set; }
[Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")]
public bool Verbose { get; set; }
}
static void Main(string[] args)
{
CommandLine.Parser.Default.ParseArguments<Options>(args)
.WithParsed(RunOptions)
.WithNotParsed(HandleParseError);
}
static void RunOptions(Options opts)
{
// Auto-discover operations via reflection
BangOperation.RegisterAll();
var rawSource = File.ReadAllText(opts.XnDefinition);
var xnSource = XnParser.StripComments(rawSource);
IEnumerable<Part> rules;
try
{
rules = XnParser.Rules.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)
{
var msg = ex.Message;
var hint = (msg.Contains("expected }") && !msg.Contains("expected ';'"))
? "Hint: a missing ';' terminator may be located just after this position"
: "";
Console.Error.WriteLine($"{opts.XnDefinition}({line},{col}): parse error: {msg}");
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}");
if (!string.IsNullOrEmpty(hint))
{
Console.Error.WriteLine($"\n{hint}");
}
}
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);
try
{
foreach (var rule in rules.OfType<Rule>())
rule.Transform(doc, sloc);
}
catch (RequireException ex)
{
Console.Error.WriteLine($"{ex.SLoc.FilePath}({ex.SLoc.LineNumber}): require error: {ex.UserMessage}");
return;
}
using var writer = XmlRoundtripWriter.Create(opts.Input, opts.Output);
doc.WriteTo(writer);
Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\"");
}
static void HandleParseError(IEnumerable<Error> errs) {}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,289 @@
using System;
using System.Globalization;
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 double num(Context ctx, string value, double numbase = 10)
{
if (string.IsNullOrWhiteSpace(value))
{
return 0;
}
string cleanedValue = value.Trim();
if (numbase != 10)
{
cleanedValue = cleanedValue.TrimStart('0');
cleanedValue = cleanedValue.TrimStart('x');
// Convert to Int64 to not lose any fidelity
return Convert.ToInt64(cleanedValue, (int)numbase);
}
return double.Parse(cleanedValue, CultureInfo.InvariantCulture);
}
public static string str(Context ctx, double value, double numbase = 10)
{
if (numbase == 10)
{
return $"{value}";
}
// Ignore fraction for other bases by casting to long
return Convert.ToString((long)value, (int)numbase);
}
public static string hexlit(Context ctx, double value)
{
uint v = (uint)value;
return $"#x{v:x8}";
}
public static double parselit(Context ctx, string literal, double numbase = 16)
{
if (string.IsNullOrWhiteSpace(literal) || literal.Length <= 2)
{
throw new ArgumentException($"Cannot parse literal \"{literal}\"");
}
return (double)Convert.ToInt32(literal.Substring(2), (int)numbase);
}
public static string lower(Context ctx, string text)
{
return text.ToLower();
}
public static string upper(Context ctx, string text)
{
return text.ToUpper();
}
public static double GetLevel(Context ctx)
{
int level = 0;
XElement? n = ctx.Node;
while (null != (n = n?.Parent))
level++;
return level;
}
private static uint ComputeCrc32(byte[] data, uint poly, uint init, uint xorOut, bool reflected)
{
uint crc = init;
uint polyReflected = ReflectBits(poly, 32);
foreach (byte b in data)
{
if (reflected)
{
crc ^= b;
for (int i = 0; i < 8; i++)
crc = (crc & 1) != 0 ? (crc >> 1) ^ polyReflected : crc >> 1;
}
else
{
crc ^= (uint)b << 24;
for (int i = 0; i < 8; i++)
crc = (crc & 0x80000000u) != 0 ? (crc << 1) ^ poly : crc << 1;
}
}
return crc ^ xorOut;
}
private static uint ReflectBits(uint value, int bits)
{
uint reflected = 0;
for (int i = 0; i < bits; i++)
{
if ((value & (1u << i)) != 0)
reflected |= 1u << (bits - 1 - i);
}
return reflected;
}
public static double crc32(Context ctx, double poly = 0x04C11DB7, double init = 0, double xorOut = 0, double reflected = 0.0)
{
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);
//byte[] fragBytes = ctx.SLoc.Encoding.GetBytes(data);
byte[] fragBytes = Encoding.UTF8.GetBytes(data);
// Step 3: Digest into CRC32 using parameterized CRC32 - by default MPEG-2
return (double)ComputeCrc32(fragBytes, (uint)poly, (uint)init, (uint)xorOut, reflected == 1.0);
}
public static string text(Context ctx)
{
if (ctx.Node is null) return "";
return ctx.Node.Value;
}
public static string text(Context ctx, string txt)
{
if (ctx.Node is null) return "";
ctx.Node.SetValue(txt);
return txt;
}
public static string cdata(Context ctx)
{
if (ctx.Node is null) return "";
var cdataNode = ctx.Node.Nodes().OfType<System.Xml.Linq.XCData>().FirstOrDefault();
return cdataNode?.Value ?? "";
}
public static string cdata(Context ctx, string txt)
{
if (ctx.Node is null) return "";
var cdataNode = ctx.Node.Nodes().OfType<System.Xml.Linq.XCData>().FirstOrDefault();
if (cdataNode != null)
{
cdataNode.Value = txt;
}
else
{
ctx.Node.Add(new System.Xml.Linq.XCData(txt));
}
return txt;
}
public static string attr(Context ctx, string name, string value)
{
if (ctx.Node is null) return "";
var attr = ctx.Node.Attribute(name);
if (attr != null)
{
attr.Value = value;
}
else
{
ctx.Node.SetAttributeValue(name, value);
}
return "";
}
public static string attr(Context ctx, string name)
{
var attr = ctx.Node?.Attribute(name);
if (attr != null)
{
return attr.Value;
}
return "";
}
public static string swap(Context ctx, string a_select, string b_select)
{
if (ctx.Node == null)
{
throw new ArgumentNullException("!swap needs a reachable node");
}
var a_node = ctx.Node.XPathSelectElement(a_select);
var b_node = ctx.Node.XPathSelectElement(b_select);
if (a_node == null || b_node == null)
{
throw new InvalidOperationException($"!swap could not find nodes for \"{a_select}\" and/or \"{b_select}\".");
}
var placeholder = new XElement("__placeholder__");
a_node.ReplaceWith(placeholder);
b_node.ReplaceWith(a_node);
placeholder.ReplaceWith(b_node);
return "";
}
public static string remove(Context ctx, string select)
{
if (ctx.Node == null)
{
throw new ArgumentNullException("!remove needs a reachable node");
}
// Use ToList to make the lazy/generator enumeration concrete before applying operations
var nodes = ctx.Node.XPathSelectElements(select).ToList();
foreach (var node in nodes)
{
node.Remove();
}
return "";
}
public static string rename(Context ctx, string newName)
{
if (ctx.Node is null) return "";
var replacement = new XElement(newName, ctx.Node.Attributes(), ctx.Node.Nodes());
ctx.Node.ReplaceWith(replacement);
ctx.Node = replacement; // update context to point to new element
return "";
}
public static string rename(Context ctx, string select, string newName)
{
if (ctx.Node is null) return "";
int atIdx = select.IndexOf('@');
if (atIdx >= 0)
{
// "Node@Attr" — rename an attribute
var elementSelect = select[..atIdx].TrimEnd();
var attrName = select[(atIdx + 1)..].TrimStart();
var target = string.IsNullOrEmpty(elementSelect)
? ctx.Node
: ctx.Node.XPathSelectElement(elementSelect);
var attr = target?.Attribute(attrName);
if (attr is null) return "";
attr.Remove();
target!.SetAttributeValue(newName, attr.Value);
}
else
{
// plain XPath select — rename element
var target = ctx.Node.XPathSelectElement(select);
if (target is null) return "";
var replacement = new XElement(newName, target.Attributes(), target.Nodes());
target.ReplaceWith(replacement);
}
return "";
}
public static string require(Context ctx, string message = "Required node not present")
{
if (ctx.Node == null)
{
throw new RequireException{SLoc=ctx.SLoc, UserMessage=message};
}
else
{
Console.WriteLine(ctx.Node.ToString());
}
return "";
}
}
}

View File

@ -0,0 +1,15 @@
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 RequireException : Exception
{
public SourceLocation SLoc { get; set; }
public required string UserMessage { get; set; }
}
}

View File

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

27
src/xn.csproj Normal file
View File

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

5
xn.slnx Normal file
View File

@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/xn.csproj" />
</Folder>
</Solution>