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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user