Merge MVP features #1
235
Formats/XmlRoundtripWriter.cs
Normal file
235
Formats/XmlRoundtripWriter.cs
Normal 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 (<Tag/> vs <Tag />),
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Program.cs
27
Program.cs
@ -6,6 +6,7 @@ using Sprache;
|
|||||||
using XNeedle.Parser;
|
using XNeedle.Parser;
|
||||||
using XNeedle.Parser.Elements;
|
using XNeedle.Parser.Elements;
|
||||||
using XNeedle.Transformation;
|
using XNeedle.Transformation;
|
||||||
|
using XNeedle.Formats;
|
||||||
|
|
||||||
namespace XNeedle
|
namespace XNeedle
|
||||||
{
|
{
|
||||||
@ -37,33 +38,11 @@ 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);
|
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}\"");
|
Console.WriteLine($"Applied \"{opts.XnDefinition}\" to \"{opts.Input}\" and saved result to \"{opts.Output}\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void HandleParseError(IEnumerable<Error> errs) {}
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user