diff --git a/Formats/XmlRoundtripWriter.cs b/Formats/XmlRoundtripWriter.cs
new file mode 100644
index 0000000..6dcec1f
--- /dev/null
+++ b/Formats/XmlRoundtripWriter.cs
@@ -0,0 +1,235 @@
+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);
+ }
+
+ ///
+ /// Detects encoding from BOM first, then the XML declaration, then defaults to UTF-8.
+ ///
+ 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 ───────────────────────────────────────────────
+
+ ///
+ /// 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/Program.cs b/Program.cs
index d99dd33..6703d9f 100644
--- a/Program.cs
+++ b/Program.cs
@@ -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 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);
- }
- }
- }
}
\ No newline at end of file