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 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/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/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/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()); + } + } +} 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 + } +} 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);