diff --git a/docs/syntax/version-variables.md b/docs/syntax/version-variables.md index 42a06a0da..280c57b18 100644 --- a/docs/syntax/version-variables.md +++ b/docs/syntax/version-variables.md @@ -26,7 +26,54 @@ can be printed in any kind of ways. | `{{version.stack | M+1 | M }}` | {{version.stack | M+1 | M }} | | `{{version.stack.base | M.M+1 }}` | {{version.stack.base | M.M+1 }} | -## Available versioning schemes. +## Mutation Operators in Links and Code Blocks + +Mutation operators also work correctly in links and code blocks, making them versatile for various documentation contexts. + +### In Links + +Mutation operators can be used in both link URLs and link text: + +```markdown subs=false +[Download version {{version.stack | M.M}}](https://download.elastic.co/{{version.stack | M.M}}/elasticsearch.tar.gz) +[Latest major version](https://elastic.co/guide/en/elasticsearch/reference/{{version.stack | M}}/index.html) +``` + +Which renders as: + +[Download version {{version.stack | M.M}}](https://download.elastic.co/{{version.stack | M.M}}/elasticsearch.tar.gz) +[Latest major version](https://elastic.co/guide/en/elasticsearch/reference/{{version.stack | M}}/index.html) + +### In Code Blocks + +Mutation operators work in enhanced code blocks when `subs=true` is specified: + +````markdown subs=false +```bash subs=true +curl -X GET "localhost:9200/_cluster/health?v&pretty" +echo "Elasticsearch {{version.stack | M.M}} is running" +``` +```` + +Which renders as: + +```bash subs=true +curl -X GET "localhost:9200/_cluster/health?v&pretty" +echo "Elasticsearch {{version.stack | M.M}} is running" +``` + +### Whitespace Handling + +Mutation operators are robust and handle whitespace around the pipe character correctly: + +| Syntax | Result | Notes | +|--------|--------| ----- | +| `{{version.stack|M.M}}` | {{version.stack|M.M}} | No spaces | +| `{{version.stack | M.M}}` | {{version.stack | M.M}} | Spaces around pipe | +| `{{version.stack |M.M}}` | {{version.stack |M.M}} | Space before pipe | +| `{{version.stack| M.M}}` | {{version.stack| M.M}} | Space after pipe | + +## Available versioning schemes This is dictated by the [`versions.yml`](https://github.com/elastic/docs-builder/blob/main/config/versions.yml) configuration file diff --git a/src/Elastic.Markdown/Helpers/Interpolation.cs b/src/Elastic.Markdown/Helpers/Interpolation.cs index b74b0d7a8..2c40116e9 100644 --- a/src/Elastic.Markdown/Helpers/Interpolation.cs +++ b/src/Elastic.Markdown/Helpers/Interpolation.cs @@ -3,9 +3,12 @@ // See the LICENSE file in the project root for more information using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.RegularExpressions; +using Elastic.Documentation; using Elastic.Documentation.Diagnostics; using Elastic.Markdown.Myst; +using Elastic.Markdown.Myst.InlineParsers.Substitution; namespace Elastic.Markdown.Helpers; @@ -67,17 +70,27 @@ private static bool ReplaceSubstitutions( continue; var spanMatch = span.Slice(match.Index, match.Length); - var key = spanMatch.Trim(['{', '}']); + var fullKey = spanMatch.Trim(['{', '}']); + + // Enhanced mutation support: parse key and mutations using shared utility + var (cleanKey, mutations) = SubstitutionMutationHelper.ParseKeyWithMutations(fullKey.ToString()); + foreach (var lookup in lookups) { - if (!lookup.TryGetValue(key, out var value)) - continue; + if (lookup.TryGetValue(cleanKey, out var value)) + { + // Apply mutations if present using shared utility + if (mutations.Length > 0) + { + value = SubstitutionMutationHelper.ApplyMutations(value, mutations); + } - collector?.CollectUsedSubstitutionKey(key); + collector?.CollectUsedSubstitutionKey(cleanKey); - replacement ??= span.ToString(); - replacement = replacement.Replace(spanMatch.ToString(), value); - replaced = true; + replacement ??= span.ToString(); + replacement = replacement.Replace(spanMatch.ToString(), value); + replaced = true; + } } } diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs new file mode 100644 index 000000000..10b347d92 --- /dev/null +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionMutationHelper.cs @@ -0,0 +1,124 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Linq; +using System.Text.Json; +using Elastic.Documentation; + +namespace Elastic.Markdown.Myst.InlineParsers.Substitution; + +/// +/// Shared utility for parsing and applying substitution mutations +/// +public static class SubstitutionMutationHelper +{ + /// + /// Parses a substitution key with mutations and returns the key and mutation components + /// + /// The raw substitution key (e.g., "version.stack | M.M") + /// A tuple containing the cleaned key and array of mutation strings + public static (string Key, string[] Mutations) ParseKeyWithMutations(string rawKey) + { + // Improved handling of pipe-separated components with better whitespace handling + var components = rawKey.Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var key = components[0].Trim(); + var mutations = components.Length > 1 ? components[1..] : []; + + return (key, mutations); + } + + /// + /// Applies mutations to a value using the existing SubstitutionMutation system + /// + /// The original value to transform + /// Collection of SubstitutionMutation enums to apply + /// The transformed value + public static string ApplyMutations(string value, IReadOnlyCollection mutations) + { + var result = value; + foreach (var mutation in mutations) + { + result = ApplySingleMutation(result, mutation); + } + return result; + } + + /// + /// Applies mutations to a value using the existing SubstitutionMutation system + /// + /// The original value to transform + /// Array of mutation strings to apply + /// The transformed value + public static string ApplyMutations(string value, string[] mutations) + { + var result = value; + foreach (var mutationStr in mutations) + { + var trimmedMutation = mutationStr.Trim(); + if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true)) + { + result = ApplySingleMutation(result, mutation); + } + } + return result; + } + + /// + /// Applies a single mutation to a value + /// + /// The value to transform + /// The mutation to apply + /// The transformed value + private static string ApplySingleMutation(string value, SubstitutionMutation mutation) + { + var (success, result) = mutation switch + { + SubstitutionMutation.MajorComponent => TryGetVersion(value, v => $"{v.Major}"), + SubstitutionMutation.MajorX => TryGetVersion(value, v => $"{v.Major}.x"), + SubstitutionMutation.MajorMinor => TryGetVersion(value, v => $"{v.Major}.{v.Minor}"), + SubstitutionMutation.IncreaseMajor => TryGetVersion(value, v => $"{v.Major + 1}.0.0"), + SubstitutionMutation.IncreaseMinor => TryGetVersion(value, v => $"{v.Major}.{v.Minor + 1}.0"), + SubstitutionMutation.LowerCase => (true, value.ToLowerInvariant()), + SubstitutionMutation.UpperCase => (true, value.ToUpperInvariant()), + SubstitutionMutation.Capitalize => (true, Capitalize(value)), + SubstitutionMutation.KebabCase => (true, ToKebabCase(value)), + SubstitutionMutation.CamelCase => (true, ToCamelCase(value)), + SubstitutionMutation.PascalCase => (true, ToPascalCase(value)), + SubstitutionMutation.SnakeCase => (true, ToSnakeCase(value)), + SubstitutionMutation.TitleCase => (true, TitleCase(value)), + SubstitutionMutation.Trim => (true, Trim(value)), + _ => (false, value) + }; + return success ? result : value; + } + + private static (bool Success, string Result) TryGetVersion(string version, Func transform) + { + if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v)) + return (false, version); + return (true, transform(v)); + } + + // These methods match the exact implementation in SubstitutionRenderer + private static string Capitalize(string input) => + input switch + { + null => string.Empty, + "" => string.Empty, + _ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1)) + }; + + private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty); + + private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str).Replace(" ", string.Empty); + + private static string ToPascalCase(string str) => TitleCase(str).Replace(" ", string.Empty); + + private static string ToSnakeCase(string str) => JsonNamingPolicy.SnakeCaseLower.ConvertName(str).Replace(" ", string.Empty); + + private static string TitleCase(string str) => str.Split(' ').Select(word => Capitalize(word)).Aggregate((a, b) => $"{a} {b}"); + + private static string Trim(string str) => str.TrimEnd('!', '.', ',', ';', ':', '?', ' ', '\t', '\n', '\r'); +} diff --git a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs index 7b0f33e67..08422bd72 100644 --- a/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs +++ b/src/Elastic.Markdown/Myst/InlineParsers/Substitution/SubstitutionParser.cs @@ -66,61 +66,10 @@ protected override void Write(HtmlRenderer renderer, SubstitutionLeaf leaf) return; } - foreach (var mutation in leaf.Mutations) - { - var (success, update) = mutation switch - { - SubstitutionMutation.MajorComponent => TryGetVersion(replacement, v => $"{v.Major}"), - SubstitutionMutation.MajorX => TryGetVersion(replacement, v => $"{v.Major}.x"), - SubstitutionMutation.MajorMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor}"), - SubstitutionMutation.IncreaseMajor => TryGetVersion(replacement, v => $"{v.Major + 1}.0.0"), - SubstitutionMutation.IncreaseMinor => TryGetVersion(replacement, v => $"{v.Major}.{v.Minor + 1}.0"), - SubstitutionMutation.LowerCase => (true, replacement.ToLowerInvariant()), - SubstitutionMutation.UpperCase => (true, replacement.ToUpperInvariant()), - SubstitutionMutation.Capitalize => (true, Capitalize(replacement)), - SubstitutionMutation.KebabCase => (true, ToKebabCase(replacement)), - SubstitutionMutation.CamelCase => (true, ToCamelCase(replacement)), - SubstitutionMutation.PascalCase => (true, ToPascalCase(replacement)), - SubstitutionMutation.SnakeCase => (true, ToSnakeCase(replacement)), - SubstitutionMutation.TitleCase => (true, TitleCase(replacement)), - SubstitutionMutation.Trim => (true, Trim(replacement)), - _ => throw new Exception($"encountered an unknown mutation '{mutation.ToStringFast(true)}'") - }; - if (!success) - { - _ = renderer.Write(leaf.Content); - return; - } - replacement = update; - } + // Apply mutations using shared utility + replacement = SubstitutionMutationHelper.ApplyMutations(replacement, leaf.Mutations); _ = renderer.Write(replacement); } - - private static string ToCamelCase(string str) => JsonNamingPolicy.CamelCase.ConvertName(str.Replace(" ", string.Empty)); - private static string ToSnakeCase(string str) => JsonNamingPolicy.SnakeCaseLower.ConvertName(str).Replace(" ", string.Empty); - private static string ToKebabCase(string str) => JsonNamingPolicy.KebabCaseLower.ConvertName(str).Replace(" ", string.Empty); - private static string ToPascalCase(string str) => TitleCase(str).Replace(" ", string.Empty); - - private static string TitleCase(string str) => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(str); - - private static string Trim(string str) => - str.AsSpan().Trim(['!', ' ', '\t', '\r', '\n', '.', ',', ')', '(', ':', ';', '<', '>', '[', ']']).ToString(); - - private static string Capitalize(string input) => - input switch - { - null => string.Empty, - "" => string.Empty, - _ => string.Concat(input[0].ToString().ToUpper(), input.AsSpan(1)) - }; - - private (bool, string) TryGetVersion(string version, Func mutate) - { - if (!SemVersion.TryParse(version, out var v) && !SemVersion.TryParse(version + ".0", out v)) - return (false, string.Empty); - - return (true, mutate(v)); - } } public class SubstitutionParser : InlineParser @@ -177,12 +126,12 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) startPosition -= openSticks; startPosition = Math.Max(startPosition, 0); - var key = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant(); + var rawKey = content.ToString().Trim(['{', '}']).Trim().ToLowerInvariant(); var found = false; var replacement = string.Empty; - var components = key.Split('|'); - if (components.Length > 1) - key = components[0].Trim(['{', '}']).Trim().ToLowerInvariant(); + + // Use shared mutation parsing logic + var (key, mutationStrings) = SubstitutionMutationHelper.ParseKeyWithMutations(rawKey); if (context.Substitutions.TryGetValue(key, out var value)) { @@ -215,19 +164,21 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice) else { List? mutations = null; - if (components.Length >= 10) + if (mutationStrings.Length >= 10) processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Substitution key {{{key}}} defines too many mutations, none will be applied"); - else if (components.Length > 1) + else if (mutationStrings.Length > 0) { - foreach (var c in components[1..]) + foreach (var mutationStr in mutationStrings) { - if (SubstitutionMutationExtensions.TryParse(c.Trim(), out var mutation, true, true)) + // Ensure mutation string is properly trimmed and normalized + var trimmedMutation = mutationStr.Trim(); + if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true)) { mutations ??= []; mutations.Add(mutation); } else - processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{c}' on {{{key}}} is undefined"); + processor.EmitError(line + 1, column + 3, substitutionLeaf.Span.Length - 3, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined"); } } diff --git a/src/Elastic.Markdown/Myst/MarkdownParser.cs b/src/Elastic.Markdown/Myst/MarkdownParser.cs index 2acb938e4..f5e01bd21 100644 --- a/src/Elastic.Markdown/Myst/MarkdownParser.cs +++ b/src/Elastic.Markdown/Myst/MarkdownParser.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Cysharp.IO; using Elastic.Documentation.Configuration; +using Elastic.Markdown.Helpers; using Elastic.Markdown.Myst.CodeBlocks; using Elastic.Markdown.Myst.Comments; using Elastic.Markdown.Myst.Directives; @@ -23,7 +24,7 @@ namespace Elastic.Markdown.Myst; -public class MarkdownParser(BuildContext build, IParserResolvers resolvers) +public partial class MarkdownParser(BuildContext build, IParserResolvers resolvers) { private BuildContext Build { get; } = build; public IParserResolvers Resolvers { get; } = resolvers; @@ -69,7 +70,11 @@ public static MarkdownDocument ParseMarkdownStringAsync(BuildContext build, IPar CrossLinkResolver = resolvers.CrossLinkResolver }; var context = new ParserContext(state); - var markdownDocument = Markdig.Markdown.Parse(markdown, pipeline, context); + + // Preprocess substitutions in link patterns before Markdig parsing + var preprocessedMarkdown = PreprocessLinkSubstitutions(markdown, context); + + var markdownDocument = Markdig.Markdown.Parse(preprocessedMarkdown, pipeline, context); return markdownDocument; } @@ -105,7 +110,10 @@ private static async Task ParseAsync( else inputMarkdown = await path.FileSystem.File.ReadAllTextAsync(path.FullName, ctx); - var markdownDocument = Markdig.Markdown.Parse(inputMarkdown, pipeline, context); + // Preprocess substitutions in link patterns before Markdig parsing + var preprocessedMarkdown = PreprocessLinkSubstitutions(inputMarkdown, (ParserContext)context); + + var markdownDocument = Markdig.Markdown.Parse(preprocessedMarkdown, pipeline, context); return markdownDocument; } @@ -166,4 +174,91 @@ public static MarkdownPipeline Pipeline return PipelineCached; } } + + [System.Text.RegularExpressions.GeneratedRegex(@"\[([^\]]+)\]\(([^\)]+)\)", System.Text.RegularExpressions.RegexOptions.Multiline)] + private static partial System.Text.RegularExpressions.Regex LinkPattern(); + + /// + /// Preprocesses substitutions specifically in link patterns [text](url) before Markdig parsing + /// Only processes links that are not inside code blocks with subs=false + /// + private static string PreprocessLinkSubstitutions(string markdown, ParserContext context) + { + // Find all code block boundaries to avoid processing links inside subs=false blocks + var codeBlockRanges = GetCodeBlockRanges(markdown); + + return LinkPattern().Replace(markdown, match => + { + // Check if this link is inside a code block with subs=false + if (IsInsideSubsDisabledCodeBlock(match.Index, codeBlockRanges)) + { + return match.Value; // Don't process links in subs=false code blocks + } + + var linkText = match.Groups[1].Value; + var linkUrl = match.Groups[2].Value; + + // Only preprocess external links to preserve internal link validation behavior + // Check if URL contains substitutions and looks like it might resolve to an external URL + if (linkUrl.Contains("{{") && (linkUrl.Contains("http") || linkText.Contains("{{"))) + { + // Apply substitutions to both link text and URL + var processedText = linkText.ReplaceSubstitutions(context); + var processedUrl = linkUrl.ReplaceSubstitutions(context); + return $"[{processedText}]({processedUrl})"; + } + + // Return original match for internal links + return match.Value; + }); + } + + private static List<(int start, int end, bool subsDisabled)> GetCodeBlockRanges(string markdown) + { + var ranges = new List<(int start, int end, bool subsDisabled)>(); + var lines = markdown.Split('\n'); + var currentPos = 0; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + + // Check for code block start (``` or ````) + if (line.TrimStart().StartsWith("```")) + { + // Check if this line contains subs=false + var subsDisabled = line.Contains("subs=false"); + var blockStart = currentPos; + + // Find the end of the code block + var blockEnd = currentPos + line.Length; + for (var j = i + 1; j < lines.Length; j++) + { + blockEnd += lines[j].Length + 1; // +1 for newline + if (lines[j].TrimStart().StartsWith("```")) + { + break; + } + } + + ranges.Add((blockStart, blockEnd, subsDisabled)); + } + + currentPos += line.Length + 1; // +1 for newline + } + + return ranges; + } + + private static bool IsInsideSubsDisabledCodeBlock(int index, List<(int start, int end, bool subsDisabled)> codeBlockRanges) + { + foreach (var (start, end, subsDisabled) in codeBlockRanges) + { + if (index >= start && index <= end && subsDisabled) + { + return true; + } + } + return false; + } } diff --git a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs index 26af37945..040509985 100644 --- a/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs +++ b/tests/Elastic.Markdown.Tests/Inline/SubstitutionTest.cs @@ -205,3 +205,150 @@ public void OnlySeesGlobalVariable() => Html.Should().NotContain("title=\"{{hello-world}}\"") .And.Contain("title=\"Hello World\""); } + +public class MutationOperatorTest(ITestOutputHelper output) : InlineTest(output, +""" +--- +sub: + version: "9.0.4" +--- + +# Testing Mutation Operators + +Version: {{version|M.M}} +Version with space: {{version | M.M}} +Major only: {{version|M}} +Major only with space: {{version | M}} +Major.x: {{version|M.x}} +Major.x with space: {{version | M.x}} +Increase major: {{version|M+1}} +Increase major with space: {{version | M+1}} +Increase minor: {{version|M.M+1}} +Increase minor with space: {{version | M.M+1}} +""" +) +{ + [Fact] + public void MutationOperatorsWorkWithAndWithoutSpaces() + { + // Both versions with and without spaces should render the same way + Html.Should().Contain("Version: 9.0") + .And.Contain("Version with space: 9.0") + .And.Contain("Major only: 9") + .And.Contain("Major only with space: 9") + .And.Contain("Major.x: 9.x") + .And.Contain("Major.x with space: 9.x") + .And.Contain("Increase major: 10.0.0") + .And.Contain("Increase major with space: 10.0.0") + .And.Contain("Increase minor: 9.1.0") + .And.Contain("Increase minor with space: 9.1.0"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class MultipleMutationOperatorsTest(ITestOutputHelper output) : InlineTest(output, +""" +--- +sub: + version: "9.0.4" + product: "Elasticsearch" +--- + +# Testing Multiple Mutation Operators + +Version: {{version|M.M|lc}} +Version with spaces: {{version | M.M | lc}} +Product: {{product|uc}} +Product with spaces: {{product | uc}} +""" +) +{ + [Fact] + public void MultipleMutationOperatorsWorkWithAndWithoutSpaces() + { + // Both versions with and without spaces should render the same way + Html.Should().Contain("Version: 9.0") + .And.Contain("Version with spaces: 9.0") + .And.Contain("Product: ELASTICSEARCH") + .And.Contain("Product with spaces: ELASTICSEARCH"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class MutationOperatorsInLinksTest(ITestOutputHelper output) : InlineTest(output, +""" +--- +sub: + version: "9.0.4" + product: "Elasticsearch" +--- + +# Testing Mutation Operators in Links + +[Link with mutation operator](https://www.elastic.co/guide/en/elasticsearch/reference/{{version|M.M}}/index.html) +[Link with mutation operator and space](https://www.elastic.co/guide/en/elasticsearch/reference/{{version | M.M}}/index.html) +[Link text with mutation]({{product|uc}} {{version|M.M}}) +[Link text with mutation and space]({{product | uc}} {{version | M.M}}) + +""" +) +{ + [Fact] + public void MutationOperatorsWorkInLinks() + { + // Check URL mutations + Html.Should().Contain("href=\"https://www.elastic.co/guide/en/elasticsearch/reference/9.0/index.html\"") + .And.NotContain("{{version|M.M}}") + .And.NotContain("{{version | M.M}}"); + + // Check link text mutations + Html.Should().Contain("ELASTICSEARCH 9.0") + .And.NotContain("{{product|uc}}") + .And.NotContain("{{version|M.M}}"); + + // Check link text mutations with spaces + Html.Should().Contain("ELASTICSEARCH 9.0") + .And.NotContain("{{product | uc}}") + .And.NotContain("{{version | M.M}}"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +} + +public class MutationOperatorsInCodeBlocksTest(ITestOutputHelper output) : BlockTest(output, +""" +--- +sub: + version: "9.0.4" + product: "Elasticsearch" +--- + +# Testing Mutation Operators in Code Blocks + +```{code} sh subs=true +# Install Elasticsearch {{version|M.M}} +wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-{{version|M.M}}-linux-x86_64.tar.gz + +# With space in mutation +wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-{{version | M.M}}-linux-x86_64.tar.gz +``` +""" +) +{ + [Fact] + public void MutationOperatorsWorkInCodeBlocks() + { + Html.Should().Contain("# Install Elasticsearch 9.0") + .And.Contain("elasticsearch-9.0-linux-x86_64.tar.gz") + .And.NotContain("{{version|M.M}}") + .And.NotContain("{{version | M.M}}"); + } + + [Fact] + public void HasNoErrors() => Collector.Diagnostics.Should().HaveCount(0); +}