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