Merge MVP features #1

Merged
dominic merged 16 commits from prototype into master 2026-04-24 18:48:16 +00:00
2 changed files with 238 additions and 24 deletions
Showing only changes of commit 2d54bec41d - Show all commits

View File

@ -0,0 +1,235 @@
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>
/// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8.
/// </summary>
private static Encoding DetectEncoding(byte[] raw)
{
if (raw.Length >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF)
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
if (raw.Length >= 2 && raw[0] == 0xFF && raw[1] == 0xFE)
return Encoding.Unicode; // UTF-16 LE
if (raw.Length >= 2 && raw[0] == 0xFE && raw[1] == 0xFF)
return Encoding.BigEndianUnicode; // UTF-16 BE
// No BOM — probe the XML declaration for an encoding= attribute
var probe = Encoding.ASCII.GetString(raw, 0, Math.Min(raw.Length, 256));
var match = Regex.Match(probe, @"encoding\s*=\s*[""']([^""']+)[""']",
RegexOptions.IgnoreCase);
if (match.Success)
{
try { return Encoding.GetEncoding(match.Groups[1].Value); }
catch (ArgumentException) { /* unknown name, fall through */ }
}
return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
}
// ── XmlWriter abstract member delegation ──────────────────────────────
public override void WriteStartDocument()
=> _inner.WriteStartDocument();
public override void WriteStartDocument(bool standalone)
=> _inner.WriteStartDocument(standalone);
public override void WriteEndDocument()
=> _inner.WriteEndDocument();
public override void WriteDocType(string name, string? pubid, string? sysid, string? subset)
=> _inner.WriteDocType(name, pubid, sysid, subset);
public override void WriteStartElement(string? prefix, string localName, string? ns)
=> _inner.WriteStartElement(prefix, localName, ns);
public override void WriteEndElement()
=> _inner.WriteEndElement();
public override void WriteFullEndElement()
=> _inner.WriteFullEndElement();
public override void WriteStartAttribute(string? prefix, string localName, string? ns)
=> _inner.WriteStartAttribute(prefix, localName, ns);
public override void WriteEndAttribute()
=> _inner.WriteEndAttribute();
public override void WriteCData(string? text)
=> _inner.WriteCData(text);
public override void WriteComment(string? text)
=> _inner.WriteComment(text);
public override void WriteProcessingInstruction(string name, string? text)
=> _inner.WriteProcessingInstruction(name, text);
public override void WriteEntityRef(string name)
=> _inner.WriteEntityRef(name);
public override void WriteCharEntity(char ch)
=> _inner.WriteCharEntity(ch);
public override void WriteWhitespace(string? ws)
=> _inner.WriteWhitespace(ws);
public override void WriteString(string? text)
=> _inner.WriteString(text);
public override void WriteSurrogateCharEntity(char lowChar, char highChar)
=> _inner.WriteSurrogateCharEntity(lowChar, highChar);
public override void WriteChars(char[] buffer, int index, int count)
=> _inner.WriteChars(buffer, index, count);
public override void WriteRaw(string data)
=> _inner.WriteRaw(data);
public override void WriteRaw(char[] buffer, int index, int count)
=> _inner.WriteRaw(buffer, index, count);
public override void WriteBase64(byte[] buffer, int index, int count)
=> _inner.WriteBase64(buffer, index, count);
public override string? LookupPrefix(string ns)
=> _inner.LookupPrefix(ns);
public override void Flush()
=> _inner.Flush();
public override void Close()
=> _inner.Close();
protected override void Dispose(bool disposing)
{
if (disposing) _inner.Close();
base.Dispose(disposing);
}
// ── FilteringTextWriter ───────────────────────────────────────────────
/// <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

@ -6,6 +6,7 @@ using Sprache;
using XNeedle.Parser;
using XNeedle.Parser.Elements;
using XNeedle.Transformation;
using XNeedle.Formats;
namespace XNeedle
{
@ -37,33 +38,11 @@ namespace XNeedle
var masterRule = (Rule)XnParser.RuleObject.Parse(File.ReadAllText(opts.XnDefinition));
var doc = XDocument.Load(opts.Input, LoadOptions.PreserveWhitespace);
masterRule.Transform(doc);
doc.Save(opts.Output, SaveOptions.DisableFormatting);
using var writer = XmlRoundtripWriter.Create(opts.Input, opts.Output);
doc.WriteTo(writer);
Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\"");
}
static void HandleParseError(IEnumerable<Error> errs) {}
}
public static class DocumentExtensions
{
public static XmlDocument ToXmlDocument(this XDocument xDocument)
{
var xmlDocument = new XmlDocument();
using(var xmlReader = xDocument.CreateReader())
{
xmlDocument.Load(xmlReader);
}
return xmlDocument;
}
public static XDocument ToXDocument(this XmlDocument xmlDocument)
{
using (var nodeReader = new XmlNodeReader(xmlDocument))
{
nodeReader.MoveToContent();
return XDocument.Load(nodeReader);
}
}
}
}