Forge
csharp4405de34
1#nullable enable
2
3using System.Text;
4using CascadeIDE.Features.Forge.Infrastructure;
5using CascadeIDE.Services.Intercom;
6
7namespace CascadeIDE.Services.MarkdownPreview;
8
9/// <summary>
10/// Превращает bracket code anchors в prose в markdown-ссылки для preview (ADR 0156 §2.4).
11/// Схема <c>cascade-code-anchor:</c> — не http; рендерер рисует как code-link.
12/// </summary>
13public static class MarkdownCodeAnchorPreviewExpander
14{
15 public const string UriScheme = "cascade-code-anchor:";
16 public const string ForgeUriScheme = "cascade-forge-artifact:";
17
18 public static string Expand(string markdown)
19 {
20 if (string.IsNullOrEmpty(markdown))
21 return markdown;
22
23 var sb = new StringBuilder(markdown.Length + 64);
24 var i = 0;
25 var inFence = false;
26 while (i < markdown.Length)
27 {
28 if (IsFenceOpenAt(markdown, i))
29 {
30 var close = FindFenceClose(markdown, i + 3);
31 if (close < 0)
32 {
33 sb.Append(markdown.AsSpan(i));
34 break;
35 }
36
37 sb.Append(markdown.AsSpan(i, close + 3 - i));
38 i = close + 3;
39 inFence = !inFence;
40 continue;
41 }
42
43 if (inFence)
44 {
45 sb.Append(markdown[i]);
46 i++;
47 continue;
48 }
49
50 if (markdown[i] == '['
51 && BracketCodeReferenceParser.TryReadBracketSpan(markdown, i, out var closeBracket))
52 {
53 var inner = markdown.Substring(i + 1, closeBracket - i - 1);
54 if (inner.TrimStart().StartsWith("FRG:", StringComparison.Ordinal)
55 && BracketForgeReferenceParser.TryParse("[" + inner + "]", out var forgeRef, out _)
56 && !BracketCodeReferenceParser.IsMarkdownLinkAfter(markdown, closeBracket))
57 {
58 var label = forgeRef.Kind switch
59 {
60 Models.Forge.ForgeArtifactKind.Issue => $"issue #{forgeRef.Number} · {forgeRef.Repo}",
61 Models.Forge.ForgeArtifactKind.MergeRequest => $"mr #{forgeRef.Number} · {forgeRef.Repo}",
62 _ => forgeRef.Repo,
63 };
64 var url = ForgeUriScheme + Uri.EscapeDataString("[" + inner.Trim() + "]");
65 sb.Append('[').Append(EscapeMarkdownLinkLabel(label)).Append("](").Append(url).Append(')');
66 i = closeBracket + 1;
67 continue;
68 }
69
70 if (BracketCodeReferenceParser.TryParse(inner, out var reference, out _)
71 && !string.IsNullOrWhiteSpace(reference.File)
72 && !BracketCodeReferenceParser.IsMarkdownLinkAfter(markdown, closeBracket))
73 {
74 var label = BuildLinkLabel(reference);
75 var url = UriScheme + Uri.EscapeDataString(inner.Trim());
76 sb.Append('[').Append(EscapeMarkdownLinkLabel(label)).Append("](").Append(url).Append(')');
77 i = closeBracket + 1;
78 continue;
79 }
80 }
81
82 sb.Append(markdown[i]);
83 i++;
84 }
85
86 return sb.ToString();
87 }
88
89 private static string BuildLinkLabel(in BracketCodeReference reference)
90 {
91 if (!string.IsNullOrWhiteSpace(reference.MemberKey))
92 {
93 var file = reference.File;
94 var tail = string.IsNullOrWhiteSpace(file) ? "" : $" › {Path.GetFileName(file.Replace('\\', '/'))}";
95 return reference.MemberKey.Trim() + tail;
96 }
97
98 if (!string.IsNullOrWhiteSpace(reference.File))
99 return Path.GetFileName(reference.File.Replace('\\', '/'));
100
101 return "code";
102 }
103
104 private static string EscapeMarkdownLinkLabel(string label) =>
105 label.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("]", "\\]", StringComparison.Ordinal);
106
107 private static bool IsFenceOpenAt(string text, int index)
108 {
109 if (index + 2 >= text.Length)
110 return false;
111 if (text[index] != '`' || text[index + 1] != '`' || text[index + 2] != '`')
112 return false;
113
114 return index <= 0 || text[index - 1] != '`';
115 }
116
117 private static int FindFenceClose(string text, int afterOpen)
118 {
119 for (var i = afterOpen; i + 2 < text.Length; i++)
120 {
121 if (text[i] != '`' || text[i + 1] != '`' || text[i + 2] != '`')
122 continue;
123
124 if (i > afterOpen && text[i - 1] == '`')
125 continue;
126
127 return i;
128 }
129
130 return -1;
131 }
132}
133
View only · write via MCP/CIDE