Forge
csharpdeeb25a2
1using System.Text.RegularExpressions;
2using CascadeIDE.Features.Workspace;
3using CascadeIDE.Models;
4
5namespace CascadeIDE.Services;
6
7internal static partial class WorkspaceAdrMapResolver
8{
9 public const string AutoIncludeNone = "none";
10 public const string AutoIncludeLinked = "linked";
11
12 public const string DefaultAdrRootDir = "docs/adr/";
13 public const string DefaultAdrIdRegex = @"(?:^|/)docs/adr/(?<id>\d{4})-";
14
15 public static IReadOnlyList<string> ResolveAdrDocPathsFromWorkspaceToml(
16 RepositoryWorkspaceToml? workspaceToml,
17 string repositoryRootDirectory,
18 string absoluteFilePath)
19 {
20 if (workspaceToml?.Workspace?.Adr?.Map is not { Count: > 0 } map)
21 return [];
22
23 var rel = TryComputeRepoRelativePath(repositoryRootDirectory, absoluteFilePath);
24 if (rel is null)
25 return [];
26
27 var normalizedRel = NormalizeTomlPath(rel);
28
29 string? bestKey = null;
30 var bestLen = -1;
31 foreach (var rawKey in map.Keys)
32 {
33 var k = NormalizeTomlPath(rawKey);
34 if (k == "*")
35 {
36 if (bestKey is null)
37 {
38 bestKey = rawKey;
39 bestLen = 0;
40 }
41 continue;
42 }
43
44 if (!normalizedRel.StartsWith(k, StringComparison.OrdinalIgnoreCase))
45 continue;
46
47 if (k.Length > bestLen)
48 {
49 bestKey = rawKey;
50 bestLen = k.Length;
51 }
52 }
53
54 if (bestKey is null || !map.TryGetValue(bestKey, out var v))
55 return [];
56
57 return ExtractStringList(v)
58 .Select(x => NormalizeDocPath(x))
59 .Where(x => x.Length > 0)
60 .Distinct(StringComparer.OrdinalIgnoreCase)
61 .ToList();
62 }
63
64 public static string BuildAdrIndicatorLine(
65 IReadOnlyList<string> adrDocPaths,
66 RepositoryAdrToml? adrSettings = null)
67 {
68 if (adrDocPaths.Count == 0)
69 return "";
70
71 var idRegex = ResolveAdrIdRegex(adrSettings);
72
73 var ids = new List<string>(adrDocPaths.Count);
74 foreach (var p in adrDocPaths)
75 {
76 var id = TryExtractAdrId(p, idRegex);
77 ids.Add(id ?? p);
78 }
79
80 if (ids.Count == 1)
81 return $"ADR: {ids[0]}";
82
83 // Keep it compact in PFD/MFD: show first and count.
84 return $"ADR: {ids[0]} (+{ids.Count - 1})";
85 }
86
87 public static string NormalizeAutoInclude(string? raw)
88 {
89 var v = (raw ?? "").Trim().ToLowerInvariant();
90 return v switch
91 {
92 AutoIncludeLinked => AutoIncludeLinked,
93 _ => AutoIncludeNone
94 };
95 }
96
97 public static string? TryResolveAbsoluteDocPath(string repositoryRootDirectory, string repoRelativeDocPath)
98 {
99 var rel = (repoRelativeDocPath ?? "").Trim();
100 if (rel.Length == 0)
101 return null;
102
103 // Treat "docs/..." as repo-relative (not absolute on Windows).
104 rel = rel.Replace('\\', '/');
105 if (Path.IsPathRooted(rel))
106 return rel;
107
108 try
109 {
110 var abs = Path.Combine(repositoryRootDirectory.Trim(), rel.Replace('/', Path.DirectorySeparatorChar));
111 return CanonicalFilePath.Normalize(abs);
112 }
113 catch
114 {
115 return null;
116 }
117 }
118
119 public static string GuessAdrPreviewTitle(string repoRelativeDocPath)
120 {
121 var id = TryExtractAdrId(repoRelativeDocPath, ResolveAdrIdRegex(null));
122 return id is null ? repoRelativeDocPath : $"{id}";
123 }
124
125 public static IReadOnlyList<string> ExtractLinkedAdrDocPathsFromMarkdown(
126 string markdown,
127 string currentDocRepoRelativePath,
128 RepositoryAdrToml? adrSettings = null)
129 {
130 if (string.IsNullOrWhiteSpace(markdown))
131 return [];
132
133 var adrRoot = NormalizeAdrRootDir(adrSettings?.RootDir);
134 var current = NormalizeDocPath(currentDocRepoRelativePath);
135 var list = new List<string>();
136
137 foreach (Match m in MarkdownLinkRegex().Matches(markdown))
138 {
139 var raw = m.Groups["target"].Value;
140 if (string.IsNullOrWhiteSpace(raw))
141 continue;
142 raw = raw.Trim();
143 if (raw.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
144 || raw.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
145 continue;
146
147 var resolved = ResolveMarkdownLinkTargetToRepoRelative(raw, current);
148 if (resolved is null)
149 continue;
150
151 if (!resolved.StartsWith(adrRoot, StringComparison.OrdinalIgnoreCase))
152 continue;
153
154 if (string.Equals(resolved, current, StringComparison.OrdinalIgnoreCase))
155 continue;
156
157 list.Add(resolved);
158 }
159
160 return list
161 .Distinct(StringComparer.OrdinalIgnoreCase)
162 .ToList();
163 }
164
165 private static string? ResolveMarkdownLinkTargetToRepoRelative(string target, string currentDocRepoRelativePath)
166 {
167 var t = (target ?? "").Trim().Replace('\\', '/');
168 if (t.Length == 0)
169 return null;
170
171 // Strip anchors/fragments.
172 var hash = t.IndexOf('#');
173 if (hash >= 0)
174 t = t[..hash];
175
176 if (t.Length == 0)
177 return null;
178
179 // Absolute-ish repo path (under configured ADR root).
180 var adrRoot = DefaultAdrRootDir;
181 if (t.StartsWith(adrRoot, StringComparison.OrdinalIgnoreCase))
182 return NormalizeDocPath(t);
183
184 // Relative path: resolve against current doc directory.
185 // Markdown commonly uses either "./x.md", "../x.md" or just "x.md" (same directory).
186 if (t.StartsWith("./", StringComparison.Ordinal)
187 || t.StartsWith("../", StringComparison.Ordinal)
188 || (!t.Contains(':') && !t.StartsWith("/", StringComparison.Ordinal)))
189 {
190 var cur = NormalizeDocPath(currentDocRepoRelativePath);
191 var lastSlash = cur.LastIndexOf('/');
192 var baseDir = lastSlash >= 0 ? cur[..(lastSlash + 1)] : "";
193 var combined = baseDir + t;
194
195 // Normalize /./ and /../ segments.
196 var parts = new List<string>();
197 foreach (var p in combined.Split('/', StringSplitOptions.RemoveEmptyEntries))
198 {
199 if (p == ".")
200 continue;
201 if (p == "..")
202 {
203 if (parts.Count > 0)
204 parts.RemoveAt(parts.Count - 1);
205 continue;
206 }
207 parts.Add(p);
208 }
209
210 return parts.Count == 0 ? null : string.Join('/', parts);
211 }
212
213 return null;
214 }
215
216 private static string NormalizeDocPath(string raw)
217 {
218 var s = (raw ?? "").Trim();
219 if (s.Length == 0)
220 return "";
221 return s.Replace('\\', '/');
222 }
223
224 private static string NormalizeAdrRootDir(string? raw)
225 {
226 var s = (raw ?? "").Trim();
227 if (s.Length == 0)
228 return DefaultAdrRootDir;
229 s = s.Replace('\\', '/');
230 if (!s.EndsWith("/", StringComparison.Ordinal))
231 s += "/";
232 return s;
233 }
234
235 private static Regex ResolveAdrIdRegex(RepositoryAdrToml? adrSettings)
236 {
237 var raw = (adrSettings?.IdRegex ?? "").Trim();
238 if (raw.Length == 0)
239 raw = DefaultAdrIdRegex;
240
241 try
242 {
243 return new Regex(raw, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
244 }
245 catch
246 {
247 return new Regex(DefaultAdrIdRegex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
248 }
249 }
250
251 private static string NormalizeTomlPath(string rawKey)
252 {
253 var s = (rawKey ?? "").Trim().Replace('\\', '/');
254 return s;
255 }
256
257 internal static string? TryComputeRepoRelativePath(string repositoryRootDirectory, string absoluteFilePath)
258 {
259 try
260 {
261 var root = CanonicalFilePath.Normalize(repositoryRootDirectory.Trim());
262 var abs = CanonicalFilePath.Normalize(absoluteFilePath.Trim());
263 if (!abs.StartsWith(root, StringComparison.OrdinalIgnoreCase))
264 return null;
265
266 var rel = abs[root.Length..].TrimStart('\\', '/');
267 return rel;
268 }
269 catch
270 {
271 return null;
272 }
273 }
274
275 private static IReadOnlyList<string> ExtractStringList(object? v)
276 {
277 if (v is null)
278 return [];
279
280 if (v is string s)
281 return [s];
282
283 if (v is IEnumerable<object> objs)
284 {
285 var list = new List<string>();
286 foreach (var o in objs)
287 {
288 if (o is string os && os.Trim().Length > 0)
289 list.Add(os);
290 }
291 return list;
292 }
293
294 return [];
295 }
296
297 private static string? TryExtractAdrId(string docPath, Regex idRegex)
298 {
299 var p = (docPath ?? "").Replace('\\', '/');
300 var m = idRegex.Match(p);
301 if (!m.Success)
302 return null;
303 var id = m.Groups["id"].Value;
304 return string.IsNullOrWhiteSpace(id) ? null : $"ADR {id}";
305 }
306
307 // Markdown links: [text](target)
308 [GeneratedRegex(@"\[[^\]]*\]\((?<target>[^)]+)\)", RegexOptions.CultureInvariant)]
309 private static partial Regex MarkdownLinkRegex();
310}
311
312
View only · write via MCP/CIDE