Created
July 20, 2025 00:44
-
-
Save jnm2/eb3798fbb2a4e0c5a6a6495f94b6efc7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Buffers; | |
using System.Globalization; | |
public sealed class KdlWriter(TextWriter writer) | |
{ | |
private static readonly NumberFormatInfo DefaultNumberFormat = new() | |
{ | |
NumberGroupSeparator = "_", | |
NumberGroupSizes = [3], | |
}; | |
private static readonly string indentation = " "; | |
private int nodeDepth; | |
private bool atLineStart = true; | |
private bool insideNode; | |
private bool singleLineMode; | |
private bool isAfterProperty; | |
public SingleLineModeScope EnterSingleLineMode() | |
{ | |
singleLineMode = true; | |
return new SingleLineModeScope(this); | |
} | |
public readonly struct SingleLineModeScope(KdlWriter writer) : IDisposable | |
{ | |
public void Dispose() => writer.singleLineMode = false; | |
} | |
public void StartNode(string name) | |
{ | |
CheckIncompleteProperty(); | |
if (insideNode) | |
{ | |
writer.Write(" {"); | |
if (!singleLineMode) | |
writer.WriteLine(); | |
} | |
else | |
{ | |
if (!atLineStart) | |
writer.Write(';'); | |
} | |
if (singleLineMode) | |
{ | |
writer.Write(' '); | |
} | |
else | |
{ | |
for (var i = 0; i < nodeDepth; i++) | |
writer.Write(indentation); | |
} | |
writer.Write(name); | |
insideNode = true; | |
atLineStart = false; | |
nodeDepth++; | |
} | |
public void EndNode() | |
{ | |
CheckIncompleteProperty(); | |
nodeDepth--; | |
if (insideNode) | |
{ | |
if (!singleLineMode) | |
{ | |
writer.WriteLine(); | |
atLineStart = true; | |
} | |
insideNode = false; | |
} | |
else if (!atLineStart) | |
{ | |
writer.Write(" }"); | |
if (!singleLineMode) | |
{ | |
writer.WriteLine(); | |
atLineStart = true; | |
} | |
} | |
else | |
{ | |
for (var i = 0; i < nodeDepth; i++) | |
writer.Write(indentation); | |
writer.WriteLine('}'); | |
atLineStart = true; | |
} | |
} | |
public void WritePropertyName(string name, bool forceQuotes = false) | |
{ | |
CheckIncompleteProperty(); | |
WriteStringValue(name, forceQuotes); | |
writer.Write('='); | |
isAfterProperty = true; | |
} | |
private void CheckIncompleteProperty() | |
{ | |
if (isAfterProperty) | |
throw new InvalidOperationException("A value must be written next because a property has been started."); | |
} | |
private void StartValue() | |
{ | |
if (isAfterProperty) | |
isAfterProperty = false; | |
else | |
writer.Write(' '); | |
} | |
public void WriteStringValue(string value, bool forceQuotes = false) | |
{ | |
StartValue(); | |
if (!forceQuotes && IsValidIdentifierString(value)) | |
{ | |
writer.Write(value); | |
return; | |
} | |
writer.Write('"'); | |
var remainingValue = value.AsSpan(); | |
while (remainingValue.IndexOfAny(CharactersRequiringEscaping) is (not -1) and var indexRequiringEscaping) | |
{ | |
writer.Write(remainingValue[..indexRequiringEscaping]); | |
var charToEscape = remainingValue[indexRequiringEscaping]; | |
remainingValue = remainingValue[(indexRequiringEscaping + 1)..]; | |
switch (charToEscape) | |
{ | |
case '"': | |
writer.Write("\\\""); | |
break; | |
case '\\': | |
writer.Write("\\\\"); | |
break; | |
case '\r': | |
writer.Write("\\r"); | |
break; | |
case '\n': | |
writer.Write("\\n"); | |
break; | |
case '\b': | |
writer.Write("\\b"); | |
break; | |
case '\f': | |
writer.Write("\\f"); | |
break; | |
default: | |
if (char.IsSurrogate(charToEscape)) | |
throw new NotImplementedException("TODO: write the actual code point formed by the pair, which will take more than 4 hex digits"); | |
writer.Write("\\u"); | |
// If the next character is a hex digit, the only way to avoid confusion is to write the maximum number of hex digits. | |
var format = !remainingValue.IsEmpty && char.IsAsciiHexDigit(remainingValue[0]) ? "x6" : "x"; | |
writer.Write(((ushort)charToEscape).ToString(format, CultureInfo.InvariantCulture)); | |
break; | |
} | |
} | |
writer.Write(remainingValue); | |
writer.Write('"'); | |
} | |
public void WriteNumberValue(int value) | |
{ | |
StartValue(); | |
writer.Write(value.ToString("N0", DefaultNumberFormat)); | |
} | |
// https://kdl.dev/spec/#newline | |
private static readonly char[] NewLineChars = [.. "\r\n\u0085\v\f\u2028\u2029"]; | |
// https://kdl.dev/spec/#disallowed-literal-code-points | |
private static readonly char[] DisallowedLiteralCodePoints = [ | |
.. char.Range('\0', '\u0008'), .. char.Range('\u000e', '\u001f'), | |
'\u007f', | |
.. char.Range('\ud800', '\udfff'), | |
.. char.Range('\u200e', '\u200f'), .. char.Range('\u202a', '\u202e'), .. char.Range('\u2066', '\u2069'), | |
'\ufeff']; | |
// https://kdl.dev/spec/#name-quoted-string | |
private static readonly SearchValues<char> CharactersRequiringEscaping = SearchValues.Create(['"', '\\', .. NewLineChars, .. DisallowedLiteralCodePoints]); | |
private static readonly SearchValues<char> InvalidIdentifierStringChars = SearchValues.Create([ | |
// https://kdl.dev/spec/#non-identifier-characters | |
.. "(){}[]/\\\"#;=", | |
.. "\t \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202F\u205F\u3000", // https://kdl.dev/spec/#whitespace | |
.. NewLineChars, | |
.. DisallowedLiteralCodePoints, | |
]); | |
private static bool IsValidIdentifierString(string value) | |
{ | |
return | |
value is not [] | |
// https://kdl.dev/spec/#name-non-initial-characters | |
and not [>= '0' and <= '9', ..] | |
and not ['-' or '+' or '.', >= '0' and <= '9', ..] | |
and not ['-' or '+', '.', >= '0' and <= '9', ..] | |
&& !value.ContainsAny(InvalidIdentifierStringChars); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment