|
| 1 | +// Licensed to Elasticsearch B.V under one or more agreements. |
| 2 | +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. |
| 3 | +// See the LICENSE file in the project root for more information |
| 4 | + |
| 5 | +using System.Buffers; |
| 6 | +using System.Diagnostics; |
| 7 | +using System.Text; |
| 8 | +using System.Text.RegularExpressions; |
| 9 | +using Elastic.Documentation; |
| 10 | +using Elastic.Documentation.Diagnostics; |
| 11 | +using Elastic.Markdown.Diagnostics; |
| 12 | +using Elastic.Markdown.Myst.InlineParsers.Substitution; |
| 13 | +using Elastic.Markdown.Myst.Roles; |
| 14 | +using Markdig.Helpers; |
| 15 | +using Markdig.Parsers; |
| 16 | +using Markdig.Renderers; |
| 17 | +using Markdig.Renderers.Html; |
| 18 | +using Markdig.Syntax; |
| 19 | +using Markdig.Syntax.Inlines; |
| 20 | + |
| 21 | +namespace Elastic.Markdown.Myst.InlineParsers.SubstitutionInlineCode; |
| 22 | + |
| 23 | +[DebuggerDisplay("{GetType().Name} Line: {Line}, Content: {Content}, ProcessedContent: {ProcessedContent}")] |
| 24 | +public class SubstitutionInlineCodeLeaf(string role, string content, string processedContent) : RoleLeaf(role, content) |
| 25 | +{ |
| 26 | + public string ProcessedContent { get; } = processedContent; |
| 27 | +} |
| 28 | + |
| 29 | +public class SubstitutionInlineCodeRenderer : HtmlObjectRenderer<SubstitutionInlineCodeLeaf> |
| 30 | +{ |
| 31 | + protected override void Write(HtmlRenderer renderer, SubstitutionInlineCodeLeaf leaf) |
| 32 | + { |
| 33 | + // Render as a code element with the processed content (substitutions applied) |
| 34 | + _ = renderer.Write("<code"); |
| 35 | + _ = renderer.WriteAttributes(leaf); |
| 36 | + _ = renderer.Write(">"); |
| 37 | + _ = renderer.WriteEscape(leaf.ProcessedContent); |
| 38 | + _ = renderer.Write("</code>"); |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +public partial class SubstitutionInlineCodeParser : RoleParser<SubstitutionInlineCodeLeaf> |
| 43 | +{ |
| 44 | + protected override SubstitutionInlineCodeLeaf CreateRole(string role, string content, InlineProcessor parserContext) |
| 45 | + { |
| 46 | + var context = (ParserContext)parserContext.Context!; |
| 47 | + var processedContent = ProcessSubstitutions(content, context, parserContext, 0, 0); |
| 48 | + return new SubstitutionInlineCodeLeaf(role, content, processedContent); |
| 49 | + } |
| 50 | + |
| 51 | + protected override bool Matches(ReadOnlySpan<char> role) => role.SequenceEqual("{subs}".AsSpan()); |
| 52 | + |
| 53 | + private static readonly Regex SubstitutionPattern = SubstitutionRegex(); |
| 54 | + |
| 55 | + private static string ProcessSubstitutions(string content, ParserContext context, InlineProcessor processor, int line, int column) |
| 56 | + { |
| 57 | + var result = new StringBuilder(content); |
| 58 | + var substitutions = new List<(int Start, int Length, string Replacement)>(); |
| 59 | + |
| 60 | + // Find all substitution patterns |
| 61 | + foreach (Match match in SubstitutionPattern.Matches(content)) |
| 62 | + { |
| 63 | + var rawKey = match.Groups[1].Value.Trim().ToLowerInvariant(); |
| 64 | + var found = false; |
| 65 | + var replacement = string.Empty; |
| 66 | + |
| 67 | + // Use shared mutation parsing logic |
| 68 | + var (key, mutationStrings) = SubstitutionMutationHelper.ParseKeyWithMutations(rawKey); |
| 69 | + |
| 70 | + if (context.Substitutions.TryGetValue(key, out var value) && value is not null) |
| 71 | + { |
| 72 | + found = true; |
| 73 | + replacement = value; |
| 74 | + } |
| 75 | + else if (context.ContextSubstitutions.TryGetValue(key, out value) && value is not null) |
| 76 | + { |
| 77 | + found = true; |
| 78 | + replacement = value; |
| 79 | + } |
| 80 | + |
| 81 | + if (found) |
| 82 | + { |
| 83 | + context.Build.Collector.CollectUsedSubstitutionKey(key); |
| 84 | + |
| 85 | + // Apply mutations if any |
| 86 | + if (mutationStrings.Length > 0) |
| 87 | + { |
| 88 | + if (mutationStrings.Length >= 10) |
| 89 | + { |
| 90 | + processor.EmitError(line + 1, column + match.Index, match.Length, $"Substitution key {{{key}}} defines too many mutations, none will be applied"); |
| 91 | + replacement = value; // Use original value without mutations |
| 92 | + } |
| 93 | + else |
| 94 | + { |
| 95 | + var mutations = new List<SubstitutionMutation>(); |
| 96 | + foreach (var mutationStr in mutationStrings) |
| 97 | + { |
| 98 | + var trimmedMutation = mutationStr.Trim(); |
| 99 | + if (SubstitutionMutationExtensions.TryParse(trimmedMutation, out var mutation, true, true)) |
| 100 | + { |
| 101 | + mutations.Add(mutation); |
| 102 | + } |
| 103 | + else |
| 104 | + { |
| 105 | + processor.EmitError(line + 1, column + match.Index, match.Length, $"Mutation '{trimmedMutation}' on {{{key}}} is undefined"); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + if (mutations.Count > 0) |
| 110 | + replacement = SubstitutionMutationHelper.ApplyMutations(replacement, mutations); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + substitutions.Add((match.Index, match.Length, replacement ?? string.Empty)); |
| 115 | + } |
| 116 | + else |
| 117 | + { |
| 118 | + // We temporarily diagnose variable spaces as hints. We used to not read this at all. |
| 119 | + processor.Emit(key.Contains(' ') ? Severity.Hint : Severity.Error, line + 1, column + match.Index, match.Length, $"Substitution key {{{key}}} is undefined"); |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + // Apply substitutions in reverse order to maintain correct indices |
| 124 | + foreach (var (start, length, replacement) in substitutions.OrderByDescending(s => s.Start)) |
| 125 | + { |
| 126 | + _ = result.Remove(start, length); |
| 127 | + _ = result.Insert(start, replacement); |
| 128 | + } |
| 129 | + |
| 130 | + return result.ToString(); |
| 131 | + } |
| 132 | + |
| 133 | + [GeneratedRegex(@"\{\{([^}]+)\}\}", RegexOptions.Compiled)] |
| 134 | + private static partial Regex SubstitutionRegex(); |
| 135 | +} |
0 commit comments