From f4f7d622f457890a146139436b93658029f9e031 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Tue, 17 Mar 2026 22:37:09 +0800 Subject: [PATCH 1/5] Add lightweight YAML parser and comprehensive tests Introduced a new YAML parser in Yaml.cs supporting string keys, scalars, sequences, nested mappings, and block scalars, with robust error handling for malformed input. Added YamlTests.cs with extensive NUnit tests covering empty cases, key-value pairs, null/boolean/numeric values, quoted strings, comments, nested structures, sequences, block scalars, structural errors, and edge cases. --- src/Support/Yaml.cs | 600 +++++++++++++++++++++++ tests/Support/YamlTests.cs | 960 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1560 insertions(+) create mode 100644 src/Support/Yaml.cs create mode 100644 tests/Support/YamlTests.cs diff --git a/src/Support/Yaml.cs b/src/Support/Yaml.cs new file mode 100644 index 00000000..54ea6fa2 --- /dev/null +++ b/src/Support/Yaml.cs @@ -0,0 +1,600 @@ +// Copyright (C) Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Support +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Runtime.CompilerServices; + + /// + /// Provides simplified YAML parsing. + /// + /// + /// + /// This class supports a limited YAML subset intended for lightweight metadata scenarios. It supports + /// string keys, scalar values, simple sequences, simple nested mappings based on indentation, and + /// block scalars. + /// + /// + /// The parser does not support all YAML features and is designed for simplicity and performance in + /// common use cases. It does not handle complex constructs such as anchors, aliases, or advanced tags. + /// For more advanced YAML processing needs, consider using a full-featured YAML library. + /// + /// + public static class Yaml + { + /// + /// Gets a reusable empty read-only YAML mapping. + /// + /// + /// A reusable empty read-only YAML mapping. + /// + public static readonly IReadOnlyDictionary Empty = new Dictionary(0, StringComparer.Ordinal); + + /// + /// Parses the specified YAML text into a read-only dictionary. + /// + /// The YAML text to parse. + /// A read-only dictionary containing parsed keys and values. + /// Thrown when is . + /// Thrown when the YAML content is malformed or uses unsupported constructs. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IReadOnlyDictionary Parse(ReadOnlySpan text) + { + return text.IsEmpty ? Empty : new Parser(text).Parse(); + } + + /// + /// A lightweight structural YAML parser. + /// + private ref struct Parser + { + private readonly ReadOnlySpan source; + private readonly List tokens; + private int index; + + /// + /// Initializes a new instance of the struct. + /// + /// The source span. + public Parser(ReadOnlySpan sourceSpan) + { + source = sourceSpan; + tokens = []; + index = 0; + + var lineNumber = 0; + var start = 0; + for (var i = 0; i <= source.Length; i++) + { + if (i != source.Length && source[i] is not '\n') + continue; + + lineNumber++; + + var end = i; + if (end > start && source[end - 1] is '\r') + end--; + + var lineSpan = source[start..end]; + var indent = 0; + while (indent < lineSpan.Length && char.IsWhiteSpace(lineSpan[indent])) + indent++; + + var contentLen = StripComment(lineSpan[indent..], lineNumber); + var contentSpan = lineSpan.Slice(indent, contentLen).TrimEnd(); + + if (!contentSpan.IsEmpty) + tokens.Add(new LineToken(indent, start + indent, contentSpan.Length, lineNumber)); + + start = i + 1; + } + } + + /// + /// Parses the document. + /// + /// A read-only dictionary of the top-level mapping. + public IReadOnlyDictionary Parse() + { + if (tokens.Count == 0) + return Empty; + + var root = ParseMapping(tokens[0].Indent); + if (index < tokens.Count) + throw CreateFormatException(tokens[index].LineNumber, "Unexpected content after the root mapping."); + + return root; + } + + /// + /// Parses a YAML mapping block. + /// + /// The expected block indentation. + /// The parsed mapping. + private Dictionary ParseMapping(int indent) + { + var map = new Dictionary(StringComparer.Ordinal); + while (index < tokens.Count) + { + var token = tokens[index]; + if (token.Indent < indent) + break; + + if (token.Indent > indent) + throw CreateFormatException(token.LineNumber, "Unexpected indentation."); + + var tokenContent = source.Slice(token.Offset, token.Length); + + if (IsSequenceItem(tokenContent)) + throw CreateFormatException(token.LineNumber, "A mapping key was expected."); + + if (!TrySplitKeyValue(tokenContent, out var keySpan, out var valueSpan)) + throw CreateFormatException(token.LineNumber, "A mapping entry must contain ':'."); + + index++; + + object? value; + if (valueSpan is ['|' or '>', ..]) + value = ParseBlockScalar(valueSpan[0], token.Indent); + else if (!valueSpan.IsEmpty) + value = ParseInlineValue(valueSpan, token.LineNumber); + else if (index < tokens.Count && tokens[index].Indent > indent) + value = ParseNode(tokens[index].Indent); + else + value = null; + + var key = keySpan.ToString(); + if (!map.TryAdd(key, value)) + throw CreateFormatException(token.LineNumber, $"Duplicate key '{key}'."); + } + + return map; + } + + /// + /// Parses a YAML sequence block. + /// + /// The expected block indentation. + /// A list of objects. + private List ParseSequence(int indent) + { + var list = new List(); + + while (index < tokens.Count) + { + var token = tokens[index]; + if (token.Indent < indent) + break; + + if (token.Indent > indent) + throw CreateFormatException(token.LineNumber, "Unexpected indentation."); + + var tokenContent = source.Slice(token.Offset, token.Length); + if (!IsSequenceItem(tokenContent)) + break; + + var itemSpan = GetSequenceItemValue(tokenContent); + + index++; + + object? value; + if (itemSpan is ['|' or '>', ..]) + { + value = ParseBlockScalar(itemSpan[0], token.Indent); + } + else if (itemSpan.Length == 0) + { + value = index < tokens.Count && tokens[index].Indent > indent + ? ParseNode(tokens[index].Indent) + : null; + } + else if (TrySplitKeyValue(itemSpan, out var keySpan, out var valueSpan)) + { + var key = keySpan.ToString(); + var itemMap = new Dictionary(StringComparer.Ordinal); + + itemMap[key] = valueSpan is ['|' or '>', ..] + ? ParseBlockScalar(valueSpan[0], token.Indent) + : valueSpan.Length > 0 + ? ParseInlineValue(valueSpan, token.LineNumber)! + : index < tokens.Count && tokens[index].Indent > indent + ? ParseNode(tokens[index].Indent)! + : null; + + if (index < tokens.Count && tokens[index].Indent > indent) + { + var additionalEntries = ParseMapping(tokens[index].Indent); + foreach (var entry in additionalEntries) + { + if (!itemMap.TryAdd(entry.Key, entry.Value)) + throw CreateFormatException(token.LineNumber, $"Duplicate key '{entry.Key}'."); + } + } + + value = itemMap; + } + else + { + value = ParseInlineValue(itemSpan, token.LineNumber); + } + + list.Add(value!); + } + + return list; + } + + /// + /// Parses a block scalar. + /// + /// The character indicating literal or folded. + /// The indentation of the parent block. + /// The block scalar string. + private string ParseBlockScalar(char blockType, int parentIndent) + { + if (index >= tokens.Count) + return string.Empty; + + var blockIndent = tokens[index].Indent; + if (blockIndent <= parentIndent) + return string.Empty; + + using var reusable = StringBuilderPool.Shared.GetBuilder(); + var sb = reusable.Builder; + + var literal = blockType == '|'; + var firstLine = true; + var expectedIndent = blockIndent; + + while (index < tokens.Count) + { + var token = tokens[index]; + if (token.Indent < expectedIndent) + break; + + if (!firstLine) + sb.Append(literal ? '\n' : ' '); + + var tokenContent = source.Slice(token.Offset, token.Length); + sb.Append(tokenContent); + firstLine = false; + index++; + } + + return sb.ToString().TrimEnd(); + } + + /// + /// Parses an arbitrary node. + /// + /// The expected node indentation. + /// The parsed node. + private object? ParseNode(int indent) + { + if (index >= tokens.Count) + return null; + + var token = tokens[index]; + if (token.Indent < indent) + return null; + + var tokenContent = source.Slice(token.Offset, token.Length); + return IsSequenceItem(tokenContent) + ? ParseSequence(indent) + : ParseMapping(indent); + } + + /// + /// Parses an inline scalar value. + /// + /// The span to parse. + /// The line number. + /// The parsed object. + private static object? ParseInlineValue(ReadOnlySpan span, int lineNumber) + { + span = span.Trim(); + if (span.Length == 0) + return string.Empty; + + if (span[0] is '"' or '\'') + return ParseQuotedValue(span, lineNumber); + + if (span.Equals("null", StringComparison.OrdinalIgnoreCase) || span.Equals("~", StringComparison.Ordinal)) + return null; + + if (span.Equals("true", StringComparison.OrdinalIgnoreCase)) + return true; + + if (span.Equals("false", StringComparison.OrdinalIgnoreCase)) + return false; + + if (long.TryParse(span, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue)) + return longValue; + + if (double.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue)) + return doubleValue; + + return span.ToString(); + } + + /// + /// Parses a quoted scalar value. + /// + /// The span indicating the quoted value. + /// The line number. + /// The unquoted string. + private static string ParseQuotedValue(ReadOnlySpan span, int lineNumber) + { + var quote = span[0]; + if (span.Length < 2 || span[^1] != quote) + throw CreateFormatException(lineNumber, "Quoted scalar is not terminated."); + + return quote == '"' + ? ParseDoubleQuotedValue(span, lineNumber) + : ParseSingleQuotedValue(span, lineNumber); + } + + /// + /// Parses a double-quoted string. + /// + /// The entire double-quoted string span. + /// The line number for diagnostic purposes. + /// The unescaped string, without quotes. + private static string ParseDoubleQuotedValue(ReadOnlySpan span, int lineNumber) + { + using var reusable = StringBuilderPool.Shared.GetBuilder(); + var sb = reusable.Builder; + + for (var i = 1; i < span.Length - 1; i++) + { + var c = span[i]; + if (c is not '\\') + { + sb.Append(c); + continue; + } + + if (i + 1 >= span.Length - 1) + throw CreateFormatException(lineNumber, "Invalid escape sequence in double-quoted scalar."); + + i++; + sb.Append(span[i] switch + { + '\\' => '\\', + '"' => '"', + '0' => '\0', + 'a' => '\a', + 'b' => '\b', + 'f' => '\f', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'v' => '\v', + _ => throw CreateFormatException(lineNumber, $"Unsupported escape sequence '\\{span[i]}'.") + }); + } + + return sb.ToString(); + } + + /// + /// Parses a single-quoted string. + /// + /// The single-quoted span. + /// The line number. + /// The unescaped string, without quotes. + private static string ParseSingleQuotedValue(ReadOnlySpan span, int lineNumber) + { + using var reusable = StringBuilderPool.Shared.GetBuilder(); + var sb = reusable.Builder; + + for (var i = 1; i < span.Length - 1; i++) + { + var c = span[i]; + if (c is not '\'') + { + sb.Append(c); + continue; + } + + if (i + 1 < span.Length - 1 && span[i + 1] == '\'') + { + sb.Append('\''); + i++; + continue; + } + + throw CreateFormatException(lineNumber, "Single quote must be escaped by doubling it."); + } + + return sb.ToString(); + } + + /// + /// Splits a `key: value` span into components. + /// + /// The span acting as mapping entry. + /// Outputs the key span. + /// Outputs the value span. + /// if split was successful; otherwise, . + private static bool TrySplitKeyValue(ReadOnlySpan span, out ReadOnlySpan key, out ReadOnlySpan valueSpan) + { + var separatorIndex = FindKeyValueSeparator(span); + if (separatorIndex <= 0) + { + key = default; + valueSpan = default; + return false; + } + + key = span[..separatorIndex].Trim(); + valueSpan = span[(separatorIndex + 1)..].TrimStart(); + return !key.IsEmpty; + } + + /// + /// Locates the unescaped colon character separating keys and values. + /// + /// The string span to search within. + /// The index of the colon character, or -1 if not found. + private static int FindKeyValueSeparator(ReadOnlySpan span) + { + var quote = '\0'; + + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + if (quote is '\0') + { + if (c is '"' or '\'') + { + quote = c; + continue; + } + + if (c is ':') + return i; + + continue; + } + + if (quote is '"' && c is '\\') + { + i++; + continue; + } + + if (quote is '\'' && c is '\'' && i + 1 < span.Length && span[i + 1] is '\'') + { + i++; + continue; + } + + if (c == quote) + quote = '\0'; + } + + return -1; + } + + /// + /// Returns the length of the span unaffected by inline comments (ignoring the # marking the start of a comment unless it's within quotes). + /// + /// The span to scan. + /// The line number. + /// The length remaining after stripping the comment. + private static int StripComment(ReadOnlySpan span, int lineNumber) + { + var quote = '\0'; + + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + if (quote is '\0') + { + if (c is '"' or '\'') + { + quote = c; + continue; + } + + if (c is '#' && (i == 0 || char.IsWhiteSpace(span[i - 1]))) + return i; + + continue; + } + + if (quote is '"' && c is '\\') + { + i++; + continue; + } + + if (quote is '\'' && c is '\'' && i + 1 < span.Length && span[i + 1] is '\'') + { + i++; + continue; + } + + if (c == quote) + quote = '\0'; + } + + if (quote != '\0') + throw CreateFormatException(lineNumber, "Quoted scalar is not terminated."); + + return span.Length; + } + + /// + /// Checks if a span corresponds to a sequence item block marker. + /// + /// The span. + /// if the item represents a sequence item; otherwise, . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsSequenceItem(ReadOnlySpan span) => span is ['-'] or ['-', ' ', ..]; + + /// + /// Extracts the sequence item value after the '-' prefix. + /// + /// The original span. + /// The extracted sequence item value without the prefix. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ReadOnlySpan GetSequenceItemValue(ReadOnlySpan span) => span is ['-'] ? default : span[2..].TrimStart(); + + /// + /// Creates a format exception. + /// + /// The faulty line number. + /// The failure reason. + /// A formatted exception. + private static FormatException CreateFormatException(int lineNumber, string message) => new($"YAML parse error on line {lineNumber}: {message}"); + + /// + /// Represents a line tokenizer output tracking token position mapping to its content and source file details. + /// + private readonly struct LineToken + { + /// + /// Initializes a new instance of the struct. + /// + /// The indentation level. + /// The offset in original span. + /// Length of the mapped token string content. + /// Its original line number. + public LineToken(int indent, int offset, int length, int lineNumber) + { + this.Indent = indent; + this.Offset = offset; + this.Length = length; + this.LineNumber = lineNumber; + } + + /// + /// The physical indentation of the token. + /// + public readonly int Indent; + + /// + /// The starting original span offset. + /// + public readonly int Offset; + + /// + /// The spanned token length. + /// + public readonly int Length; + + /// + /// The original line number for formatting exceptions and tracking positions. + /// + public readonly int LineNumber; + } + } + } +} \ No newline at end of file diff --git a/tests/Support/YamlTests.cs b/tests/Support/YamlTests.cs new file mode 100644 index 00000000..48d15855 --- /dev/null +++ b/tests/Support/YamlTests.cs @@ -0,0 +1,960 @@ +// Copyright (C) Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Test.Support +{ + using Kampute.DocToolkit.Support; + using NUnit.Framework; + using System; + using System.Collections.Generic; + + [TestFixture] + public class YamlTests + { + #region Empty and Null Cases + + [Test] + public void Parse_EmptyString_ReturnsEmptyDictionary() + { + var result = Yaml.Parse(""); + + Assert.That(result, Is.Empty); + Assert.That(result, Is.SameAs(Yaml.Empty)); + } + + [Test] + public void Parse_WhitespaceOnly_ReturnsEmptyDictionary() + { + var result = Yaml.Parse(" \n \n "); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Parse_CommentsOnly_ReturnsEmptyDictionary() + { + var yaml = """ + # This is a comment + # Another comment + # Yet another comment + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Is.Empty); + } + + #endregion + + #region Simple Key-Value Pairs + + [Test] + public void Parse_SingleStringKeyValue_ReturnsDictionary() + { + var yaml = "key: value"; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result["key"], Is.EqualTo("value")); + } + + [Test] + public void Parse_MultipleStringKeyValues_ReturnsDictionary() + { + var yaml = """ + key1: value1 + key2: value2 + key3: value3 + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + Assert.That(result["key3"], Is.EqualTo("value3")); + } + } + + [Test] + public void Parse_KeyWithEmptyValue_ReturnsNull() + { + var yaml = "key:"; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result["key"], Is.Null); + } + + [Test] + public void Parse_KeyWithWhitespaceValue_ReturnsNull() + { + var yaml = "key: "; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result["key"], Is.Null); + } + + #endregion + + #region Null and Boolean Values + + [TestCase("key: null")] + [TestCase("key: Null")] + [TestCase("key: NULL")] + [TestCase("key: ~")] + public void Parse_NullValues_ReturnsNull(string yaml) + { + var result = Yaml.Parse(yaml); + Assert.That(result["key"], Is.Null); + } + + [TestCase("key: true", ExpectedResult = true)] + [TestCase("key: True", ExpectedResult = true)] + [TestCase("key: TRUE", ExpectedResult = true)] + [TestCase("key: false", ExpectedResult = false)] + [TestCase("key: False", ExpectedResult = false)] + [TestCase("key: FALSE", ExpectedResult = false)] + public object? Parse_BooleanValues_ReturnsBoolean(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + #endregion + + #region Numeric Values + + [TestCase("key: 0", ExpectedResult = 0L)] + [TestCase("key: 42", ExpectedResult = 42L)] + [TestCase("key: -42", ExpectedResult = -42L)] + [TestCase("key: 9223372036854775807", ExpectedResult = 9223372036854775807L)] + [TestCase("key: -9223372036854775808", ExpectedResult = -9223372036854775808L)] + public object? Parse_IntegerValues_ReturnsLong(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + [TestCase("key: 0.0", ExpectedResult = 0.0)] + [TestCase("key: 3.14", ExpectedResult = 3.14)] + [TestCase("key: -3.14", ExpectedResult = -3.14)] + [TestCase("key: 1.23e10", ExpectedResult = 1.23e10)] + [TestCase("key: -1.23e-10", ExpectedResult = -1.23e-10)] + public object? Parse_FloatValues_ReturnsDouble(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + #endregion + + #region Quoted Strings + + [Test] + public void Parse_DoubleQuotedString_ReturnsUnquotedString() + { + var yaml = @"key: ""value with spaces"""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value with spaces")); + } + + [Test] + public void Parse_SingleQuotedString_ReturnsUnquotedString() + { + var yaml = "key: 'value with spaces'"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value with spaces")); + } + + [Test] + public void Parse_DoubleQuotedStringWithEscapes_ReturnsUnescapedString() + { + var yaml = @"key: ""line1\nline2\ttab\backslash\""quote"""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("line1\nline2\ttab\backslash\"quote")); + } + + [TestCase(@"key: ""\0""", ExpectedResult = "\0")] + [TestCase(@"key: ""\a""", ExpectedResult = "\a")] + [TestCase(@"key: ""\b""", ExpectedResult = "\b")] + [TestCase(@"key: ""\f""", ExpectedResult = "\f")] + [TestCase(@"key: ""\n""", ExpectedResult = "\n")] + [TestCase(@"key: ""\r""", ExpectedResult = "\r")] + [TestCase(@"key: ""\t""", ExpectedResult = "\t")] + [TestCase(@"key: ""\v""", ExpectedResult = "\v")] + [TestCase(@"key: ""\\""", ExpectedResult = "\\")] + [TestCase(@"key: ""\""""", ExpectedResult = "\"")] + public object? Parse_DoubleQuotedEscapeSequences_ReturnsCorrectCharacter(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + [Test] + public void Parse_SingleQuotedStringWithDoubledQuote_ReturnsQuote() + { + var yaml = "key: 'can''t'"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("can't")); + } + + [Test] + public void Parse_QuotedKeyValue_ParsesCorrectly() + { + var yaml = "\"quoted key\": \"quoted value\""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["\"quoted key\""], Is.EqualTo("quoted value")); + } + + [Test] + public void Parse_UnterminatedDoubleQuotedString_ThrowsFormatException() + { + var yaml = "key: \"unterminated"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("not terminated")); + } + + [Test] + public void Parse_UnterminatedSingleQuotedString_ThrowsFormatException() + { + var yaml = "key: 'unterminated"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("not terminated")); + } + + [Test] + public void Parse_InvalidEscapeSequence_ThrowsFormatException() + { + var yaml = @"key: ""\x"""; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Unsupported escape sequence")); + } + + [Test] + public void Parse_SingleQuoteNotDoubled_ThrowsFormatException() + { + var yaml = "key: 'it's'"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("not terminated")); + } + + #endregion + + #region Comments + + [Test] + public void Parse_InlineComment_IgnoresComment() + { + var yaml = "key: value # this is a comment"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value")); + } + + [Test] + public void Parse_HashInQuotedString_PreservesHash() + { + var yaml = @"key: ""value # not a comment"""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value # not a comment")); + } + + [Test] + public void Parse_HashWithoutPrecedingSpace_NotTreatedAsComment() + { + var yaml = "key: value#notacomment"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value#notacomment")); + } + + [Test] + public void Parse_MultipleCommentsAndData_ParsesDataCorrectly() + { + var yaml = """ + # Comment before + key1: value1 # inline comment + # Comment between + key2: value2 + # Comment after + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + } + } + + #endregion + + #region Nested Mappings + + [Test] + public void Parse_NestedMapping_ReturnsNestedDictionary() + { + var yaml = """ + parent: + child1: value1 + child2: value2 + """; + + var result = Yaml.Parse(yaml); + + var nested = result["parent"] as IReadOnlyDictionary; + Assert.That(nested, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(nested!["child1"], Is.EqualTo("value1")); + Assert.That(nested["child2"], Is.EqualTo("value2")); + } + } + + [Test] + public void Parse_DeeplyNestedMapping_ReturnsNestedDictionaries() + { + var yaml = """ + level1: + level2: + level3: + key: value + """; + + var result = Yaml.Parse(yaml); + + var level1 = result["level1"] as IReadOnlyDictionary; + Assert.That(level1, Is.Not.Null); + + var level2 = level1!["level2"] as IReadOnlyDictionary; + Assert.That(level2, Is.Not.Null); + + var level3 = level2!["level3"] as IReadOnlyDictionary; + Assert.That(level3, Is.Not.Null); + + Assert.That(level3!["key"], Is.EqualTo("value")); + } + + [Test] + public void Parse_MixedLevelMappings_ParsesCorrectly() + { + var yaml = """ + root1: value1 + root2: + child1: value2 + child2: value3 + root3: value4 + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["root1"], Is.EqualTo("value1")); + Assert.That(result["root3"], Is.EqualTo("value4")); + } + + var nested = result["root2"] as IReadOnlyDictionary; + Assert.That(nested, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(nested!["child1"], Is.EqualTo("value2")); + Assert.That(nested["child2"], Is.EqualTo("value3")); + } + } + + #endregion + + #region Sequences + + [Test] + public void Parse_SimpleSequence_ReturnsList() + { + var yaml = """ + items: + - item1 + - item2 + - item3 + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new[] { "item1", "item2", "item3" })); + } + + [Test] + public void Parse_SequenceWithMixedTypes_ReturnsList() + { + var yaml = """ + items: + - string value + - 42 + - 3.14 + - true + - null + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new object?[] { "string value", 42L, 3.14, true, null })); + } + + [Test] + public void Parse_SequenceWithNestedMappings_ReturnsList() + { + var yaml = """ + items: + - name: item1 + value: 1 + - name: item2 + value: 2 + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.Not.Null); + + var item1 = list![0] as IReadOnlyDictionary; + Assert.That(item1, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(item1!["name"], Is.EqualTo("item1")); + Assert.That(item1["value"], Is.EqualTo(1L)); + } + + var item2 = list[1] as IReadOnlyDictionary; + Assert.That(item2, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(item2!["name"], Is.EqualTo("item2")); + Assert.That(item2["value"], Is.EqualTo(2L)); + } + } + + [Test] + public void Parse_SequenceWithEmptyItems_ReturnsListWithNulls() + { + var yaml = """ + items: + - + - + - value + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new object?[] { null, null, "value" })); + } + + [Test] + public void Parse_SequenceItemWithMapping_ParsesCorrectly() + { + var yaml = """ + items: + - key1: value1 + - + key2: value2 + key3: value3 + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.Not.Null); + + var item1 = list![0] as IReadOnlyDictionary; + Assert.That(item1, Is.Not.Null); + Assert.That(item1!["key1"], Is.EqualTo("value1")); + + var item2 = list[1] as IReadOnlyDictionary; + Assert.That(item2, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(item2!["key2"], Is.EqualTo("value2")); + Assert.That(item2["key3"], Is.EqualTo("value3")); + } + } + + #endregion + + #region Block Scalars + + [Test] + public void Parse_LiteralBlockScalar_PreservesNewlines() + { + var yaml = """ + description: | + Line 1 + Line 2 + Line 3 + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result["description"], Is.EqualTo("Line 1\nLine 2\nLine 3")); + } + + [Test] + public void Parse_FoldedBlockScalar_FoldsNewlines() + { + var yaml = """ + description: > + Line 1 + Line 2 + Line 3 + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result["description"], Is.EqualTo("Line 1 Line 2 Line 3")); + } + + [Test] + public void Parse_EmptyLiteralBlockScalar_ReturnsEmptyString() + { + var yaml = """ + description: | + next: value + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["description"], Is.EqualTo("")); + Assert.That(result["next"], Is.EqualTo("value")); + } + } + + [Test] + public void Parse_EmptyFoldedBlockScalar_ReturnsEmptyString() + { + var yaml = """ + description: > + next: value + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["description"], Is.EqualTo("")); + Assert.That(result["next"], Is.EqualTo("value")); + } + } + + [Test] + public void Parse_BlockScalarWithTrailingWhitespace_TrimsEnd() + { + var yaml = """ + description: | + Line 1 + Line 2 + + + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result["description"], Is.EqualTo("Line 1\nLine 2")); + } + + [Test] + public void Parse_BlockScalarInSequence_ParsesCorrectly() + { + var yaml = """ + items: + - | + Block 1 + Line 2 + - normal value + - > + Folded + text + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new[] { "Block 1\nLine 2", "normal value", "Folded text" })); + } + + [Test] + public void Parse_BlockScalarInNestedMapping_ParsesCorrectly() + { + var yaml = """ + parent: + description: | + Multi + Line + other: value + """; + + var result = Yaml.Parse(yaml); + + var nested = result["parent"] as IReadOnlyDictionary; + Assert.That(nested, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(nested!["description"], Is.EqualTo("Multi\nLine")); + Assert.That(nested["other"], Is.EqualTo("value")); + } + } + + #endregion + + #region Indentation and Structure + + [Test] + public void Parse_IncorrectIndentation_ThrowsFormatException() + { + var yaml = """ + key1: value1 + key2: value2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Unexpected indentation")); + } + + [Test] + public void Parse_SequenceAtRootLevel_ThrowsFormatException() + { + var yaml = """ + - item1 + - item2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("mapping key was expected")); + } + + [Test] + public void Parse_MissingColon_ThrowsFormatException() + { + var yaml = "key value"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("must contain ':'")); + } + + [Test] + public void Parse_DuplicateKey_ThrowsFormatException() + { + var yaml = """ + key: value1 + key: value2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Duplicate key")); + } + + [Test] + public void Parse_DuplicateKeyInNested_ThrowsFormatException() + { + var yaml = """ + parent: + key: value1 + key: value2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Duplicate key")); + } + + [Test] + public void Parse_DuplicateKeyInSequenceItem_ThrowsFormatException() + { + var yaml = """ + items: + - name: item1 + name: duplicate + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Duplicate key")); + } + + #endregion + + #region Complex Scenarios + + [Test] + public void Parse_ComplexDocument_ParsesCorrectly() + { + var yaml = """ + # Configuration file + version: 1.0 + enabled: true + count: 42 + + database: + host: localhost + port: 5432 + credentials: + username: admin + password: "secret!@#" + + servers: + - name: server1 + ip: 192.168.1.1 + active: true + - name: server2 + ip: 192.168.1.2 + active: false + + description: | + This is a multi-line + description that preserves + line breaks. + + summary: > + This is a folded + multi-line text + that joins lines. + + tags: + - production + - critical + - monitored + + metadata: null + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["version"], Is.EqualTo(1.0)); + Assert.That(result["enabled"], Is.EqualTo(true)); + Assert.That(result["count"], Is.EqualTo(42L)); + Assert.That(result["metadata"], Is.Null); + } + + var database = result["database"] as IReadOnlyDictionary; + Assert.That(database, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(database!["host"], Is.EqualTo("localhost")); + Assert.That(database["port"], Is.EqualTo(5432L)); + } + + var credentials = database!["credentials"] as IReadOnlyDictionary; + Assert.That(credentials, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(credentials!["username"], Is.EqualTo("admin")); + Assert.That(credentials["password"], Is.EqualTo("secret!@#")); + } + + var servers = result["servers"] as IList; + Assert.That(servers, Is.Not.Null); + + var server1 = servers![0] as IReadOnlyDictionary; + Assert.That(server1, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(server1!["name"], Is.EqualTo("server1")); + Assert.That(server1["ip"], Is.EqualTo("192.168.1.1")); + Assert.That(server1["active"], Is.EqualTo(true)); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["description"], Is.EqualTo("This is a multi-line\ndescription that preserves\nline breaks.")); + Assert.That(result["summary"], Is.EqualTo("This is a folded multi-line text that joins lines.")); + } + + var tags = result["tags"] as IList; + Assert.That(tags, Is.EqualTo(new[] { "production", "critical", "monitored" })); + } + + [Test] + public void Parse_ColonInValue_ParsesCorrectly() + { + var yaml = """ + url: http://example.com:8080 + time: 12:30:45 + quoted: "key: value" + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["url"], Is.EqualTo("http://example.com:8080")); + Assert.That(result["time"], Is.EqualTo("12:30:45")); + Assert.That(result["quoted"], Is.EqualTo("key: value")); + } + } + + [Test] + public void Parse_WindowsAndUnixLineEndings_ParsesCorrectly() + { + var yaml = "key1: value1\r\nkey2: value2\nkey3: value3\r\n"; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + Assert.That(result["key3"], Is.EqualTo("value3")); + } + } + + [Test] + public void Parse_TrailingWhitespace_HandlesCorrectly() + { + var yaml = """ + key1: value1 + key2: value2 + key3: value3 + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + Assert.That(result["key3"], Is.EqualTo("value3")); + } + } + + [Test] + public void Parse_EmptyMapping_ReturnsNull() + { + var yaml = """ + parent: + next: value + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["parent"], Is.Null); + Assert.That(result["next"], Is.EqualTo("value")); + } + } + + [Test] + public void Parse_EmptySequenceItem_ReturnsNull() + { + var yaml = """ + items: + - + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new object?[] { null })); + } + + #endregion + + #region Edge Cases with Special Characters + + [Test] + public void Parse_DashInValue_NotTreatedAsSequence() + { + var yaml = "key: value-with-dashes"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value-with-dashes")); + } + + [Test] + public void Parse_ColonInQuotedKey_ParsesCorrectly() + { + var yaml = "\"key:with:colons\": value"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["\"key:with:colons\""], Is.EqualTo("value")); + } + + [Test] + public void Parse_QuotedBooleanString_ReturnsString() + { + var yaml = """ + key1: "true" + key2: "false" + key3: "null" + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("true")); + Assert.That(result["key2"], Is.EqualTo("false")); + Assert.That(result["key3"], Is.EqualTo("null")); + } + } + + [Test] + public void Parse_QuotedNumericString_ReturnsString() + { + var yaml = """ + key1: "42" + key2: "3.14" + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("42")); + Assert.That(result["key2"], Is.EqualTo("3.14")); + } + } + + [Test] + public void Parse_LeadingZeros_ParsesAsInteger() + { + var yaml = "key: 007"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo(7L)); + } + + #endregion + } +} From 7a9be5fea9b3a6aa5432dba709d9e9e2fd77dde5 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 21 Mar 2026 17:54:48 +0800 Subject: [PATCH 2/5] Add lightweight YAML parser and comprehensive tests Implemented a simple YAML parser in Yaml.cs supporting string keys, scalars, sequences, nested mappings, and block scalars for basic metadata scenarios. Added YamlTests.cs with extensive NUnit tests covering parsing, edge cases, and error handling. Both files include MIT license headers. Advanced YAML features are not supported. --- src/Support/Yaml.cs | 600 +++++++++++++++++++++++ tests/Support/YamlTests.cs | 960 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1560 insertions(+) create mode 100644 src/Support/Yaml.cs create mode 100644 tests/Support/YamlTests.cs diff --git a/src/Support/Yaml.cs b/src/Support/Yaml.cs new file mode 100644 index 00000000..54ea6fa2 --- /dev/null +++ b/src/Support/Yaml.cs @@ -0,0 +1,600 @@ +// Copyright (C) Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Support +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Runtime.CompilerServices; + + /// + /// Provides simplified YAML parsing. + /// + /// + /// + /// This class supports a limited YAML subset intended for lightweight metadata scenarios. It supports + /// string keys, scalar values, simple sequences, simple nested mappings based on indentation, and + /// block scalars. + /// + /// + /// The parser does not support all YAML features and is designed for simplicity and performance in + /// common use cases. It does not handle complex constructs such as anchors, aliases, or advanced tags. + /// For more advanced YAML processing needs, consider using a full-featured YAML library. + /// + /// + public static class Yaml + { + /// + /// Gets a reusable empty read-only YAML mapping. + /// + /// + /// A reusable empty read-only YAML mapping. + /// + public static readonly IReadOnlyDictionary Empty = new Dictionary(0, StringComparer.Ordinal); + + /// + /// Parses the specified YAML text into a read-only dictionary. + /// + /// The YAML text to parse. + /// A read-only dictionary containing parsed keys and values. + /// Thrown when is . + /// Thrown when the YAML content is malformed or uses unsupported constructs. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IReadOnlyDictionary Parse(ReadOnlySpan text) + { + return text.IsEmpty ? Empty : new Parser(text).Parse(); + } + + /// + /// A lightweight structural YAML parser. + /// + private ref struct Parser + { + private readonly ReadOnlySpan source; + private readonly List tokens; + private int index; + + /// + /// Initializes a new instance of the struct. + /// + /// The source span. + public Parser(ReadOnlySpan sourceSpan) + { + source = sourceSpan; + tokens = []; + index = 0; + + var lineNumber = 0; + var start = 0; + for (var i = 0; i <= source.Length; i++) + { + if (i != source.Length && source[i] is not '\n') + continue; + + lineNumber++; + + var end = i; + if (end > start && source[end - 1] is '\r') + end--; + + var lineSpan = source[start..end]; + var indent = 0; + while (indent < lineSpan.Length && char.IsWhiteSpace(lineSpan[indent])) + indent++; + + var contentLen = StripComment(lineSpan[indent..], lineNumber); + var contentSpan = lineSpan.Slice(indent, contentLen).TrimEnd(); + + if (!contentSpan.IsEmpty) + tokens.Add(new LineToken(indent, start + indent, contentSpan.Length, lineNumber)); + + start = i + 1; + } + } + + /// + /// Parses the document. + /// + /// A read-only dictionary of the top-level mapping. + public IReadOnlyDictionary Parse() + { + if (tokens.Count == 0) + return Empty; + + var root = ParseMapping(tokens[0].Indent); + if (index < tokens.Count) + throw CreateFormatException(tokens[index].LineNumber, "Unexpected content after the root mapping."); + + return root; + } + + /// + /// Parses a YAML mapping block. + /// + /// The expected block indentation. + /// The parsed mapping. + private Dictionary ParseMapping(int indent) + { + var map = new Dictionary(StringComparer.Ordinal); + while (index < tokens.Count) + { + var token = tokens[index]; + if (token.Indent < indent) + break; + + if (token.Indent > indent) + throw CreateFormatException(token.LineNumber, "Unexpected indentation."); + + var tokenContent = source.Slice(token.Offset, token.Length); + + if (IsSequenceItem(tokenContent)) + throw CreateFormatException(token.LineNumber, "A mapping key was expected."); + + if (!TrySplitKeyValue(tokenContent, out var keySpan, out var valueSpan)) + throw CreateFormatException(token.LineNumber, "A mapping entry must contain ':'."); + + index++; + + object? value; + if (valueSpan is ['|' or '>', ..]) + value = ParseBlockScalar(valueSpan[0], token.Indent); + else if (!valueSpan.IsEmpty) + value = ParseInlineValue(valueSpan, token.LineNumber); + else if (index < tokens.Count && tokens[index].Indent > indent) + value = ParseNode(tokens[index].Indent); + else + value = null; + + var key = keySpan.ToString(); + if (!map.TryAdd(key, value)) + throw CreateFormatException(token.LineNumber, $"Duplicate key '{key}'."); + } + + return map; + } + + /// + /// Parses a YAML sequence block. + /// + /// The expected block indentation. + /// A list of objects. + private List ParseSequence(int indent) + { + var list = new List(); + + while (index < tokens.Count) + { + var token = tokens[index]; + if (token.Indent < indent) + break; + + if (token.Indent > indent) + throw CreateFormatException(token.LineNumber, "Unexpected indentation."); + + var tokenContent = source.Slice(token.Offset, token.Length); + if (!IsSequenceItem(tokenContent)) + break; + + var itemSpan = GetSequenceItemValue(tokenContent); + + index++; + + object? value; + if (itemSpan is ['|' or '>', ..]) + { + value = ParseBlockScalar(itemSpan[0], token.Indent); + } + else if (itemSpan.Length == 0) + { + value = index < tokens.Count && tokens[index].Indent > indent + ? ParseNode(tokens[index].Indent) + : null; + } + else if (TrySplitKeyValue(itemSpan, out var keySpan, out var valueSpan)) + { + var key = keySpan.ToString(); + var itemMap = new Dictionary(StringComparer.Ordinal); + + itemMap[key] = valueSpan is ['|' or '>', ..] + ? ParseBlockScalar(valueSpan[0], token.Indent) + : valueSpan.Length > 0 + ? ParseInlineValue(valueSpan, token.LineNumber)! + : index < tokens.Count && tokens[index].Indent > indent + ? ParseNode(tokens[index].Indent)! + : null; + + if (index < tokens.Count && tokens[index].Indent > indent) + { + var additionalEntries = ParseMapping(tokens[index].Indent); + foreach (var entry in additionalEntries) + { + if (!itemMap.TryAdd(entry.Key, entry.Value)) + throw CreateFormatException(token.LineNumber, $"Duplicate key '{entry.Key}'."); + } + } + + value = itemMap; + } + else + { + value = ParseInlineValue(itemSpan, token.LineNumber); + } + + list.Add(value!); + } + + return list; + } + + /// + /// Parses a block scalar. + /// + /// The character indicating literal or folded. + /// The indentation of the parent block. + /// The block scalar string. + private string ParseBlockScalar(char blockType, int parentIndent) + { + if (index >= tokens.Count) + return string.Empty; + + var blockIndent = tokens[index].Indent; + if (blockIndent <= parentIndent) + return string.Empty; + + using var reusable = StringBuilderPool.Shared.GetBuilder(); + var sb = reusable.Builder; + + var literal = blockType == '|'; + var firstLine = true; + var expectedIndent = blockIndent; + + while (index < tokens.Count) + { + var token = tokens[index]; + if (token.Indent < expectedIndent) + break; + + if (!firstLine) + sb.Append(literal ? '\n' : ' '); + + var tokenContent = source.Slice(token.Offset, token.Length); + sb.Append(tokenContent); + firstLine = false; + index++; + } + + return sb.ToString().TrimEnd(); + } + + /// + /// Parses an arbitrary node. + /// + /// The expected node indentation. + /// The parsed node. + private object? ParseNode(int indent) + { + if (index >= tokens.Count) + return null; + + var token = tokens[index]; + if (token.Indent < indent) + return null; + + var tokenContent = source.Slice(token.Offset, token.Length); + return IsSequenceItem(tokenContent) + ? ParseSequence(indent) + : ParseMapping(indent); + } + + /// + /// Parses an inline scalar value. + /// + /// The span to parse. + /// The line number. + /// The parsed object. + private static object? ParseInlineValue(ReadOnlySpan span, int lineNumber) + { + span = span.Trim(); + if (span.Length == 0) + return string.Empty; + + if (span[0] is '"' or '\'') + return ParseQuotedValue(span, lineNumber); + + if (span.Equals("null", StringComparison.OrdinalIgnoreCase) || span.Equals("~", StringComparison.Ordinal)) + return null; + + if (span.Equals("true", StringComparison.OrdinalIgnoreCase)) + return true; + + if (span.Equals("false", StringComparison.OrdinalIgnoreCase)) + return false; + + if (long.TryParse(span, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue)) + return longValue; + + if (double.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue)) + return doubleValue; + + return span.ToString(); + } + + /// + /// Parses a quoted scalar value. + /// + /// The span indicating the quoted value. + /// The line number. + /// The unquoted string. + private static string ParseQuotedValue(ReadOnlySpan span, int lineNumber) + { + var quote = span[0]; + if (span.Length < 2 || span[^1] != quote) + throw CreateFormatException(lineNumber, "Quoted scalar is not terminated."); + + return quote == '"' + ? ParseDoubleQuotedValue(span, lineNumber) + : ParseSingleQuotedValue(span, lineNumber); + } + + /// + /// Parses a double-quoted string. + /// + /// The entire double-quoted string span. + /// The line number for diagnostic purposes. + /// The unescaped string, without quotes. + private static string ParseDoubleQuotedValue(ReadOnlySpan span, int lineNumber) + { + using var reusable = StringBuilderPool.Shared.GetBuilder(); + var sb = reusable.Builder; + + for (var i = 1; i < span.Length - 1; i++) + { + var c = span[i]; + if (c is not '\\') + { + sb.Append(c); + continue; + } + + if (i + 1 >= span.Length - 1) + throw CreateFormatException(lineNumber, "Invalid escape sequence in double-quoted scalar."); + + i++; + sb.Append(span[i] switch + { + '\\' => '\\', + '"' => '"', + '0' => '\0', + 'a' => '\a', + 'b' => '\b', + 'f' => '\f', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'v' => '\v', + _ => throw CreateFormatException(lineNumber, $"Unsupported escape sequence '\\{span[i]}'.") + }); + } + + return sb.ToString(); + } + + /// + /// Parses a single-quoted string. + /// + /// The single-quoted span. + /// The line number. + /// The unescaped string, without quotes. + private static string ParseSingleQuotedValue(ReadOnlySpan span, int lineNumber) + { + using var reusable = StringBuilderPool.Shared.GetBuilder(); + var sb = reusable.Builder; + + for (var i = 1; i < span.Length - 1; i++) + { + var c = span[i]; + if (c is not '\'') + { + sb.Append(c); + continue; + } + + if (i + 1 < span.Length - 1 && span[i + 1] == '\'') + { + sb.Append('\''); + i++; + continue; + } + + throw CreateFormatException(lineNumber, "Single quote must be escaped by doubling it."); + } + + return sb.ToString(); + } + + /// + /// Splits a `key: value` span into components. + /// + /// The span acting as mapping entry. + /// Outputs the key span. + /// Outputs the value span. + /// if split was successful; otherwise, . + private static bool TrySplitKeyValue(ReadOnlySpan span, out ReadOnlySpan key, out ReadOnlySpan valueSpan) + { + var separatorIndex = FindKeyValueSeparator(span); + if (separatorIndex <= 0) + { + key = default; + valueSpan = default; + return false; + } + + key = span[..separatorIndex].Trim(); + valueSpan = span[(separatorIndex + 1)..].TrimStart(); + return !key.IsEmpty; + } + + /// + /// Locates the unescaped colon character separating keys and values. + /// + /// The string span to search within. + /// The index of the colon character, or -1 if not found. + private static int FindKeyValueSeparator(ReadOnlySpan span) + { + var quote = '\0'; + + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + if (quote is '\0') + { + if (c is '"' or '\'') + { + quote = c; + continue; + } + + if (c is ':') + return i; + + continue; + } + + if (quote is '"' && c is '\\') + { + i++; + continue; + } + + if (quote is '\'' && c is '\'' && i + 1 < span.Length && span[i + 1] is '\'') + { + i++; + continue; + } + + if (c == quote) + quote = '\0'; + } + + return -1; + } + + /// + /// Returns the length of the span unaffected by inline comments (ignoring the # marking the start of a comment unless it's within quotes). + /// + /// The span to scan. + /// The line number. + /// The length remaining after stripping the comment. + private static int StripComment(ReadOnlySpan span, int lineNumber) + { + var quote = '\0'; + + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + if (quote is '\0') + { + if (c is '"' or '\'') + { + quote = c; + continue; + } + + if (c is '#' && (i == 0 || char.IsWhiteSpace(span[i - 1]))) + return i; + + continue; + } + + if (quote is '"' && c is '\\') + { + i++; + continue; + } + + if (quote is '\'' && c is '\'' && i + 1 < span.Length && span[i + 1] is '\'') + { + i++; + continue; + } + + if (c == quote) + quote = '\0'; + } + + if (quote != '\0') + throw CreateFormatException(lineNumber, "Quoted scalar is not terminated."); + + return span.Length; + } + + /// + /// Checks if a span corresponds to a sequence item block marker. + /// + /// The span. + /// if the item represents a sequence item; otherwise, . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsSequenceItem(ReadOnlySpan span) => span is ['-'] or ['-', ' ', ..]; + + /// + /// Extracts the sequence item value after the '-' prefix. + /// + /// The original span. + /// The extracted sequence item value without the prefix. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ReadOnlySpan GetSequenceItemValue(ReadOnlySpan span) => span is ['-'] ? default : span[2..].TrimStart(); + + /// + /// Creates a format exception. + /// + /// The faulty line number. + /// The failure reason. + /// A formatted exception. + private static FormatException CreateFormatException(int lineNumber, string message) => new($"YAML parse error on line {lineNumber}: {message}"); + + /// + /// Represents a line tokenizer output tracking token position mapping to its content and source file details. + /// + private readonly struct LineToken + { + /// + /// Initializes a new instance of the struct. + /// + /// The indentation level. + /// The offset in original span. + /// Length of the mapped token string content. + /// Its original line number. + public LineToken(int indent, int offset, int length, int lineNumber) + { + this.Indent = indent; + this.Offset = offset; + this.Length = length; + this.LineNumber = lineNumber; + } + + /// + /// The physical indentation of the token. + /// + public readonly int Indent; + + /// + /// The starting original span offset. + /// + public readonly int Offset; + + /// + /// The spanned token length. + /// + public readonly int Length; + + /// + /// The original line number for formatting exceptions and tracking positions. + /// + public readonly int LineNumber; + } + } + } +} \ No newline at end of file diff --git a/tests/Support/YamlTests.cs b/tests/Support/YamlTests.cs new file mode 100644 index 00000000..39b2627d --- /dev/null +++ b/tests/Support/YamlTests.cs @@ -0,0 +1,960 @@ +// Copyright (C) Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Test.Support +{ + using Kampute.DocToolkit.Support; + using NUnit.Framework; + using System; + using System.Collections.Generic; + + [TestFixture] + public class YamlTests + { + #region Empty and Null Cases + + [Test] + public void Parse_EmptyString_ReturnsEmptyDictionary() + { + var result = Yaml.Parse(""); + + Assert.That(result, Is.Empty); + Assert.That(result, Is.SameAs(Yaml.Empty)); + } + + [Test] + public void Parse_WhitespaceOnly_ReturnsEmptyDictionary() + { + var result = Yaml.Parse(" \n \n "); + + Assert.That(result, Is.Empty); + } + + [Test] + public void Parse_CommentsOnly_ReturnsEmptyDictionary() + { + var yaml = """ + # This is a comment + # Another comment + # Yet another comment + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Is.Empty); + } + + #endregion + + #region Simple Key-Value Pairs + + [Test] + public void Parse_SingleStringKeyValue_ReturnsDictionary() + { + var yaml = "key: value"; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result["key"], Is.EqualTo("value")); + } + + [Test] + public void Parse_MultipleStringKeyValues_ReturnsDictionary() + { + var yaml = """ + key1: value1 + key2: value2 + key3: value3 + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + Assert.That(result["key3"], Is.EqualTo("value3")); + } + } + + [Test] + public void Parse_KeyWithEmptyValue_ReturnsNull() + { + var yaml = "key:"; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result["key"], Is.Null); + } + + [Test] + public void Parse_KeyWithWhitespaceValue_ReturnsNull() + { + var yaml = "key: "; + + var result = Yaml.Parse(yaml); + + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result["key"], Is.Null); + } + + #endregion + + #region Null and Boolean Values + + [TestCase("key: null")] + [TestCase("key: Null")] + [TestCase("key: NULL")] + [TestCase("key: ~")] + public void Parse_NullValues_ReturnsNull(string yaml) + { + var result = Yaml.Parse(yaml); + Assert.That(result["key"], Is.Null); + } + + [TestCase("key: true", ExpectedResult = true)] + [TestCase("key: True", ExpectedResult = true)] + [TestCase("key: TRUE", ExpectedResult = true)] + [TestCase("key: false", ExpectedResult = false)] + [TestCase("key: False", ExpectedResult = false)] + [TestCase("key: FALSE", ExpectedResult = false)] + public object? Parse_BooleanValues_ReturnsBoolean(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + #endregion + + #region Numeric Values + + [TestCase("key: 0", ExpectedResult = 0L)] + [TestCase("key: 42", ExpectedResult = 42L)] + [TestCase("key: -42", ExpectedResult = -42L)] + [TestCase("key: 9223372036854775807", ExpectedResult = 9223372036854775807L)] + [TestCase("key: -9223372036854775808", ExpectedResult = -9223372036854775808L)] + public object? Parse_IntegerValues_ReturnsLong(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + [TestCase("key: 0.0", ExpectedResult = 0.0)] + [TestCase("key: 3.14", ExpectedResult = 3.14)] + [TestCase("key: -3.14", ExpectedResult = -3.14)] + [TestCase("key: 1.23e10", ExpectedResult = 1.23e10)] + [TestCase("key: -1.23e-10", ExpectedResult = -1.23e-10)] + public object? Parse_FloatValues_ReturnsDouble(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + #endregion + + #region Quoted Strings + + [Test] + public void Parse_DoubleQuotedString_ReturnsUnquotedString() + { + var yaml = @"key: ""value with spaces"""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value with spaces")); + } + + [Test] + public void Parse_SingleQuotedString_ReturnsUnquotedString() + { + var yaml = "key: 'value with spaces'"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value with spaces")); + } + + [Test] + public void Parse_DoubleQuotedStringWithEscapes_ReturnsUnescapedString() + { + var yaml = @"key: ""line1\nline2\ttab\\backslash\""quote"""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("line1\nline2\ttab\\backslash\"quote")); + } + + [TestCase(@"key: ""\0""", ExpectedResult = "\0")] + [TestCase(@"key: ""\a""", ExpectedResult = "\a")] + [TestCase(@"key: ""\b""", ExpectedResult = "\b")] + [TestCase(@"key: ""\f""", ExpectedResult = "\f")] + [TestCase(@"key: ""\n""", ExpectedResult = "\n")] + [TestCase(@"key: ""\r""", ExpectedResult = "\r")] + [TestCase(@"key: ""\t""", ExpectedResult = "\t")] + [TestCase(@"key: ""\v""", ExpectedResult = "\v")] + [TestCase(@"key: ""\\""", ExpectedResult = "\\")] + [TestCase(@"key: ""\""""", ExpectedResult = "\"")] + public object? Parse_DoubleQuotedEscapeSequences_ReturnsCorrectCharacter(string yaml) + { + var result = Yaml.Parse(yaml); + return result["key"]; + } + + [Test] + public void Parse_SingleQuotedStringWithDoubledQuote_ReturnsQuote() + { + var yaml = "key: 'can''t'"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("can't")); + } + + [Test] + public void Parse_QuotedKeyValue_ParsesCorrectly() + { + var yaml = "\"quoted key\": \"quoted value\""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["\"quoted key\""], Is.EqualTo("quoted value")); + } + + [Test] + public void Parse_UnterminatedDoubleQuotedString_ThrowsFormatException() + { + var yaml = "key: \"unterminated"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("not terminated")); + } + + [Test] + public void Parse_UnterminatedSingleQuotedString_ThrowsFormatException() + { + var yaml = "key: 'unterminated"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("not terminated")); + } + + [Test] + public void Parse_InvalidEscapeSequence_ThrowsFormatException() + { + var yaml = @"key: ""\x"""; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Unsupported escape sequence")); + } + + [Test] + public void Parse_SingleQuoteNotDoubled_ThrowsFormatException() + { + var yaml = "key: 'it's'"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("not terminated")); + } + + #endregion + + #region Comments + + [Test] + public void Parse_InlineComment_IgnoresComment() + { + var yaml = "key: value # this is a comment"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value")); + } + + [Test] + public void Parse_HashInQuotedString_PreservesHash() + { + var yaml = @"key: ""value # not a comment"""; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value # not a comment")); + } + + [Test] + public void Parse_HashWithoutPrecedingSpace_NotTreatedAsComment() + { + var yaml = "key: value#not-a-comment"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value#not-a-comment")); + } + + [Test] + public void Parse_MultipleCommentsAndData_ParsesDataCorrectly() + { + var yaml = """ + # Comment before + key1: value1 # inline comment + # Comment between + key2: value2 + # Comment after + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + } + } + + #endregion + + #region Nested Mappings + + [Test] + public void Parse_NestedMapping_ReturnsNestedDictionary() + { + var yaml = """ + parent: + child1: value1 + child2: value2 + """; + + var result = Yaml.Parse(yaml); + + var nested = result["parent"] as IReadOnlyDictionary; + Assert.That(nested, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(nested!["child1"], Is.EqualTo("value1")); + Assert.That(nested["child2"], Is.EqualTo("value2")); + } + } + + [Test] + public void Parse_DeeplyNestedMapping_ReturnsNestedDictionaries() + { + var yaml = """ + level1: + level2: + level3: + key: value + """; + + var result = Yaml.Parse(yaml); + + var level1 = result["level1"] as IReadOnlyDictionary; + Assert.That(level1, Is.Not.Null); + + var level2 = level1!["level2"] as IReadOnlyDictionary; + Assert.That(level2, Is.Not.Null); + + var level3 = level2!["level3"] as IReadOnlyDictionary; + Assert.That(level3, Is.Not.Null); + + Assert.That(level3!["key"], Is.EqualTo("value")); + } + + [Test] + public void Parse_MixedLevelMappings_ParsesCorrectly() + { + var yaml = """ + root1: value1 + root2: + child1: value2 + child2: value3 + root3: value4 + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["root1"], Is.EqualTo("value1")); + Assert.That(result["root3"], Is.EqualTo("value4")); + } + + var nested = result["root2"] as IReadOnlyDictionary; + Assert.That(nested, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(nested!["child1"], Is.EqualTo("value2")); + Assert.That(nested["child2"], Is.EqualTo("value3")); + } + } + + #endregion + + #region Sequences + + [Test] + public void Parse_SimpleSequence_ReturnsList() + { + var yaml = """ + items: + - item1 + - item2 + - item3 + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(["item1", "item2", "item3"])); + } + + [Test] + public void Parse_SequenceWithMixedTypes_ReturnsList() + { + var yaml = """ + items: + - string value + - 42 + - 3.14 + - true + - null + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new object?[] { "string value", 42L, 3.14, true, null })); + } + + [Test] + public void Parse_SequenceWithNestedMappings_ReturnsList() + { + var yaml = """ + items: + - name: item1 + value: 1 + - name: item2 + value: 2 + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.Not.Null); + + var item1 = list![0] as IReadOnlyDictionary; + Assert.That(item1, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(item1!["name"], Is.EqualTo("item1")); + Assert.That(item1["value"], Is.EqualTo(1L)); + } + + var item2 = list[1] as IReadOnlyDictionary; + Assert.That(item2, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(item2!["name"], Is.EqualTo("item2")); + Assert.That(item2["value"], Is.EqualTo(2L)); + } + } + + [Test] + public void Parse_SequenceWithEmptyItems_ReturnsListWithNulls() + { + var yaml = """ + items: + - + - + - value + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new object?[] { null, null, "value" })); + } + + [Test] + public void Parse_SequenceItemWithMapping_ParsesCorrectly() + { + var yaml = """ + items: + - key1: value1 + - + key2: value2 + key3: value3 + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.Not.Null); + + var item1 = list![0] as IReadOnlyDictionary; + Assert.That(item1, Is.Not.Null); + Assert.That(item1!["key1"], Is.EqualTo("value1")); + + var item2 = list[1] as IReadOnlyDictionary; + Assert.That(item2, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(item2!["key2"], Is.EqualTo("value2")); + Assert.That(item2["key3"], Is.EqualTo("value3")); + } + } + + #endregion + + #region Block Scalars + + [Test] + public void Parse_LiteralBlockScalar_PreservesNewlines() + { + var yaml = """ + description: | + Line 1 + Line 2 + Line 3 + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result["description"], Is.EqualTo("Line 1\nLine 2\nLine 3")); + } + + [Test] + public void Parse_FoldedBlockScalar_FoldsNewlines() + { + var yaml = """ + description: > + Line 1 + Line 2 + Line 3 + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result["description"], Is.EqualTo("Line 1 Line 2 Line 3")); + } + + [Test] + public void Parse_EmptyLiteralBlockScalar_ReturnsEmptyString() + { + var yaml = """ + description: | + next: value + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["description"], Is.EqualTo("")); + Assert.That(result["next"], Is.EqualTo("value")); + } + } + + [Test] + public void Parse_EmptyFoldedBlockScalar_ReturnsEmptyString() + { + var yaml = """ + description: > + next: value + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["description"], Is.EqualTo("")); + Assert.That(result["next"], Is.EqualTo("value")); + } + } + + [Test] + public void Parse_BlockScalarWithTrailingWhitespace_TrimsEnd() + { + var yaml = """ + description: | + Line 1 + Line 2 + + + """; + + var result = Yaml.Parse(yaml); + + Assert.That(result["description"], Is.EqualTo("Line 1\nLine 2")); + } + + [Test] + public void Parse_BlockScalarInSequence_ParsesCorrectly() + { + var yaml = """ + items: + - | + Block 1 + Line 2 + - normal value + - > + Folded + text + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(["Block 1\nLine 2", "normal value", "Folded text"])); + } + + [Test] + public void Parse_BlockScalarInNestedMapping_ParsesCorrectly() + { + var yaml = """ + parent: + description: | + Multi + Line + other: value + """; + + var result = Yaml.Parse(yaml); + + var nested = result["parent"] as IReadOnlyDictionary; + Assert.That(nested, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(nested!["description"], Is.EqualTo("Multi\nLine")); + Assert.That(nested["other"], Is.EqualTo("value")); + } + } + + #endregion + + #region Indentation and Structure + + [Test] + public void Parse_IncorrectIndentation_ThrowsFormatException() + { + var yaml = """ + key1: value1 + key2: value2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Unexpected indentation")); + } + + [Test] + public void Parse_SequenceAtRootLevel_ThrowsFormatException() + { + var yaml = """ + - item1 + - item2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("mapping key was expected")); + } + + [Test] + public void Parse_MissingColon_ThrowsFormatException() + { + var yaml = "key value"; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("must contain ':'")); + } + + [Test] + public void Parse_DuplicateKey_ThrowsFormatException() + { + var yaml = """ + key: value1 + key: value2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Duplicate key")); + } + + [Test] + public void Parse_DuplicateKeyInNested_ThrowsFormatException() + { + var yaml = """ + parent: + key: value1 + key: value2 + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Duplicate key")); + } + + [Test] + public void Parse_DuplicateKeyInSequenceItem_ThrowsFormatException() + { + var yaml = """ + items: + - name: item1 + name: duplicate + """; + + Assert.That(() => Yaml.Parse(yaml), Throws.TypeOf() + .With.Message.Contains("Duplicate key")); + } + + #endregion + + #region Complex Scenarios + + [Test] + public void Parse_ComplexDocument_ParsesCorrectly() + { + var yaml = """ + # Configuration file + version: 1.0 + enabled: true + count: 42 + + database: + host: localhost + port: 5432 + credentials: + username: admin + password: "secret!@#" + + servers: + - name: server1 + ip: 192.168.1.1 + active: true + - name: server2 + ip: 192.168.1.2 + active: false + + description: | + This is a multi-line + description that preserves + line breaks. + + summary: > + This is a folded + multi-line text + that joins lines. + + tags: + - production + - critical + - monitored + + metadata: null + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["version"], Is.EqualTo(1.0)); + Assert.That(result["enabled"], Is.True); + Assert.That(result["count"], Is.EqualTo(42L)); + Assert.That(result["metadata"], Is.Null); + } + + var database = result["database"] as IReadOnlyDictionary; + Assert.That(database, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(database!["host"], Is.EqualTo("localhost")); + Assert.That(database["port"], Is.EqualTo(5432L)); + } + + var credentials = database!["credentials"] as IReadOnlyDictionary; + Assert.That(credentials, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(credentials!["username"], Is.EqualTo("admin")); + Assert.That(credentials["password"], Is.EqualTo("secret!@#")); + } + + var servers = result["servers"] as IList; + Assert.That(servers, Is.Not.Null); + + var server1 = servers![0] as IReadOnlyDictionary; + Assert.That(server1, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(server1!["name"], Is.EqualTo("server1")); + Assert.That(server1["ip"], Is.EqualTo("192.168.1.1")); + Assert.That(server1["active"], Is.True); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["description"], Is.EqualTo("This is a multi-line\ndescription that preserves\nline breaks.")); + Assert.That(result["summary"], Is.EqualTo("This is a folded multi-line text that joins lines.")); + } + + var tags = result["tags"] as IList; + Assert.That(tags, Is.EqualTo(["production", "critical", "monitored"])); + } + + [Test] + public void Parse_ColonInValue_ParsesCorrectly() + { + var yaml = """ + url: http://example.com:8080 + time: 12:30:45 + quoted: "key: value" + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["url"], Is.EqualTo("http://example.com:8080")); + Assert.That(result["time"], Is.EqualTo("12:30:45")); + Assert.That(result["quoted"], Is.EqualTo("key: value")); + } + } + + [Test] + public void Parse_WindowsAndUnixLineEndings_ParsesCorrectly() + { + var yaml = "key1: value1\r\nkey2: value2\nkey3: value3\r\n"; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + Assert.That(result["key3"], Is.EqualTo("value3")); + } + } + + [Test] + public void Parse_TrailingWhitespace_HandlesCorrectly() + { + var yaml = """ + key1: value1 + key2: value2 + key3: value3 + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("value1")); + Assert.That(result["key2"], Is.EqualTo("value2")); + Assert.That(result["key3"], Is.EqualTo("value3")); + } + } + + [Test] + public void Parse_EmptyMapping_ReturnsNull() + { + var yaml = """ + parent: + next: value + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["parent"], Is.Null); + Assert.That(result["next"], Is.EqualTo("value")); + } + } + + [Test] + public void Parse_EmptySequenceItem_ReturnsNull() + { + var yaml = """ + items: + - + """; + + var result = Yaml.Parse(yaml); + + var list = result["items"] as IList; + Assert.That(list, Is.EqualTo(new object?[] { null })); + } + + #endregion + + #region Edge Cases with Special Characters + + [Test] + public void Parse_DashInValue_NotTreatedAsSequence() + { + var yaml = "key: value-with-dashes"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo("value-with-dashes")); + } + + [Test] + public void Parse_ColonInQuotedKey_ParsesCorrectly() + { + var yaml = "\"key:with:colons\": value"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["\"key:with:colons\""], Is.EqualTo("value")); + } + + [Test] + public void Parse_QuotedBooleanString_ReturnsString() + { + var yaml = """ + key1: "true" + key2: "false" + key3: "null" + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("true")); + Assert.That(result["key2"], Is.EqualTo("false")); + Assert.That(result["key3"], Is.EqualTo("null")); + } + } + + [Test] + public void Parse_QuotedNumericString_ReturnsString() + { + var yaml = """ + key1: "42" + key2: "3.14" + """; + + var result = Yaml.Parse(yaml); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result["key1"], Is.EqualTo("42")); + Assert.That(result["key2"], Is.EqualTo("3.14")); + } + } + + [Test] + public void Parse_LeadingZeros_ParsesAsInteger() + { + var yaml = "key: 007"; + + var result = Yaml.Parse(yaml); + + Assert.That(result["key"], Is.EqualTo(7L)); + } + + #endregion + } +} From f425db509f3417ac5bb82a855893fb5dada1ac13 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 21 Mar 2026 21:02:15 +0800 Subject: [PATCH 3/5] Add Markdown heading and front matter parsing utilities Added methods to enumerate ATX headings, extract the first heading, and parse YAML front matter blocks in the Markdown class. Replaced MarkdownHelperTests with a new MarkdownTests suite covering encoding/decoding, heading detection, and front matter extraction, including edge cases and error handling. --- src/Support/Markdown.cs | 133 ++++++++++++++++++ tests/Support/MarkdownHelperTests.cs | 66 --------- tests/Support/MarkdownTests.cs | 198 +++++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 66 deletions(-) delete mode 100644 tests/Support/MarkdownHelperTests.cs create mode 100644 tests/Support/MarkdownTests.cs diff --git a/src/Support/Markdown.cs b/src/Support/Markdown.cs index 79c7cd49..30f27d5f 100644 --- a/src/Support/Markdown.cs +++ b/src/Support/Markdown.cs @@ -6,6 +6,8 @@ namespace Kampute.DocToolkit.Support { using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.CompilerServices; @@ -211,5 +213,136 @@ public static int GetMinimumFenceBackticks(ReadOnlySpan code) ? maxConsecutiveBackticks + 1 : DefaultFenceBackticks; } + + /// + /// Enumerates all ATX-style headings in the given Markdown content, returning their text and corresponding levels. + /// + /// The Markdown content to inspect. + /// An enumerable of tuples, each containing the level and text of a heading found in the content. + /// Thrown when is . + public static IEnumerable<(int Level, string Heading)> EnumerateHeadings(string content) + { + if (content is null) + throw new ArgumentNullException(nameof(content)); + + var pos = 0; + var length = content.Length; + while (pos < length) + { + var lineStart = pos; + while (pos < length && content[pos] != '\n') + pos++; + + var line = content.AsSpan(lineStart, pos - lineStart).Trim(); + if (pos < length) + pos++; + + if (!line.IsEmpty && line[0] == '#') + { + var level = 1; + while (level < line.Length && line[level] == '#') + level++; + + var headingText = line[level..].Trim(); + if (!headingText.IsEmpty) + yield return (level, headingText.ToString()); + } + } + } + + /// + /// Attempts to find the first ATX-style heading that appears before any non-heading content in the given Markdown text. + /// + /// The Markdown content to inspect. + /// When this method returns, contains the heading text if found; otherwise, . + /// if a heading was found and it appears before any non-heading content; otherwise, . + public static bool TryGetFirstHeading(ReadOnlySpan content, [NotNullWhen(true)] out string? heading) + { + var pos = 0; + var length = content.Length; + while (pos < length) + { + var lineStart = pos; + while (pos < length && content[pos] != '\n') + pos++; + + var line = content[lineStart..pos].Trim(); + if (pos < length) + pos++; // Skip the newline character + + if (line.IsEmpty) + continue; + + if (line[0] != '#') + break; + + var level = 1; + while (level < line.Length && line[level] == '#') + level++; + + var headingText = line[level..].TrimStart(); + if (headingText.IsEmpty) + break; + + heading = headingText.ToString(); + return true; + } + + heading = null; + return false; + } + + /// + /// Attempts to extract and parse the front matter block from Markdown content. + /// + /// The Markdown content to inspect. + /// When this method returns, contains the parsed front matter metadata if found; otherwise, an empty dictionary. + /// When this method returns, contains the index in at which the body content starts. + /// if a front matter block was found and parsed; otherwise, . + /// Thrown when a front matter block is found but cannot be parsed as valid YAML. + /// + /// Front matter must start at the very beginning of the document, delimited by --- on its own line. + /// The closing delimiter may be either --- or .... + /// + public static bool TryExtractFrontMatter(ReadOnlySpan content, out IReadOnlyDictionary frontMatter, out int contentStart) + { + frontMatter = Yaml.Empty; + contentStart = 0; + + if (!content.StartsWith("---")) + return false; + + var length = content.Length; + + var pos = 3; + if (pos < length && content[pos] == '\r') + pos++; + if (pos >= length || content[pos] != '\n') + return false; + + pos++; + var yamlStart = pos; + + while (pos < length) + { + var lineStart = pos; + while (pos < length && content[pos] != '\n') + pos++; + + var lineEnd = (pos > lineStart && content[pos - 1] == '\r') ? pos - 1 : pos; + if (pos < length) + pos++; + + var line = content[lineStart..lineEnd]; + if (line.Equals("---", StringComparison.Ordinal) || line.Equals("...", StringComparison.Ordinal)) + { + frontMatter = Yaml.Parse(content[yamlStart..lineStart].TrimEnd()); + contentStart = pos; + return true; + } + } + + return false; + } } } diff --git a/tests/Support/MarkdownHelperTests.cs b/tests/Support/MarkdownHelperTests.cs deleted file mode 100644 index 6a9c98b2..00000000 --- a/tests/Support/MarkdownHelperTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (C) Kampute -// -// Released under the terms of the MIT license. -// See the LICENSE file in the project root for the full license text. - -namespace Kampute.DocToolkit.Test.Support -{ - using Kampute.DocToolkit.Support; - using NUnit.Framework; - using System.IO; - - [TestFixture] - public class MarkdownHelperTests - { - [TestCase("**bold text**", ExpectedResult = @"\*\*bold text\*\*")] - [TestCase("# Header #1", ExpectedResult = @"\# Header #1")] - [TestCase("text with *italics* and `code`", ExpectedResult = @"text with \*italics\* and \`code\`")] - [TestCase("[link](http://example.com)", ExpectedResult = @"\[link\](http://example.com)")] - public string Encode_ReturnsExpectedText(string text) - { - return Markdown.Encode(text); - } - - [TestCase("**bold text**", ExpectedResult = @"\*\*bold text\*\*")] - [TestCase("# Header #1", ExpectedResult = @"\# Header #1")] - [TestCase("text with *italics* and `code`", ExpectedResult = @"text with \*italics\* and \`code\`")] - [TestCase("[link](http://example.com)", ExpectedResult = @"\[link\](http://example.com)")] - public string Encode_WritesExpectedText(string text) - { - using var writer = new StringWriter(); - Markdown.Encode(text, writer); - return writer.ToString(); - } - - [TestCase(@"\*\*bold text\*\*", ExpectedResult = "**bold text**")] - [TestCase(@"\# Header #1", ExpectedResult = "# Header #1")] - [TestCase(@"text with \*italics\*, \`code\`, and path c:\\root", ExpectedResult = "text with *italics*, `code`, and path c:\\root")] - [TestCase(@"\[link\](http://example.com)", ExpectedResult = "[link](http://example.com)")] - public string Decode_ReturnsExpectedText(string text) - { - return Markdown.Decode(text); - } - - [TestCase(@"\*\*bold text\*\*", ExpectedResult = "**bold text**")] - [TestCase(@"\# Header #1", ExpectedResult = "# Header #1")] - [TestCase(@"text with \*italics\*, \`code\`, and path c:\\root", ExpectedResult = "text with *italics*, `code`, and path c:\\root")] - [TestCase(@"\[link\](http://example.com)", ExpectedResult = "[link](http://example.com)")] - public string Decode_WritesExpectedText(string text) - { - using var writer = new StringWriter(); - Markdown.Decode(text, writer); - return writer.ToString(); - } - - [TestCase("", ExpectedResult = 3)] - [TestCase("Hello `World`", ExpectedResult = 3)] - [TestCase("``", ExpectedResult = 3)] - [TestCase("```csharp", ExpectedResult = 4)] - [TestCase("a``b```c", ExpectedResult = 4)] - [TestCase("````html", ExpectedResult = 5)] - public int GetMinimumFenceBackticks_ReturnsExpectedNumber(string text) - { - return Markdown.GetMinimumFenceBackticks(text); - } - } -} diff --git a/tests/Support/MarkdownTests.cs b/tests/Support/MarkdownTests.cs new file mode 100644 index 00000000..fe2623af --- /dev/null +++ b/tests/Support/MarkdownTests.cs @@ -0,0 +1,198 @@ +// Copyright (C) Kampute +// +// Released under the terms of the MIT license. +// See the LICENSE file in the project root for the full license text. + +namespace Kampute.DocToolkit.Test.Support +{ + using Kampute.DocToolkit.Support; + using NUnit.Framework; + using System; + using System.IO; + using System.Linq; + + [TestFixture] + public class MarkdownTests + { + [TestCase("**bold text**", ExpectedResult = @"\*\*bold text\*\*")] + [TestCase("# Header #1", ExpectedResult = @"\# Header #1")] + [TestCase("text with *italics* and `code`", ExpectedResult = @"text with \*italics\* and \`code\`")] + [TestCase("[link](http://example.com)", ExpectedResult = @"\[link\](http://example.com)")] + public string Encode_ReturnsExpectedText(string text) + { + return Markdown.Encode(text); + } + + [TestCase("**bold text**", ExpectedResult = @"\*\*bold text\*\*")] + [TestCase("# Header #1", ExpectedResult = @"\# Header #1")] + [TestCase("text with *italics* and `code`", ExpectedResult = @"text with \*italics\* and \`code\`")] + [TestCase("[link](http://example.com)", ExpectedResult = @"\[link\](http://example.com)")] + public string Encode_WritesExpectedText(string text) + { + using var writer = new StringWriter(); + Markdown.Encode(text, writer); + return writer.ToString(); + } + + [TestCase(@"\*\*bold text\*\*", ExpectedResult = "**bold text**")] + [TestCase(@"\# Header #1", ExpectedResult = "# Header #1")] + [TestCase(@"text with \*italics\*, \`code\`, and path c:\\root", ExpectedResult = "text with *italics*, `code`, and path c:\\root")] + [TestCase(@"\[link\](http://example.com)", ExpectedResult = "[link](http://example.com)")] + public string Decode_ReturnsExpectedText(string text) + { + return Markdown.Decode(text); + } + + [TestCase(@"\*\*bold text\*\*", ExpectedResult = "**bold text**")] + [TestCase(@"\# Header #1", ExpectedResult = "# Header #1")] + [TestCase(@"text with \*italics\*, \`code\`, and path c:\\root", ExpectedResult = "text with *italics*, `code`, and path c:\\root")] + [TestCase(@"\[link\](http://example.com)", ExpectedResult = "[link](http://example.com)")] + public string Decode_WritesExpectedText(string text) + { + using var writer = new StringWriter(); + Markdown.Decode(text, writer); + return writer.ToString(); + } + + [TestCase("", ExpectedResult = 3)] + [TestCase("Hello `World`", ExpectedResult = 3)] + [TestCase("``", ExpectedResult = 3)] + [TestCase("```csharp", ExpectedResult = 4)] + [TestCase("a``b```c", ExpectedResult = 4)] + [TestCase("````html", ExpectedResult = 5)] + public int GetMinimumFenceBackticks_ReturnsExpectedNumber(string text) + { + return Markdown.GetMinimumFenceBackticks(text); + } + + [TestCase("", ExpectedResult = new string[0])] + [TestCase("Just plain text\nNo headings here", ExpectedResult = new string[0])] + [TestCase("# My Heading", ExpectedResult = new[] { "1:My Heading" })] + [TestCase("# Level 1\n## Level 2\n### Level 3", ExpectedResult = new[] { "1:Level 1", "2:Level 2", "3:Level 3" })] + [TestCase("#\n# Valid", ExpectedResult = new[] { "1:Valid" })] + [TestCase("text # not a heading\n# Real Heading", ExpectedResult = new[] { "1:Real Heading" })] + [TestCase("# Heading 1\r\n## Heading 2", ExpectedResult = new[] { "1:Heading 1", "2:Heading 2" })] + public string[] EnumerateHeadings_ReturnsExpectedHeadings(string content) + { + return [.. Markdown.EnumerateHeadings(content).Select(h => $"{h.Level}:{h.Heading}")]; + } + + [TestCase("", ExpectedResult = null)] + [TestCase("Just plain text", ExpectedResult = null)] + [TestCase("#", ExpectedResult = null)] + [TestCase("# ", ExpectedResult = null)] + [TestCase("# Title", ExpectedResult = "Title")] + [TestCase("#Title", ExpectedResult = "Title")] + [TestCase("## Title\n### Section", ExpectedResult = "Title")] + [TestCase("\n\n## Title", ExpectedResult = "Title")] + [TestCase("Some text\n# Section", ExpectedResult = null)] + public string? TryGetFirstHeading_ReturnsExpectedResult(string content) + { + return Markdown.TryGetFirstHeading(content, out var heading) ? heading : null; + } + + [Test] + public void TryExtractFrontMatter_NoFrontMatter_ReturnsFalse() + { + var content = "Just some content"; + + var result = Markdown.TryExtractFrontMatter(content, out var frontMatter, out var contentStart); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(frontMatter, Is.Empty); + Assert.That(contentStart, Is.Zero); + } + } + + [Test] + public void TryExtractFrontMatter_FrontMatterNotAtStart_ReturnsFalse() + { + var content = "Some text\n---\nkey: value\n---\n"; + + var result = Markdown.TryExtractFrontMatter(content, out var frontMatter, out var contentStart); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.False); + Assert.That(frontMatter, Is.Empty); + Assert.That(contentStart, Is.Zero); + } + } + + [Test] + public void TryExtractFrontMatter_ValidFrontMatterWithDashes_ReturnsTrue() + { + var content = "---\nkey: value\n---\nBody"; + + var result = Markdown.TryExtractFrontMatter(content, out var frontMatter, out var contentStart); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(frontMatter, Has.Count.EqualTo(1)); + Assert.That(contentStart, Is.EqualTo(content.IndexOf("Body"))); + } + + Assert.That(frontMatter, Contains.Key("key").WithValue("value")); + } + + [Test] + public void TryExtractFrontMatter_ValidFrontMatterWithDots_ReturnsTrue() + { + var content = "---\nkey: value\n...\nBody"; + + var result = Markdown.TryExtractFrontMatter(content, out var frontMatter, out var contentStart); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(frontMatter, Has.Count.EqualTo(1)); + Assert.That(contentStart, Is.EqualTo(content.IndexOf("Body"))); + } + + Assert.That(frontMatter, Contains.Key("key").WithValue("value")); + } + + [Test] + public void TryExtractFrontMatter_EmptyFrontMatter_ReturnsTrue() + { + var content = "---\n---\nBody"; + + var result = Markdown.TryExtractFrontMatter(content, out var frontMatter, out var contentStart); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(frontMatter, Is.Empty); + Assert.That(contentStart, Is.EqualTo(content.IndexOf("Body"))); + } + } + + [Test] + public void TryExtractFrontMatter_WithCrLf_ReturnsTrue() + { + var content = "---\r\nkey: value\r\n---\r\nBody"; + + var result = Markdown.TryExtractFrontMatter(content, out var frontMatter, out var contentStart); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(frontMatter, Has.Count.EqualTo(1)); + Assert.That(contentStart, Is.EqualTo(content.IndexOf("Body"))); + } + + Assert.That(frontMatter, Contains.Key("key").WithValue("value")); + } + + [Test] + public void TryExtractFrontMatter_InvalidYaml_ThrowsFormatException() + { + var content = "---\ninvalid yaml without colon\n---\n"; + + Assert.That(() => Markdown.TryExtractFrontMatter(content, out _, out _), Throws.TypeOf()); + } + } +} From 93c7cfc166bb65849d3c7903eeb286bf25ec3998 Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 21 Mar 2026 21:04:55 +0800 Subject: [PATCH 4/5] Support YAML front matter in MarkdownFileTopic Add parsing and exposure of YAML front matter metadata in MarkdownFileTopic. Title extraction now prioritizes the "title" field in front matter before falling back to Markdown headings or default logic. Content readers now exclude front matter. Includes new tests for front matter title handling. --- src/Topics/MarkdownFileTopic.cs | 95 ++++++++++++++++++++------ tests/Topics/MarkdownFileTopicTests.cs | 1 + 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/src/Topics/MarkdownFileTopic.cs b/src/Topics/MarkdownFileTopic.cs index ee085a57..762ef3b3 100644 --- a/src/Topics/MarkdownFileTopic.cs +++ b/src/Topics/MarkdownFileTopic.cs @@ -7,6 +7,7 @@ namespace Kampute.DocToolkit.Topics { using Kampute.DocToolkit.Support; using System; + using System.Collections.Generic; using System.IO; /// @@ -20,6 +21,8 @@ namespace Kampute.DocToolkit.Topics /// public class MarkdownFileTopic : FileTopic { + private readonly Lazy markdown; + /// /// Initializes a new instance of the class. /// @@ -32,8 +35,18 @@ public class MarkdownFileTopic : FileTopic public MarkdownFileTopic(string id, string path) : base(id, path) { + markdown = new(ParseMarkdown); } + /// + /// Gets the collection of front matter metadata associated with the Markdown file topic. + /// + /// + /// A read-only dictionary containing the front matter metadata, where the keys are the metadata names + /// and the values are the corresponding metadata values. + /// + public IReadOnlyDictionary FrontMatter => markdown.Value.FrontMatter; + /// /// Gets the format of the content in the topic. /// @@ -43,36 +56,32 @@ public MarkdownFileTopic(string id, string path) /// protected sealed override string ContentFormat => FileExtensions.Markdown; + /// + /// Creates a to read the content of the source file, excluding any front matter. + /// + /// The documentation context that provides additional information for the operation. + /// A for reading the content of the file specified by , starting after the front matter if present. + /// Thrown when an I/O error occurs while reading the file specified by . + protected override TextReader CreateContentReader(IDocumentationContext context) => new StringReader(markdown.Value.Content); + /// /// Extracts the title of the topic from the Markdown file. /// /// The title of the topic. - /// Thrown when an I/O error occurs while reading the file specified by . /// - /// This method attempts to extract the first Markdown heading from the Markdown file. If the heading is not found, - /// it falls back to the default title generation from the topic's name. + /// This method starts by checking the front matter for a "title" entry. If a valid title exists, it uses that as the topic title. + /// If not, it tries to extract the first Markdown heading from the file. If successful, that heading becomes the title. + /// Otherwise, it reverts to the base class's default title generation logic. /// protected override string GenerateTitle() { try { - using var reader = File.OpenText(FilePath); - - string? line; - while ((line = reader.ReadLine()) is not null) - { - if (string.IsNullOrWhiteSpace(line)) - continue; + if (FrontMatter.TryGetValue("title", out var rawTitle) && rawTitle is string title && !string.IsNullOrWhiteSpace(title)) + return title.Trim(); - if (!line.TrimStart(' ').StartsWith('#')) - break; - - var title = line.TrimStart(['#', ' ']).TrimEnd(); - if (title.Length == 0) - break; - - return title; - } + if (Markdown.TryGetFirstHeading(markdown.Value.Content, out var heading)) + return heading; } catch (Exception) { @@ -81,5 +90,53 @@ protected override string GenerateTitle() return base.GenerateTitle(); } + + /// + /// Parses the Markdown file to extract the front matter and content. + /// + /// A struct containing the parsed front matter and content of the Markdown file. + /// Thrown when an I/O error occurs while reading the file specified by . + private MarkdownData ParseMarkdown() + { + var text = File.ReadAllText(FilePath); + return Markdown.TryExtractFrontMatter(text, out var frontMatter, out var contentStart) + ? new MarkdownData(frontMatter, text[contentStart..]) + : new MarkdownData(Yaml.Empty, text); + } + + /// + /// Represents the parsed front matter and content of a Markdown file topic. + /// + private sealed class MarkdownData + { + /// + /// Initializes a new instance of the struct with the specified front matter and content. + /// + /// The front matter metadata extracted from the Markdown file. + /// The content of the Markdown file, excluding any front matter. + public MarkdownData(IReadOnlyDictionary frontMatter, string content) + { + FrontMatter = frontMatter ?? Yaml.Empty; + Content = content ?? string.Empty; + } + + /// + /// Gets the front matter metadata extracted from the Markdown file. + /// + /// + /// A read-only dictionary containing the front matter metadata, where the keys are the metadata names + /// and the values are the corresponding metadata values. + /// + public IReadOnlyDictionary FrontMatter { get; } + + /// + /// Gets the content of the Markdown file, excluding any front matter. + /// + /// + /// The content of the Markdown file, excluding any front matter, which is intended to be rendered as + /// the main body of the documentation topic. + /// + public string Content { get; } + } } } diff --git a/tests/Topics/MarkdownFileTopicTests.cs b/tests/Topics/MarkdownFileTopicTests.cs index 01581fe9..02916157 100644 --- a/tests/Topics/MarkdownFileTopicTests.cs +++ b/tests/Topics/MarkdownFileTopicTests.cs @@ -39,6 +39,7 @@ public void TearDown() [TestCase("test-file.md", "Content before title\n# Title in the middle", ExpectedResult = "Test File")] [TestCase("test-file.md", "# First Title\n## Second level heading", ExpectedResult = "First Title")] [TestCase("test-file.md", "# Title with symbols: &@#!?", ExpectedResult = "Title with symbols: &@#!?")] + [TestCase("test-file.md", "---\ntitle: Front Matter Title\n---\n# Markdown Title", ExpectedResult = "Front Matter Title")] public string? Title_ReturnsExpectedTitle(string fileName, string content) { var path = Path.Combine(tempDir, fileName); From 487ba592c54df53f2275da33923b96fceca204bf Mon Sep 17 00:00:00 2001 From: Kambiz Date: Sat, 21 Mar 2026 21:10:15 +0800 Subject: [PATCH 5/5] Update project title and bump version to 2.3.0 Correct project title in .csproj from "Kampute.DocDotLib" to "Kampute.DocToolkit" to reflect the actual project name. Increment version from 2.2.1 to 2.3.0 for the new release. --- src/Kampute.DocToolkit.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Kampute.DocToolkit.csproj b/src/Kampute.DocToolkit.csproj index ae21b1be..b5b794b3 100644 --- a/src/Kampute.DocToolkit.csproj +++ b/src/Kampute.DocToolkit.csproj @@ -2,9 +2,9 @@ netstandard2.1 - Kampute.DocDotLib + Kampute.DocToolkit Provides extensible pipeline for generating .NET API documentation by transforming assembly metadata and XML documentation into structured models, with automatic cross-reference resolution, support for multiple output formats (HTML, Markdown), and integration of conceptual topics. - 2.2.1 + 2.3.0 Kampute Kambiz Khojasteh Copyright (C) Kampute