Forge
csharpdeeb25a2
1#nullable enable
2
3using System.Text.RegularExpressions;
4using CascadeIDE.Services;
5using CascadeIDE.Services.Intercom;
6
7namespace CascadeIDE.Features.WorkspaceNavigation.Application;
8
9/// <summary>Reverse anchors: scan ADR/KB bodies for code paths and brackets (ADR 0156 §2.3).</summary>
10public static partial class DocReverseAnchorResolver
11{
12 public const string ProvenanceBracket = "bracket";
13 public const string ProvenanceDocBody = "doc_body";
14 public const string ProvenanceWorkspaceToml = "workspace_toml";
15
16 public static IReadOnlyList<DocReverseAnchorMatch> Resolve(
17 string? workspaceRoot,
18 string? navigationAbsolutePath,
19 IReadOnlyList<string> forwardDocRepoPaths,
20 IReadOnlyList<WorkspaceExplicitCodeAnchor>? explicitAnchors = null)
21 {
22 if (string.IsNullOrWhiteSpace(workspaceRoot) || string.IsNullOrWhiteSpace(navigationAbsolutePath))
23 return [];
24
25 var root = workspaceRoot.Trim();
26 var anchorRel = WorkspaceAdrMapResolver.TryComputeRepoRelativePath(root, navigationAbsolutePath);
27 if (anchorRel is null)
28 return [];
29
30 var normalizedAnchor = NormalizePath(anchorRel);
31 var anchorFileName = Path.GetFileName(normalizedAnchor);
32 var results = new List<DocReverseAnchorMatch>();
33 var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
34 var docFileOverrides = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
35
36 if (explicitAnchors is { Count: > 0 })
37 {
38 foreach (var entry in explicitAnchors)
39 {
40 if (!PathsMatch(entry.CodeAnchor.File, normalizedAnchor, anchorFileName))
41 continue;
42
43 var key = OverrideKey(entry.DocPath, entry.CodeAnchor.File);
44 docFileOverrides.Add(key);
45 AddMatch(
46 results,
47 seen,
48 entry.DocPath,
49 WorkspaceAdrMapResolver.GuessAdrPreviewTitle(entry.DocPath),
50 entry.CodeAnchor,
51 markdown: null,
52 lineHint: entry.CodeAnchor.LineStart,
53 entry.Provenance,
54 entry.Kind);
55 }
56 }
57
58 foreach (var docRel in forwardDocRepoPaths.Distinct(StringComparer.OrdinalIgnoreCase))
59 {
60 var absDoc = WorkspaceAdrMapResolver.TryResolveAbsoluteDocPath(root, docRel);
61 if (absDoc is null || !WorkspaceTextFileReader.TryReadAllText(absDoc, out var md))
62 continue;
63
64 ScanDocument(docRel, md, normalizedAnchor, anchorFileName, results, seen, docFileOverrides);
65 }
66
67 return results
68 .OrderBy(x => x.DocPath, StringComparer.OrdinalIgnoreCase)
69 .ThenBy(x => x.DocLineHint ?? int.MaxValue)
70 .ToList();
71 }
72
73 private static void ScanDocument(
74 string docRel,
75 string markdown,
76 string anchorRel,
77 string anchorFileName,
78 List<DocReverseAnchorMatch> results,
79 HashSet<string> seen,
80 HashSet<string> docFileOverrides)
81 {
82 var title = WorkspaceAdrMapResolver.GuessAdrPreviewTitle(docRel);
83
84 foreach (var (reference, line) in BracketCodeReferenceParser.EnumerateInProse(markdown))
85 {
86 if (string.IsNullOrWhiteSpace(reference.File))
87 continue;
88
89 var anchor = new CodeAnchor(
90 NormalizePath(reference.File!),
91 reference.LineStart,
92 reference.LineEnd,
93 reference.MemberKey,
94 reference.ScopeKind is null ? null : $"{reference.ScopeKind}:{reference.ScopeIndexInParent}");
95
96 if (!PathsMatch(anchor.File, anchorRel, anchorFileName))
97 continue;
98
99 if (docFileOverrides.Contains(OverrideKey(docRel, anchor.File)))
100 continue;
101
102 AddMatch(results, seen, docRel, title, anchor, markdown, line, ProvenanceBracket);
103 }
104
105 var proseText = string.Concat(MarkdownProseSegments.EnumerateProse(markdown));
106 if (proseText.Length == 0)
107 return;
108
109 foreach (Match m in BacktickPathRegex().Matches(proseText))
110 {
111 var path = m.Groups["path"].Value;
112 if (!LooksLikeCodePath(path) || !PathsMatch(path, anchorRel, anchorFileName))
113 continue;
114
115 if (docFileOverrides.Contains(OverrideKey(docRel, NormalizePath(path))))
116 continue;
117
118 AddMatch(
119 results,
120 seen,
121 docRel,
122 title,
123 new CodeAnchor(NormalizePath(path)),
124 markdown,
125 LineNumberInMarkdown(markdown, m.Index),
126 ProvenanceDocBody);
127 }
128
129 foreach (Match m in MarkdownCodeLinkRegex().Matches(proseText))
130 {
131 var path = m.Groups["path"].Value;
132 if (!PathsMatch(path, anchorRel, anchorFileName))
133 continue;
134
135 if (docFileOverrides.Contains(OverrideKey(docRel, NormalizePath(path))))
136 continue;
137
138 AddMatch(
139 results,
140 seen,
141 docRel,
142 title,
143 new CodeAnchor(NormalizePath(path)),
144 markdown,
145 LineNumberInMarkdown(markdown, m.Index),
146 ProvenanceDocBody);
147 }
148
149 foreach (Match m in FileLineRangeRegex().Matches(proseText))
150 {
151 var path = m.Groups["path"].Value;
152 if (!PathsMatch(path, anchorRel, anchorFileName))
153 continue;
154
155 if (docFileOverrides.Contains(OverrideKey(docRel, NormalizePath(path))))
156 continue;
157
158 int? ls = int.TryParse(m.Groups["start"].Value, out var s) ? s : null;
159 int? le = m.Groups["end"].Success && int.TryParse(m.Groups["end"].Value, out var e) ? e : null;
160 AddMatch(
161 results,
162 seen,
163 docRel,
164 title,
165 new CodeAnchor(NormalizePath(path), ls, le),
166 markdown,
167 LineNumberInMarkdown(markdown, m.Index),
168 ProvenanceDocBody);
169 }
170 }
171
172 private static string OverrideKey(string docPath, string file) =>
173 $"{NormalizePath(docPath)}|{NormalizePath(file)}";
174
175 private static int LineNumberInMarkdown(string markdown, int indexInProseConcat)
176 {
177 var limit = Math.Min(indexInProseConcat, markdown.Length);
178 var line = 1;
179 for (var i = 0; i < limit; i++)
180 {
181 if (markdown[i] == '\n')
182 line++;
183 }
184
185 return line;
186 }
187
188 private static void AddMatch(
189 List<DocReverseAnchorMatch> results,
190 HashSet<string> seen,
191 string docRel,
192 string title,
193 CodeAnchor anchor,
194 string? markdown,
195 int? lineHint,
196 string provenance,
197 string? kind = null)
198 {
199 var key = $"{docRel}|{anchor.File}|{anchor.LineStart}|{anchor.MemberKey}|{provenance}";
200 if (!seen.Add(key))
201 return;
202
203 var excerpt = markdown is { Length: > 0 } && lineHint is > 0
204 ? BuildExcerpt(markdown, lineHint.Value)
205 : "";
206
207 results.Add(new DocReverseAnchorMatch(
208 docRel,
209 title,
210 anchor,
211 excerpt,
212 provenance,
213 lineHint,
214 kind ?? WorkspaceCorrespondenceCodeAnchorsLoader.DefaultKind));
215 }
216
217 private static string BuildExcerpt(string markdown, int lineOneBased)
218 {
219 var lines = markdown.Replace("\r\n", "\n").Split('\n');
220 if (lineOneBased < 1 || lineOneBased > lines.Length)
221 return "";
222
223 var raw = lines[lineOneBased - 1].Trim();
224 return raw.Length <= 96 ? raw : raw[..93] + "…";
225 }
226
227 private static bool PathsMatch(string candidatePath, string anchorRel, string anchorFileName)
228 {
229 var c = NormalizePath(candidatePath);
230 if (c.Equals(anchorRel, StringComparison.OrdinalIgnoreCase))
231 return true;
232
233 if (c.EndsWith('/' + anchorRel, StringComparison.OrdinalIgnoreCase))
234 return true;
235
236 return string.Equals(Path.GetFileName(c), anchorFileName, StringComparison.OrdinalIgnoreCase)
237 && anchorRel.EndsWith('/' + anchorFileName, StringComparison.OrdinalIgnoreCase);
238 }
239
240 private static bool LooksLikeCodePath(string path) =>
241 path.Contains('.', StringComparison.Ordinal)
242 && (path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
243 || path.EndsWith(".fs", StringComparison.OrdinalIgnoreCase)
244 || path.EndsWith(".vb", StringComparison.OrdinalIgnoreCase)
245 || path.Contains('/', StringComparison.Ordinal)
246 || path.Contains('\\', StringComparison.Ordinal));
247
248 private static string NormalizePath(string path) =>
249 path.Replace('\\', '/').Trim().TrimStart('/');
250
251 [GeneratedRegex(@"`(?<path>[\w./\\-]+\.(?:cs|fs|vb))`", RegexOptions.IgnoreCase)]
252 private static partial Regex BacktickPathRegex();
253
254 [GeneratedRegex(@"\[(?<label>[^\]]+)\]\((?<path>[^)\s#]+\.(?:cs|fs|vb)[^)]*)\)", RegexOptions.IgnoreCase)]
255 private static partial Regex MarkdownCodeLinkRegex();
256
257 [GeneratedRegex(@"(?<path>[\w./\\-]+\.(?:cs|fs|vb)):(?<start>\d+)(?:\s*[-–]\s*(?<end>\d+))?", RegexOptions.IgnoreCase)]
258 private static partial Regex FileLineRangeRegex();
259}
260
View only · write via MCP/CIDE