| 1 | #nullable enable |
| 2 | |
| 3 | namespace CascadeIDE.Services; |
| 4 | |
| 5 | /// <summary>Семантика slash path: domain · object · intent (ADR 0154).</summary> |
| 6 | public readonly record struct SlashSemanticFields( |
| 7 | string Domain, |
| 8 | string Object, |
| 9 | string Intent, |
| 10 | SlashPathRole PathRole = SlashPathRole.Canonical) |
| 11 | { |
| 12 | public bool DomainOmittedInPath => |
| 13 | PathRole == SlashPathRole.Alias && !string.IsNullOrEmpty(Domain); |
| 14 | } |
| 15 | |
| 16 | public enum SlashPathRole |
| 17 | { |
| 18 | Canonical, |
| 19 | Alias, |
| 20 | } |
| 21 | |
| 22 | /// <summary>Вывод и проверка domain/object/intent для <c>[[command.form.slash]]</c>.</summary> |
| 23 | public static class SlashRouteSemantics |
| 24 | { |
| 25 | private static readonly HashSet<string> SolutionElisionObjects = |
| 26 | new(StringComparer.OrdinalIgnoreCase) { "build", "test", "debug", "format" }; |
| 27 | |
| 28 | public static SlashSemanticFields Resolve(string slashPath, string? mapLevel = null) |
| 29 | { |
| 30 | var segs = SplitPath(slashPath); |
| 31 | if (segs.Count == 0) |
| 32 | return new("", "", "", SlashPathRole.Canonical); |
| 33 | |
| 34 | if (segs.Count == 1) |
| 35 | return new(segs[0], "", "", SlashPathRole.Canonical); |
| 36 | |
| 37 | if (segs.Count == 2 && SolutionElisionObjects.Contains(segs[0])) |
| 38 | return new("solution", segs[0], segs[1], SlashPathRole.Alias); |
| 39 | |
| 40 | if (segs[0].Equals("anchor", StringComparison.OrdinalIgnoreCase)) |
| 41 | return new("intercom", "anchor", segs[1], SlashPathRole.Alias); |
| 42 | |
| 43 | return segs[0].ToLowerInvariant() switch |
| 44 | { |
| 45 | "intercom" => resolveIntercom(segs), |
| 46 | "solution" => resolveSolution(segs), |
| 47 | "editor" => resolveEditor(segs), |
| 48 | "map" => resolveMap(segs, mapLevel), |
| 49 | "git" or "agent" or "chat" or "diagnostics" or "state" or "search" or "export" or "open" |
| 50 | or "folder" or "portal" or "cockpit" or "help" or "file" or "pfd" or "related" or "mfd" |
| 51 | or "preview" or "output" or "repository" or "instrumentation" or "terminal" or "problems" |
| 52 | or "events" or "workspace" or "settings" or "readiness" or "index" or "tests" or "ide" |
| 53 | => resolveDomainIntent(segs), |
| 54 | _ => segs.Count >= 3 |
| 55 | ? new(segs[0], segs[1], joinTail(segs, 2), SlashPathRole.Canonical) |
| 56 | : new(segs[0], "", segs[1], SlashPathRole.Canonical), |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | public static string BuildCanonicalPath(string domain, string obj, string intent) |
| 61 | { |
| 62 | var parts = new List<string>(3); |
| 63 | if (!string.IsNullOrEmpty(domain)) |
| 64 | parts.Add(domain); |
| 65 | if (!string.IsNullOrEmpty(obj)) |
| 66 | parts.Add(obj); |
| 67 | if (!string.IsNullOrEmpty(intent)) |
| 68 | parts.Add(intent); |
| 69 | |
| 70 | return parts.Count == 0 ? "" : "/" + string.Join(' ', parts); |
| 71 | } |
| 72 | |
| 73 | public static bool PathMatchesSemantic(string slashPath, SlashSemanticFields fields) |
| 74 | { |
| 75 | var path = normalizePath(slashPath); |
| 76 | var canonical = BuildCanonicalPath(fields.Domain, fields.Object, fields.Intent); |
| 77 | if (path.Equals(canonical, StringComparison.OrdinalIgnoreCase)) |
| 78 | return true; |
| 79 | |
| 80 | var segs = SplitPath(path); |
| 81 | var required = collectRequiredTokens(fields); |
| 82 | if (required.Count == 0) |
| 83 | return segs.Count <= 1; |
| 84 | |
| 85 | foreach (var token in required) |
| 86 | { |
| 87 | if (tokenPresentInPath(token, segs)) |
| 88 | continue; |
| 89 | |
| 90 | if (fields.PathRole == SlashPathRole.Alias |
| 91 | && token.Equals("project", StringComparison.OrdinalIgnoreCase) |
| 92 | && fields.Intent.Equals("new", StringComparison.OrdinalIgnoreCase) |
| 93 | && segs.Any(s => s.Equals("new", StringComparison.OrdinalIgnoreCase))) |
| 94 | continue; |
| 95 | |
| 96 | if (fields.PathRole == SlashPathRole.Alias |
| 97 | && fields.DomainOmittedInPath |
| 98 | && token.Equals(fields.Domain, StringComparison.OrdinalIgnoreCase)) |
| 99 | continue; |
| 100 | |
| 101 | if (fields.PathRole == SlashPathRole.Alias |
| 102 | && fields.Intent.Equals("set", StringComparison.OrdinalIgnoreCase) |
| 103 | && fields.Object.Equals("type", StringComparison.OrdinalIgnoreCase) |
| 104 | && token.Equals("set", StringComparison.OrdinalIgnoreCase)) |
| 105 | continue; |
| 106 | |
| 107 | return false; |
| 108 | } |
| 109 | |
| 110 | return true; |
| 111 | } |
| 112 | |
| 113 | private static bool tokenPresentInPath(string token, IReadOnlyList<string> segs) |
| 114 | { |
| 115 | if (segs.Any(s => s.Equals(token, StringComparison.OrdinalIgnoreCase))) |
| 116 | return true; |
| 117 | |
| 118 | if (!token.Contains('_', StringComparison.Ordinal)) |
| 119 | return false; |
| 120 | |
| 121 | var parts = token.Split('_', StringSplitOptions.RemoveEmptyEntries); |
| 122 | var idx = 0; |
| 123 | foreach (var part in parts) |
| 124 | { |
| 125 | var found = false; |
| 126 | for (; idx < segs.Count; idx++) |
| 127 | { |
| 128 | if (!segs[idx].Equals(part, StringComparison.OrdinalIgnoreCase)) |
| 129 | continue; |
| 130 | |
| 131 | found = true; |
| 132 | idx++; |
| 133 | break; |
| 134 | } |
| 135 | |
| 136 | if (!found) |
| 137 | return false; |
| 138 | } |
| 139 | |
| 140 | return true; |
| 141 | } |
| 142 | |
| 143 | private static List<string> collectRequiredTokens(SlashSemanticFields fields) |
| 144 | { |
| 145 | var required = new List<string>(3); |
| 146 | if (!fields.DomainOmittedInPath && !string.IsNullOrEmpty(fields.Domain)) |
| 147 | required.Add(fields.Domain); |
| 148 | if (!string.IsNullOrEmpty(fields.Object)) |
| 149 | required.Add(fields.Object); |
| 150 | if (!string.IsNullOrEmpty(fields.Intent)) |
| 151 | required.Add(fields.Intent); |
| 152 | return required; |
| 153 | } |
| 154 | |
| 155 | /// <summary>Подпись шага иерархии для popup (домен → объект → действие → аргумент).</summary> |
| 156 | public static string GetNextStepLabel( |
| 157 | IReadOnlyList<string> tokens, |
| 158 | bool endsWithSpace, |
| 159 | SlashSemanticFields fields, |
| 160 | string slashPath) |
| 161 | { |
| 162 | var idx = getSemanticStepIndex(tokens, endsWithSpace, fields, slashPath); |
| 163 | return idx switch |
| 164 | { |
| 165 | 0 => "домен", |
| 166 | 1 => "объект", |
| 167 | 2 => "действие", |
| 168 | _ => "аргумент", |
| 169 | }; |
| 170 | } |
| 171 | |
| 172 | public static string BuildSemanticBreadcrumb( |
| 173 | IReadOnlyList<string> tokens, |
| 174 | SlashSemanticFields fields, |
| 175 | string slashPath) |
| 176 | { |
| 177 | var parts = new List<string> { "/" }; |
| 178 | if (fields.DomainOmittedInPath && !string.IsNullOrEmpty(fields.Domain)) |
| 179 | parts.Add($"({fields.Domain})"); |
| 180 | |
| 181 | if (!string.IsNullOrEmpty(fields.Domain) && !fields.DomainOmittedInPath) |
| 182 | parts.Add(fields.Domain); |
| 183 | |
| 184 | foreach (var t in tokens) |
| 185 | parts.Add(t); |
| 186 | |
| 187 | if (tokens.Count == 0 && string.IsNullOrEmpty(fields.Domain)) |
| 188 | return $"/ → {GetNextStepLabel(tokens, endsWithSpace: false, fields, slashPath)}"; |
| 189 | |
| 190 | parts.Add("…"); |
| 191 | return string.Join(" › ", parts); |
| 192 | } |
| 193 | |
| 194 | private static int getSemanticStepIndex( |
| 195 | IReadOnlyList<string> tokens, |
| 196 | bool endsWithSpace, |
| 197 | SlashSemanticFields fields, |
| 198 | string slashPath) |
| 199 | { |
| 200 | var idx = endsWithSpace ? tokens.Count : Math.Max(0, tokens.Count - 1); |
| 201 | if (fields.DomainOmittedInPath || isDomainOmittedAlias(fields, slashPath)) |
| 202 | idx++; |
| 203 | |
| 204 | return idx; |
| 205 | } |
| 206 | |
| 207 | private static bool isDomainOmittedAlias(SlashSemanticFields fields, string slashPath) |
| 208 | { |
| 209 | if (fields.PathRole != SlashPathRole.Alias) |
| 210 | return false; |
| 211 | |
| 212 | var canonical = BuildCanonicalPath(fields.Domain, fields.Object, fields.Intent); |
| 213 | return !slashPath.Equals(canonical, StringComparison.OrdinalIgnoreCase); |
| 214 | } |
| 215 | |
| 216 | private static SlashSemanticFields resolveIntercom(IReadOnlyList<string> segs) |
| 217 | { |
| 218 | if (segs.Count == 2) |
| 219 | { |
| 220 | var second = segs[1]; |
| 221 | if (second.Equals("overview", StringComparison.OrdinalIgnoreCase) |
| 222 | || second.Equals("show", StringComparison.OrdinalIgnoreCase)) |
| 223 | return new("intercom", "", second, SlashPathRole.Canonical); |
| 224 | |
| 225 | return new("intercom", "", second, SlashPathRole.Canonical); |
| 226 | } |
| 227 | |
| 228 | if (segs.Count == 3) |
| 229 | return new("intercom", segs[1], segs[2], SlashPathRole.Canonical); |
| 230 | |
| 231 | if (segs.Count >= 4) |
| 232 | return new("intercom", segs[1], joinTail(segs, 2), SlashPathRole.Canonical); |
| 233 | |
| 234 | return new("intercom", segs[1], joinTail(segs, 2), SlashPathRole.Canonical); |
| 235 | } |
| 236 | |
| 237 | private static SlashSemanticFields resolveSolution(IReadOnlyList<string> segs) |
| 238 | { |
| 239 | if (segs.Count == 2) |
| 240 | return new("solution", "", segs[1], SlashPathRole.Canonical); |
| 241 | |
| 242 | if (segs.Count >= 3 && segs[1].Equals("new", StringComparison.OrdinalIgnoreCase)) |
| 243 | return new("solution", "project", "new", SlashPathRole.Alias); |
| 244 | |
| 245 | if (segs.Count >= 3 && segs[1].Equals("explorer", StringComparison.OrdinalIgnoreCase)) |
| 246 | return new("solution", "explorer", segs[2], SlashPathRole.Canonical); |
| 247 | |
| 248 | return new("solution", segs[1], joinTail(segs, 2), SlashPathRole.Canonical); |
| 249 | } |
| 250 | |
| 251 | private static SlashSemanticFields resolveEditor(IReadOnlyList<string> segs) |
| 252 | { |
| 253 | if (segs.Count == 2) |
| 254 | return new("editor", "", segs[1], SlashPathRole.Canonical); |
| 255 | |
| 256 | if (segs.Count == 3 && segs[1].Equals("line", StringComparison.OrdinalIgnoreCase)) |
| 257 | return new("editor", "line", segs[2], SlashPathRole.Canonical); |
| 258 | |
| 259 | if (segs.Count == 3 && (segs[1].Equals("select", StringComparison.OrdinalIgnoreCase) |
| 260 | || segs[1].Equals("reveal", StringComparison.OrdinalIgnoreCase))) |
| 261 | return new("editor", "code", segs[1], SlashPathRole.Canonical); |
| 262 | |
| 263 | if (segs.Count == 3 && segs[1].Equals("layout", StringComparison.OrdinalIgnoreCase)) |
| 264 | return new("editor", "layout", segs[2], SlashPathRole.Canonical); |
| 265 | |
| 266 | return new("editor", segs[1], joinTail(segs, 2), SlashPathRole.Canonical); |
| 267 | } |
| 268 | |
| 269 | private static SlashSemanticFields resolveMap(IReadOnlyList<string> segs, string? mapLevel) |
| 270 | { |
| 271 | if (segs.Count >= 3 && segs[1].Equals("type", StringComparison.OrdinalIgnoreCase)) |
| 272 | return new("map", "type", "set", SlashPathRole.Alias); |
| 273 | |
| 274 | if (segs.Count == 3 && segs[1].Equals("cycle", StringComparison.OrdinalIgnoreCase)) |
| 275 | return new("map", "cycle", segs[2], SlashPathRole.Canonical); |
| 276 | |
| 277 | return new("map", segs[1], joinTail(segs, 2), SlashPathRole.Canonical); |
| 278 | } |
| 279 | |
| 280 | private static SlashSemanticFields resolveDomainIntent(IReadOnlyList<string> segs) => |
| 281 | segs.Count == 2 |
| 282 | ? new(segs[0], "", segs[1], SlashPathRole.Canonical) |
| 283 | : new(segs[0], segs[1], joinTail(segs, 2), SlashPathRole.Canonical); |
| 284 | |
| 285 | private static string joinTail(IReadOnlyList<string> segs, int start) |
| 286 | { |
| 287 | if (start >= segs.Count) |
| 288 | return ""; |
| 289 | |
| 290 | return start == segs.Count - 1 |
| 291 | ? segs[start] |
| 292 | : string.Join('_', segs.Skip(start)); |
| 293 | } |
| 294 | |
| 295 | private static List<string> SplitPath(string slashPath) |
| 296 | { |
| 297 | var path = normalizePath(slashPath); |
| 298 | if (path.Length < 2) |
| 299 | return []; |
| 300 | |
| 301 | return path[1..].Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); |
| 302 | } |
| 303 | |
| 304 | private static string normalizePath(string slashPath) |
| 305 | { |
| 306 | if (string.IsNullOrWhiteSpace(slashPath)) |
| 307 | return ""; |
| 308 | |
| 309 | var t = slashPath.Trim(); |
| 310 | return t[0] == '/' ? t : "/" + t; |
| 311 | } |
| 312 | |
| 313 | } |
| 314 | |