Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Services;
3
4namespace CascadeIDE.Features.Chat;
5
6/// <summary>
7/// Канонический путь + хвост args из текста slash-строки (ADR 0150).
8/// Единая точка для autocomplete, Enter и runner.
9/// </summary>
10public static class SlashLineResolver
11{
12 public readonly record struct SlashLineResolution(
13 string CanonicalPath,
14 string ArgTail,
15 SlashArgTailKind ArgTailKind,
16 bool IsCatalogMatch,
17 bool IsExactPathMatch,
18 bool EndsWithSpaceAfterPath,
19 bool HasArgTailContent)
20 {
21 public bool ShouldHideSegmentSuggestions =>
22 IsCatalogMatch && (
23 (ArgTailKind == SlashArgTailKind.None && IsExactPathMatch)
24 || ArgTailKind == SlashArgTailKind.Optional && (
25 IsExactPathMatch || EndsWithSpaceAfterPath || HasArgTailContent)
26 || (ArgTailKind == SlashArgTailKind.Required && HasArgTailContent));
27
28 public bool InsertsTrailingSpaceOnCommit => ArgTailKind != SlashArgTailKind.None;
29
30 public bool IsRunnable =>
31 IsCatalogMatch
32 && (ArgTailKind != SlashArgTailKind.Required || !string.IsNullOrWhiteSpace(ArgTail))
33 && ChatSlashCommandCatalog.TryResolveCanonical(CanonicalPath, ArgTail, out _);
34 }
35
36 public static bool TryResolveLine(string? rawInput, int caretIndex, out SlashLineResolution resolution)
37 {
38 resolution = default;
39 if (!ChatSlashAutocomplete.TryGetSlashLineAtCaret(rawInput, caretIndex, out var slashLine))
40 return false;
41
42 return TryResolveSlashLine(slashLine, out resolution);
43 }
44
45 public static bool TryResolveSlashLine(string slashLine, out SlashLineResolution resolution)
46 {
47 resolution = default;
48 if (string.IsNullOrWhiteSpace(slashLine) || slashLine[0] != '/')
49 return false;
50
51 var body = slashLine[1..].TrimEnd();
52 return TryResolveBody(body, out resolution);
53 }
54
55 internal static bool TryResolveBody(string body, out SlashLineResolution resolution)
56 {
57 resolution = default;
58 ChatSlashAutocomplete.ParseTypedBodyForResolver(body, out var tokens, out var endsWithSpace);
59 if (tokens.Count == 0)
60 return false;
61
62 if (TryResolveCatalogLongestPrefix(tokens, endsWithSpace, out resolution))
63 return true;
64
65 if (TryResolveIntercomMessageInlineRangePath(tokens, endsWithSpace, out resolution))
66 return true;
67
68 return false;
69 }
70
71 private static bool TryResolveCatalogLongestPrefix(
72 IReadOnlyList<string> tokens,
73 bool endsWithSpace,
74 out SlashLineResolution resolution)
75 {
76 resolution = default;
77 if (!SlashRouteCatalogPathsGenerated.TryResolveLongestPrefix(
78 tokens,
79 endsWithSpace,
80 out var path,
81 out var argTail,
82 out var isExactPath,
83 out var endsWithSpaceAfterPath)
84 && !CascadeIDE.Features.Forge.Infrastructure.ForgeSlashCatalogOverlay.TryResolveLongestPrefix(
85 tokens,
86 endsWithSpace,
87 out path,
88 out argTail,
89 out isExactPath,
90 out endsWithSpaceAfterPath))
91 {
92 return false;
93 }
94
95 var hasArgTail = argTail.Length > 0;
96 resolution = new SlashLineResolution(
97 path,
98 argTail,
99 SlashRouteCatalogIndex.GetArgTailKind(path),
100 IsCatalogMatch: true,
101 IsExactPathMatch: isExactPath,
102 EndsWithSpaceAfterPath: endsWithSpaceAfterPath,
103 HasArgTailContent: hasArgTail);
104 return true;
105 }
106
107 /// <summary>
108 /// <c>/intercom message 3:5 relate selection</c> — диапазон gutter между <c>message</c> и action-токеном.
109 /// </summary>
110 private static bool TryResolveIntercomMessageInlineRangePath(
111 IReadOnlyList<string> tokens,
112 bool endsWithSpace,
113 out SlashLineResolution resolution)
114 {
115 resolution = default;
116 if (tokens.Count < 4)
117 return false;
118
119 if (!string.Equals(tokens[0], "intercom", StringComparison.OrdinalIgnoreCase)
120 || !string.Equals(tokens[1], "message", StringComparison.OrdinalIgnoreCase))
121 {
122 return false;
123 }
124
125 ReadOnlySpan<string> inlineActions = ["relate", "select", "find"];
126 for (var actionIdx = 2; actionIdx < tokens.Count; actionIdx++)
127 {
128 var token = tokens[actionIdx];
129 var matchedAction = false;
130 foreach (var action in inlineActions)
131 {
132 if (string.Equals(token, action, StringComparison.OrdinalIgnoreCase))
133 {
134 matchedAction = true;
135 break;
136 }
137 }
138
139 if (!matchedAction || actionIdx <= 2)
140 continue;
141
142 var path = $"/intercom message {token.ToLowerInvariant()}";
143 if (!SlashRouteCatalogPathsGenerated.ContainsPath(path))
144 continue;
145
146 var inlineArg = string.Join(' ', tokens.Skip(2).Take(actionIdx - 2));
147 var tailAfterAction = string.Join(' ', tokens.Skip(actionIdx + 1));
148 var argTail = string.IsNullOrEmpty(tailAfterAction)
149 ? inlineArg
150 : string.IsNullOrEmpty(inlineArg)
151 ? tailAfterAction
152 : $"{inlineArg} {tailAfterAction}";
153
154 var hasArgTail = argTail.Length > 0;
155 resolution = new SlashLineResolution(
156 path,
157 argTail,
158 SlashRouteCatalogIndex.GetArgTailKind(path),
159 IsCatalogMatch: true,
160 IsExactPathMatch: false,
161 EndsWithSpaceAfterPath: endsWithSpace && !hasArgTail && actionIdx + 1 == tokens.Count,
162 HasArgTailContent: hasArgTail);
163 return true;
164 }
165
166 return false;
167 }
168}
169
View only · write via MCP/CIDE