feat: implement ETG.2000-compatible CRC32 for XNeedle transforms

- Add SourceLocation struct (XNeedle namespace) with FilePath, Encoding,
  and LineNumber (-1 sentinel when unavailable)
- Add SLoc property to Context; Clone() propagates it for free
- Expose XmlRoundtripWriter.DetectEncoding as internal static
- Add XmlRoundtripWriter.CreateToString(filePath, encoding) returning a
  (writer, StringBuilder) tuple for in-memory style-preserving rendering
- Update XmlFragParser.ChecksumData to accept tagName only; use
  OpeningTagAnchor to skip the opening tag robustly, and AnyChar.Until()
  to non-greedily capture content through the closing tag
- Thread SourceLocation from Program.cs through Rule.Transform/Operate,
  refreshing LineNumber per node via IXmlLineInfo
- Implement ContextFunctions.crc: render node via CreateToString, extract
  digest slice via ChecksumData, encode with SLoc.Encoding, return CRC32
  as double for arithmetic compatibility

Clanker: claude-sonnet_4.6
This commit is contained in:
Dominic Höglinger 2026-04-24 05:28:39 +02:00
parent c694fe8b17
commit 1899db4156
7 changed files with 85 additions and 14 deletions

29
Common/SourceLocation.cs Normal file
View File

@ -0,0 +1,29 @@
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);
}
}

View File

@ -57,10 +57,33 @@ namespace XNeedle.Formats
return new XmlRoundtripWriter(xmlWriter); 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> /// <summary>
/// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8. /// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8.
/// </summary> /// </summary>
private static Encoding DetectEncoding(byte[] raw) internal static Encoding DetectEncoding(byte[] raw)
{ {
if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF) if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF)
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);

View File

@ -6,6 +6,7 @@ namespace XNeedle.Parser.Elements
{ {
public string? XPathSelector { get; set; } public string? XPathSelector { get; set; }
public XElement? Node { get; set; } public XElement? Node { get; set; }
public SourceLocation SLoc { get; set; }
public Context Clone() public Context Clone()
{ {

View File

@ -13,15 +13,13 @@ namespace XNeedle.Parser
from rbraket in Parse.Char('>') from rbraket in Parse.Char('>')
select new string([.. any]); select new string([.. any]);
} }
public static Parser<string> ChecksumData(string opening, string closing) public static Parser<string> ChecksumData(string tagName)
{ {
var closing = "</" + tagName + ">";
return return
from pregarbage in Parse.AnyChar.Many() from preamble in OpeningTagAnchor(tagName)
from preamble in Parse.String(opening).Token() from content in Parse.AnyChar.Until(Parse.String(closing))
from content in Parse.AnyChar.Many() select new string([.. content]) + closing;
from epilouge in Parse.String("</" + closing + ">")
from postgarbage in Parse.AnyChar.Many()
select new string([.. content]) + epilouge;
} }
} }
} }

View File

@ -37,7 +37,9 @@ namespace XNeedle
{ {
var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition)); var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition));
var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace); var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace);
masterRule.Transform(doc); var encoding = XmlRoundtripWriter.DetectEncoding(File.ReadAllBytes(opts.Input));
var sloc = new SourceLocation(opts.Input, encoding);
masterRule.Transform(doc, sloc);
using var writer = XmlRoundtripWriter.Create(opts.Input, opts.Output); using var writer = XmlRoundtripWriter.Create(opts.Input, opts.Output);
doc.WriteTo(writer); doc.WriteTo(writer);
Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\""); Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\"");

View File

@ -1,6 +1,8 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Text;
using Sprache; using Sprache;
using XNeedle.Formats;
using XNeedle.Parser; using XNeedle.Parser;
using XNeedle.Parser.Elements; using XNeedle.Parser.Elements;
@ -25,12 +27,24 @@ namespace XNeedle.Transformation
public static double crc(Context ctx) public static double crc(Context ctx)
{ {
if (ctx.Node is null) if (ctx.Node is null)
{
return 0; return 0;
}
int crc = 0xAFFE;
return (double)crc; // Step 1: Render the element to a string, preserving source style
var (writer, buffer) = XmlRoundtripWriter.CreateToString(ctx.SLoc.FilePath, ctx.SLoc.Encoding);
ctx.Node.WriteTo(writer);
writer.Flush();
string xml = buffer.ToString();
// Step 2: Extract all text after the opening tag through and including the closing tag
string tagName = ctx.Node.Name.LocalName;
string data = XmlFragParser.ChecksumData(tagName).Parse(xml);
// Step 3: Digest into CRC32 using the source encoding
var crc32 = new System.IO.Hashing.Crc32();
crc32.Append(ctx.SLoc.Encoding.GetBytes(data));
uint result = BitConverter.ToUInt32(crc32.GetCurrentHash());
return (double)result;
} }
public static string text(Context ctx) public static string text(Context ctx)

View File

@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Xml; using System.Xml;
using System.Xml.Linq; using System.Xml.Linq;
using System.Xml.XPath; using System.Xml.XPath;
using XNeedle;
using XNeedle.Parser.Elements; using XNeedle.Parser.Elements;
namespace XNeedle.Transformation namespace XNeedle.Transformation
@ -11,10 +12,11 @@ namespace XNeedle.Transformation
public required string XPathSelector { get; set; } public required string XPathSelector { get; set; }
public required IEnumerable<Part> Body; public required IEnumerable<Part> Body;
public void Transform(XDocument xml) public void Transform(XDocument xml, SourceLocation sloc)
{ {
var context = new Context(); var context = new Context();
context.Node = xml.Root; context.Node = xml.Root;
context.SLoc = sloc;
Operate(context); Operate(context);
} }
@ -33,6 +35,8 @@ namespace XNeedle.Transformation
{ {
var loopCtx = ctx.Clone(); var loopCtx = ctx.Clone();
loopCtx.Node = n; loopCtx.Node = n;
var lineInfo = (IXmlLineInfo)n;
loopCtx.SLoc = ctx.SLoc.WithLineNumber(lineInfo.HasLineInfo() ? lineInfo.LineNumber : -1);
foreach (var b in Body) foreach (var b in Body)
{ {
b.Operate(loopCtx); b.Operate(loopCtx);