@mrclrchtr/supi-lsp 0.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +26 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +16 -11
- package/{capabilities.ts → src/capabilities.ts} +8 -0
- package/src/client/client-refresh.ts +229 -0
- package/{client.ts → src/client/client.ts} +178 -30
- package/{transport.ts → src/client/transport.ts} +10 -6
- package/src/config.ts +143 -0
- package/src/defaults.json +82 -0
- package/src/diagnostics/diagnostic-augmentation.ts +82 -0
- package/src/diagnostics/diagnostic-display.ts +68 -0
- package/{diagnostic-summary.ts → src/diagnostics/diagnostic-summary.ts} +11 -7
- package/{diagnostics.ts → src/diagnostics/diagnostics.ts} +9 -4
- package/src/diagnostics/stale-diagnostics.ts +47 -0
- package/src/diagnostics/suppression-diagnostics.ts +58 -0
- package/src/format.ts +359 -0
- package/src/guidance.ts +163 -0
- package/src/index.ts +17 -0
- package/src/lsp-state.ts +82 -0
- package/src/lsp.ts +481 -0
- package/src/manager/manager-client-state.ts +34 -0
- package/src/manager/manager-diagnostics.ts +139 -0
- package/src/manager/manager-helpers.ts +39 -0
- package/src/manager/manager-project-info.ts +46 -0
- package/src/manager/manager-stale-resync.ts +47 -0
- package/src/manager/manager-types.ts +39 -0
- package/src/manager/manager-workspace-recovery.ts +83 -0
- package/src/manager/manager-workspace-symbol.ts +18 -0
- package/src/manager/manager.ts +550 -0
- package/src/overrides.ts +173 -0
- package/src/pattern-matcher.ts +197 -0
- package/src/renderer.ts +120 -0
- package/src/scanner.ts +153 -0
- package/src/search-fallback.ts +98 -0
- package/src/service-registry.ts +153 -0
- package/src/settings-registration.ts +292 -0
- package/{summary.ts → src/summary.ts} +44 -9
- package/src/tool-actions.ts +430 -0
- package/src/tree-persist.ts +48 -0
- package/src/tsconfig-scope.ts +156 -0
- package/{types.ts → src/types.ts} +123 -0
- package/src/ui.ts +358 -0
- package/{utils.ts → src/utils.ts} +8 -25
- package/src/workspace-sentinels.ts +114 -0
- package/bash-guard.ts +0 -58
- package/config.ts +0 -99
- package/defaults.json +0 -40
- package/format.ts +0 -190
- package/guidance.ts +0 -140
- package/lsp.ts +0 -375
- package/manager.ts +0 -396
- package/overrides.ts +0 -95
- package/recent-paths.ts +0 -126
- package/runtime-state.ts +0 -113
- package/tool-actions.ts +0 -211
- package/tsconfig.json +0 -5
- package/ui.ts +0 -303
package/src/overrides.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { augmentDiagnostics } from "./diagnostics/diagnostic-augmentation.ts";
|
|
4
|
+
import { formatGroupedDiagnostics } from "./diagnostics/diagnostics.ts";
|
|
5
|
+
import { splitSuppressionDiagnostics } from "./diagnostics/suppression-diagnostics.ts";
|
|
6
|
+
import type { LspManager } from "./manager/manager.ts";
|
|
7
|
+
import type { Diagnostic } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
interface LspOverrideState {
|
|
10
|
+
getInlineSeverity(): number;
|
|
11
|
+
getManager(): LspManager | null;
|
|
12
|
+
getCwd(): string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registerLspAwareToolOverrides(pi: ExtensionAPI, state: LspOverrideState): void {
|
|
16
|
+
const readMeta = createReadTool(process.cwd());
|
|
17
|
+
const writeMeta = createWriteTool(process.cwd());
|
|
18
|
+
const editMeta = createEditTool(process.cwd());
|
|
19
|
+
|
|
20
|
+
pi.registerTool({
|
|
21
|
+
...readMeta,
|
|
22
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
23
|
+
async execute(toolCallId, params, signal, onUpdate, _ctx) {
|
|
24
|
+
const cwd = state.getCwd();
|
|
25
|
+
const originalRead = createReadTool(cwd);
|
|
26
|
+
const result = await originalRead.execute(toolCallId, params, signal, onUpdate);
|
|
27
|
+
await ensureFileOpen(state.getManager(), params.path);
|
|
28
|
+
return result;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.registerTool({
|
|
33
|
+
...writeMeta,
|
|
34
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
35
|
+
async execute(toolCallId, params, signal, onUpdate, _ctx) {
|
|
36
|
+
const cwd = state.getCwd();
|
|
37
|
+
const originalWrite = createWriteTool(cwd);
|
|
38
|
+
const result = await originalWrite.execute(toolCallId, params, signal, onUpdate);
|
|
39
|
+
return appendInlineDiagnostics({
|
|
40
|
+
manager: state.getManager(),
|
|
41
|
+
filePath: params.path,
|
|
42
|
+
inlineSeverity: state.getInlineSeverity(),
|
|
43
|
+
cwd,
|
|
44
|
+
result,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
pi.registerTool({
|
|
50
|
+
...editMeta,
|
|
51
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
52
|
+
async execute(toolCallId, params, signal, onUpdate, _ctx) {
|
|
53
|
+
const cwd = state.getCwd();
|
|
54
|
+
const originalEdit = createEditTool(cwd);
|
|
55
|
+
const result = await originalEdit.execute(toolCallId, params, signal, onUpdate);
|
|
56
|
+
return appendInlineDiagnostics({
|
|
57
|
+
manager: state.getManager(),
|
|
58
|
+
filePath: params.path,
|
|
59
|
+
inlineSeverity: state.getInlineSeverity(),
|
|
60
|
+
cwd,
|
|
61
|
+
result,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface AppendInlineDiagnosticsOptions<T extends { content: unknown[]; details: unknown }> {
|
|
68
|
+
manager: LspManager | null;
|
|
69
|
+
filePath: string;
|
|
70
|
+
inlineSeverity: number;
|
|
71
|
+
cwd: string;
|
|
72
|
+
result: T;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function appendInlineDiagnostics<T extends { content: unknown[]; details: unknown }>(
|
|
76
|
+
options: AppendInlineDiagnosticsOptions<T>,
|
|
77
|
+
): Promise<T> {
|
|
78
|
+
if (!options.manager) return options.result;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const effectiveSeverity = Math.max(options.inlineSeverity, 2);
|
|
82
|
+
const entries = await options.manager.syncFileAndGetCascadingDiagnostics(
|
|
83
|
+
options.filePath,
|
|
84
|
+
effectiveSeverity,
|
|
85
|
+
);
|
|
86
|
+
if (entries.length === 0) return options.result;
|
|
87
|
+
|
|
88
|
+
const primaryDiagnostics =
|
|
89
|
+
entries.find((entry) => entry.file === options.filePath)?.diagnostics ?? [];
|
|
90
|
+
const augmentation = await augmentDiagnostics(
|
|
91
|
+
options.filePath,
|
|
92
|
+
splitSuppressionDiagnostics(primaryDiagnostics, options.inlineSeverity).regular,
|
|
93
|
+
options.manager,
|
|
94
|
+
options.cwd,
|
|
95
|
+
);
|
|
96
|
+
const diagText = buildInlineDiagnosticsMessage(
|
|
97
|
+
entries,
|
|
98
|
+
options.cwd,
|
|
99
|
+
options.inlineSeverity,
|
|
100
|
+
augmentation ?? undefined,
|
|
101
|
+
);
|
|
102
|
+
if (!diagText) return options.result;
|
|
103
|
+
|
|
104
|
+
const diagnosticContent = {
|
|
105
|
+
type: "text" as const,
|
|
106
|
+
text: `\n\n${diagText}`,
|
|
107
|
+
} as T["content"][number];
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...options.result,
|
|
111
|
+
content: [...options.result.content, diagnosticContent],
|
|
112
|
+
} as T;
|
|
113
|
+
} catch {
|
|
114
|
+
return options.result;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildInlineDiagnosticsMessage(
|
|
119
|
+
entries: Array<{ file: string; diagnostics: Diagnostic[] }>,
|
|
120
|
+
cwd: string,
|
|
121
|
+
inlineSeverity: number = 1,
|
|
122
|
+
augmentation?: string,
|
|
123
|
+
): string | null {
|
|
124
|
+
const regularEntries: Array<{ file: string; diagnostics: Diagnostic[] }> = [];
|
|
125
|
+
const suppressionEntries: Array<{ file: string; diagnostics: Diagnostic[] }> = [];
|
|
126
|
+
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const { regular, suppressions } = splitSuppressionDiagnostics(
|
|
129
|
+
entry.diagnostics,
|
|
130
|
+
inlineSeverity,
|
|
131
|
+
);
|
|
132
|
+
if (regular.length > 0) {
|
|
133
|
+
regularEntries.push({ file: entry.file, diagnostics: regular });
|
|
134
|
+
}
|
|
135
|
+
if (suppressions.length > 0) {
|
|
136
|
+
suppressionEntries.push({ file: entry.file, diagnostics: suppressions });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (regularEntries.length === 0 && suppressionEntries.length === 0) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const sections = ["⚠️ LSP Diagnostics — review before continuing:"];
|
|
145
|
+
|
|
146
|
+
if (regularEntries.length > 0) {
|
|
147
|
+
sections.push(formatGroupedDiagnostics(regularEntries, cwd));
|
|
148
|
+
if (augmentation) {
|
|
149
|
+
sections.push(augmentation);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (suppressionEntries.length > 0) {
|
|
154
|
+
sections.push(
|
|
155
|
+
`🗑️ Stale suppressions — cleanup available:\n${formatGroupedDiagnostics(suppressionEntries, cwd)}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
sections.push(
|
|
160
|
+
"If these errors are unexpected or appear across multiple files, fix the root cause before editing more files.",
|
|
161
|
+
);
|
|
162
|
+
return sections.join("\n\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function ensureFileOpen(manager: LspManager | null, filePath: string): Promise<void> {
|
|
166
|
+
if (!manager) return;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await manager.ensureFileOpen(filePath);
|
|
170
|
+
} catch {
|
|
171
|
+
// Never block the agent on LSP errors
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/** Gitignore-style glob pattern matching for path exclusion.
|
|
2
|
+
*
|
|
3
|
+
* Supports:
|
|
4
|
+
* - Literal names at any depth: __tests__, build
|
|
5
|
+
* - Trailing slash directory-only: __tests__ + "/"
|
|
6
|
+
* - Leading slash root-anchored: "/" + build
|
|
7
|
+
* - Stars-star-slash recursive: e.g. `**` + "/" + fixtures
|
|
8
|
+
* - Asterisk single-segment wildcard: *.generated.ts
|
|
9
|
+
* - Literal paths: packages + "/" + legacy
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalize path separators to forward slashes and trim.
|
|
14
|
+
*/
|
|
15
|
+
function normalize(p: string): string {
|
|
16
|
+
return p.replaceAll("\\", "/").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check whether a project-relative file path matches a gitignore-style glob pattern.
|
|
21
|
+
*
|
|
22
|
+
* @param filePath - Relative file path with forward slashes (e.g. `"src/__tests__/bar.test.ts"`)
|
|
23
|
+
* @param pattern - Gitignore-style glob pattern (e.g. `"__tests__/"`, `"*.test.ts"`, `"/build"`)
|
|
24
|
+
* @returns `true` if the file path matches the pattern
|
|
25
|
+
*/
|
|
26
|
+
export function isGlobMatch(filePath: string, pattern: string): boolean {
|
|
27
|
+
const fp = normalize(filePath);
|
|
28
|
+
const pat = normalize(pattern);
|
|
29
|
+
if (!fp || !pat) return false;
|
|
30
|
+
|
|
31
|
+
// Leading / → anchored to root
|
|
32
|
+
const anchored = pat.startsWith("/");
|
|
33
|
+
const noLeadingSlash = anchored ? pat.slice(1) : pat;
|
|
34
|
+
|
|
35
|
+
// Trailing / → directory-only
|
|
36
|
+
const dirOnly = noLeadingSlash.endsWith("/");
|
|
37
|
+
const cleanPat = dirOnly ? noLeadingSlash.slice(0, -1) : noLeadingSlash;
|
|
38
|
+
|
|
39
|
+
if (!cleanPat) return false;
|
|
40
|
+
|
|
41
|
+
return matchGlob(fp, cleanPat, { anchored, dirOnly });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MatchOptions {
|
|
45
|
+
anchored: boolean;
|
|
46
|
+
dirOnly: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Core recursive pattern matching against a multi-segment path.
|
|
51
|
+
*/
|
|
52
|
+
function matchGlob(filePath: string, pattern: string, opts: MatchOptions): boolean {
|
|
53
|
+
// Direct match
|
|
54
|
+
if (!opts.anchored && !opts.dirOnly && filePath === pattern) return true;
|
|
55
|
+
|
|
56
|
+
// Split into segments
|
|
57
|
+
const pathSegments = filePath.split("/");
|
|
58
|
+
const patternSegments = pattern.split("/");
|
|
59
|
+
|
|
60
|
+
// ** recursive glob
|
|
61
|
+
if (pattern.startsWith("**/")) {
|
|
62
|
+
const suffix = pattern.slice(3);
|
|
63
|
+
return (
|
|
64
|
+
matchGlob(filePath, suffix, { ...opts, anchored: false }) ||
|
|
65
|
+
starStarMatch(pathSegments, suffix)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// prefix/**/suffix bounded recursive glob
|
|
70
|
+
const dstarIdx = pattern.indexOf("/**/");
|
|
71
|
+
if (dstarIdx !== -1) {
|
|
72
|
+
const prefix = pattern.slice(0, dstarIdx);
|
|
73
|
+
const suffix = pattern.slice(dstarIdx + 4);
|
|
74
|
+
return matchBoundedStar(pathSegments, prefix, suffix);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Single-segment patterns
|
|
78
|
+
if (patternSegments.length === 1 && !opts.anchored) {
|
|
79
|
+
return matchSingleSegment(pathSegments, patternSegments[0], opts.dirOnly);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Multi-segment: anchored or unanchored
|
|
83
|
+
if (opts.anchored) {
|
|
84
|
+
return matchSegments(pathSegments, patternSegments, opts.dirOnly);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Unanchored multi-segment: try at each starting position
|
|
88
|
+
return matchUnanchoredSegments(pathSegments, patternSegments, opts.dirOnly);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Match a ** recursive suffix across directory levels.
|
|
93
|
+
*/
|
|
94
|
+
function starStarMatch(segments: string[], suffix: string): boolean {
|
|
95
|
+
for (let i = 0; i < segments.length; i++) {
|
|
96
|
+
const remaining = segments.slice(i).join("/");
|
|
97
|
+
if (matchGlob(remaining, suffix, { anchored: false, dirOnly: false })) return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Match prefix plus star-star-slash plus suffix bounded recursive pattern. */
|
|
103
|
+
function matchBoundedStar(segments: string[], prefix: string, suffix: string): boolean {
|
|
104
|
+
// Try to find a split point where left matches prefix and right matches suffix
|
|
105
|
+
for (let i = 1; i < segments.length; i++) {
|
|
106
|
+
const left = segments.slice(0, i).join("/");
|
|
107
|
+
const right = segments.slice(i).join("/");
|
|
108
|
+
if (
|
|
109
|
+
matchGlob(left, prefix, { anchored: false, dirOnly: false }) &&
|
|
110
|
+
matchGlob(right, suffix, { anchored: false, dirOnly: false })
|
|
111
|
+
) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Match a single-segment pattern against path segments.
|
|
120
|
+
*/
|
|
121
|
+
function matchSingleSegment(segments: string[], patternSeg: string, dirOnly: boolean): boolean {
|
|
122
|
+
const hasGlob = patternSeg.includes("*") || patternSeg.includes("?");
|
|
123
|
+
|
|
124
|
+
if (hasGlob) {
|
|
125
|
+
// Glob pattern matches any segment
|
|
126
|
+
return segments.some((seg) => simpleMatch(seg, patternSeg));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Literal segment name
|
|
130
|
+
if (dirOnly) {
|
|
131
|
+
// Match as directory: any segment except the last (file) one
|
|
132
|
+
return segments.slice(0, -1).some((seg) => seg === patternSeg);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Match any segment (file or directory)
|
|
136
|
+
return segments.some((seg) => seg === patternSeg);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Try to match multi-segment pattern at each start position.
|
|
141
|
+
*/
|
|
142
|
+
function matchUnanchoredSegments(segments: string[], pattern: string[], dirOnly: boolean): boolean {
|
|
143
|
+
for (let i = 0; i < segments.length; i++) {
|
|
144
|
+
if (matchSegments(segments.slice(i), pattern, dirOnly)) return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Match segments from start. Returns true if all pattern segments match contiguously. */
|
|
150
|
+
function matchSegments(segments: string[], pattern: string[], dirOnly: boolean): boolean {
|
|
151
|
+
if (pattern.length > segments.length) return false;
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
154
|
+
if (!matchSegmentAtIndex(segments, pattern, i)) return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// dirOnly: last matched segment must not be the last path segment
|
|
158
|
+
if (dirOnly && pattern.length === segments.length) return false;
|
|
159
|
+
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Match a single pattern segment against the corresponding path segment. */
|
|
164
|
+
function matchSegmentAtIndex(segments: string[], pattern: string[], index: number): boolean {
|
|
165
|
+
const patSeg = pattern[index];
|
|
166
|
+
const pathSeg = segments[index];
|
|
167
|
+
|
|
168
|
+
if (patSeg === "**") {
|
|
169
|
+
// ** at end matches all remaining segments
|
|
170
|
+
if (index === pattern.length - 1) return true;
|
|
171
|
+
// Try rest of pattern from various positions
|
|
172
|
+
for (let j = index; j < segments.length; j++) {
|
|
173
|
+
if (matchSegments(segments.slice(j), pattern.slice(index + 1), false)) return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return simpleMatch(pathSeg, patSeg);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Match a single path segment against a single pattern segment.
|
|
183
|
+
* Supports `*` (any chars except `/`) and `?` (single char).
|
|
184
|
+
*/
|
|
185
|
+
function simpleMatch(segment: string, pattern: string): boolean {
|
|
186
|
+
if (pattern === "*") return true;
|
|
187
|
+
if (pattern === segment) return true;
|
|
188
|
+
if (!pattern.includes("*") && !pattern.includes("?")) return false;
|
|
189
|
+
|
|
190
|
+
// Convert pattern to simple regex
|
|
191
|
+
const regexStr = pattern
|
|
192
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
193
|
+
.replace(/\*/g, "[^/]*")
|
|
194
|
+
.replace(/\?/g, "[^/]");
|
|
195
|
+
|
|
196
|
+
return new RegExp(`^${regexStr}$`).test(segment);
|
|
197
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
interface LspContextDetails {
|
|
5
|
+
contextToken?: string;
|
|
6
|
+
inlineSeverity?: number;
|
|
7
|
+
staleWarning?: string | null;
|
|
8
|
+
diagnostics?: Array<{
|
|
9
|
+
file: string;
|
|
10
|
+
errors: number;
|
|
11
|
+
warnings: number;
|
|
12
|
+
information: number;
|
|
13
|
+
hints: number;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type DiagnosticEntry = NonNullable<LspContextDetails["diagnostics"]>[number];
|
|
18
|
+
type DiagnosticCounts = Pick<DiagnosticEntry, "errors" | "warnings" | "information" | "hints">;
|
|
19
|
+
|
|
20
|
+
function formatDiagnosticCounts(
|
|
21
|
+
totals: DiagnosticCounts,
|
|
22
|
+
theme: { fg: (color: ThemeColor, text: string) => string },
|
|
23
|
+
): string {
|
|
24
|
+
const parts: string[] = [];
|
|
25
|
+
if (totals.errors > 0)
|
|
26
|
+
parts.push(theme.fg("error", `${totals.errors} error${totals.errors === 1 ? "" : "s"}`));
|
|
27
|
+
if (totals.warnings > 0)
|
|
28
|
+
parts.push(
|
|
29
|
+
theme.fg("warning", `${totals.warnings} warning${totals.warnings === 1 ? "" : "s"}`),
|
|
30
|
+
);
|
|
31
|
+
if (totals.information > 0)
|
|
32
|
+
parts.push(
|
|
33
|
+
theme.fg("accent", `${totals.information} info${totals.information === 1 ? "" : "s"}`),
|
|
34
|
+
);
|
|
35
|
+
if (totals.hints > 0)
|
|
36
|
+
parts.push(theme.fg("dim", `${totals.hints} hint${totals.hints === 1 ? "" : "s"}`));
|
|
37
|
+
return parts.join(", ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasDiagnosticCounts(totals: DiagnosticCounts): boolean {
|
|
41
|
+
return totals.errors > 0 || totals.warnings > 0 || totals.information > 0 || totals.hints > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildLspContextCollapsed(
|
|
45
|
+
diagnostics: LspContextDetails["diagnostics"],
|
|
46
|
+
totals: DiagnosticCounts | undefined,
|
|
47
|
+
staleWarning: string | null | undefined,
|
|
48
|
+
theme: { fg: (color: ThemeColor, text: string) => string },
|
|
49
|
+
): string {
|
|
50
|
+
const icon = theme.fg("accent", "\u{1F527}");
|
|
51
|
+
let summary: string;
|
|
52
|
+
if (!diagnostics || !totals) {
|
|
53
|
+
summary = `${icon} LSP diagnostics injected`;
|
|
54
|
+
} else if (!hasDiagnosticCounts(totals)) {
|
|
55
|
+
summary = `${icon} LSP diagnostics injected ${theme.fg("success", "\u2713")}`;
|
|
56
|
+
} else {
|
|
57
|
+
summary = `${icon} LSP diagnostics injected (${formatDiagnosticCounts(totals, theme)})`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!staleWarning) return summary;
|
|
61
|
+
return `${summary}\n${theme.fg("warning", staleWarning)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatFileDiagnosticEntry(d: DiagnosticCounts): string {
|
|
65
|
+
return formatDiagnosticCounts(d, { fg: (_color, text) => text });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatExpandedDetails(
|
|
69
|
+
diagnostics: LspContextDetails["diagnostics"],
|
|
70
|
+
token: string | undefined,
|
|
71
|
+
staleWarning: string | null | undefined,
|
|
72
|
+
theme: { fg: (color: ThemeColor, text: string) => string },
|
|
73
|
+
): string {
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
if (staleWarning) {
|
|
76
|
+
lines.push(theme.fg("warning", ` ${staleWarning}`));
|
|
77
|
+
}
|
|
78
|
+
if (diagnostics && diagnostics.length > 0) {
|
|
79
|
+
for (const d of diagnostics) {
|
|
80
|
+
lines.push(theme.fg("dim", ` ${d.file}: ${formatFileDiagnosticEntry(d)}`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (token) {
|
|
84
|
+
lines.push(theme.fg("dim", ` token: ${token}`));
|
|
85
|
+
}
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type { LspContextDetails };
|
|
90
|
+
|
|
91
|
+
export function registerLspMessageRenderer(
|
|
92
|
+
pi: import("@earendil-works/pi-coding-agent").ExtensionAPI,
|
|
93
|
+
): void {
|
|
94
|
+
pi.registerMessageRenderer("lsp-context", (message, { expanded }, theme) => {
|
|
95
|
+
const details = message.details as LspContextDetails | undefined;
|
|
96
|
+
const diagnostics = details?.diagnostics;
|
|
97
|
+
const token = details?.contextToken;
|
|
98
|
+
|
|
99
|
+
const totals = diagnostics?.reduce(
|
|
100
|
+
(acc, d) => ({
|
|
101
|
+
errors: acc.errors + d.errors,
|
|
102
|
+
warnings: acc.warnings + d.warnings,
|
|
103
|
+
information: acc.information + d.information,
|
|
104
|
+
hints: acc.hints + d.hints,
|
|
105
|
+
}),
|
|
106
|
+
{ errors: 0, warnings: 0, information: 0, hints: 0 },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const staleWarning = details?.staleWarning;
|
|
110
|
+
const collapsed = buildLspContextCollapsed(diagnostics, totals, staleWarning, theme);
|
|
111
|
+
const expandedDetails = expanded
|
|
112
|
+
? formatExpandedDetails(diagnostics, token, staleWarning, theme)
|
|
113
|
+
: "";
|
|
114
|
+
const fullText = expandedDetails ? `${collapsed}\n${expandedDetails}` : collapsed;
|
|
115
|
+
|
|
116
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
117
|
+
box.addChild(new Text(fullText, 0, 0));
|
|
118
|
+
return box;
|
|
119
|
+
});
|
|
120
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { dedupeTopmostRoots, walkProject } from "@mrclrchtr/supi-core";
|
|
4
|
+
import type { LspManager } from "./manager/manager.ts";
|
|
5
|
+
import type {
|
|
6
|
+
DetectedProjectServer,
|
|
7
|
+
LspConfig,
|
|
8
|
+
MissingServer,
|
|
9
|
+
ProjectServerInfo,
|
|
10
|
+
ServerConfig,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
import { commandExists } from "./utils.ts";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
15
|
+
|
|
16
|
+
export function scanProjectCapabilities(
|
|
17
|
+
config: LspConfig,
|
|
18
|
+
cwd: string,
|
|
19
|
+
maxDepth: number = DEFAULT_MAX_DEPTH,
|
|
20
|
+
): DetectedProjectServer[] {
|
|
21
|
+
const { markerMatches, extensionBasedServers } = collectServerHints(config, cwd, maxDepth);
|
|
22
|
+
|
|
23
|
+
return Object.entries(config.servers)
|
|
24
|
+
.flatMap(([serverName, server]) => {
|
|
25
|
+
if (!commandExists(server.command)) return [];
|
|
26
|
+
const allRoots = new Set(markerMatches.get(serverName) ?? []);
|
|
27
|
+
if (extensionBasedServers.has(serverName)) {
|
|
28
|
+
allRoots.add(cwd);
|
|
29
|
+
}
|
|
30
|
+
const roots = dedupeTopmostRoots(Array.from(allRoots));
|
|
31
|
+
return roots.map(
|
|
32
|
+
(root) =>
|
|
33
|
+
({
|
|
34
|
+
name: serverName,
|
|
35
|
+
root,
|
|
36
|
+
fileTypes: [...server.fileTypes],
|
|
37
|
+
}) satisfies DetectedProjectServer,
|
|
38
|
+
);
|
|
39
|
+
})
|
|
40
|
+
.sort((a, b) => a.root.localeCompare(b.root) || a.name.localeCompare(b.name));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Walk the project collecting marker matches and extension-based server hints. */
|
|
44
|
+
function collectServerHints(
|
|
45
|
+
config: LspConfig,
|
|
46
|
+
cwd: string,
|
|
47
|
+
maxDepth: number,
|
|
48
|
+
): { markerMatches: Map<string, Set<string>>; extensionBasedServers: Set<string> } {
|
|
49
|
+
const ctx: HintContext = { markerMatches: new Map(), extensionBasedServers: new Set() };
|
|
50
|
+
|
|
51
|
+
walkProject(cwd, maxDepth, (directory, entryNames) => {
|
|
52
|
+
inspectDirectory(directory, entryNames, config.servers, ctx);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { markerMatches: ctx.markerMatches, extensionBasedServers: ctx.extensionBasedServers };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface HintContext {
|
|
59
|
+
markerMatches: Map<string, Set<string>>;
|
|
60
|
+
extensionBasedServers: Set<string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Check one directory for server markers or matching file extensions. */
|
|
64
|
+
function inspectDirectory(
|
|
65
|
+
directory: string,
|
|
66
|
+
entryNames: Set<string>,
|
|
67
|
+
servers: Record<string, ServerConfig>,
|
|
68
|
+
ctx: HintContext,
|
|
69
|
+
): void {
|
|
70
|
+
for (const [serverName, server] of Object.entries(servers)) {
|
|
71
|
+
if (!commandExists(server.command)) continue;
|
|
72
|
+
|
|
73
|
+
if (server.rootMarkers.length === 0) {
|
|
74
|
+
if (hasMatchingFile(directory, entryNames, server.fileTypes)) {
|
|
75
|
+
ctx.extensionBasedServers.add(serverName);
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!server.rootMarkers.some((marker) => entryNames.has(marker))) continue;
|
|
81
|
+
|
|
82
|
+
const matches = ctx.markerMatches.get(serverName) ?? new Set<string>();
|
|
83
|
+
matches.add(directory);
|
|
84
|
+
ctx.markerMatches.set(serverName, matches);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Check whether a directory contains a file with one of the target extensions. */
|
|
89
|
+
function hasMatchingFile(directory: string, entryNames: Set<string>, fileTypes: string[]): boolean {
|
|
90
|
+
for (const name of entryNames) {
|
|
91
|
+
const ext = path.extname(name).slice(1).toLowerCase();
|
|
92
|
+
if (!fileTypes.includes(ext)) continue;
|
|
93
|
+
try {
|
|
94
|
+
if (!fs.statSync(path.join(directory, name)).isFile()) continue;
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function startDetectedServers(
|
|
104
|
+
manager: LspManager,
|
|
105
|
+
detected: DetectedProjectServer[],
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
await Promise.all(detected.map((entry) => manager.startServerForRoot(entry.name, entry.root)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function introspectCapabilities(
|
|
111
|
+
manager: LspManager,
|
|
112
|
+
detected: DetectedProjectServer[],
|
|
113
|
+
): ProjectServerInfo[] {
|
|
114
|
+
return manager.getKnownProjectServers(detected);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Scan the project for languages whose source files exist but whose LSP server
|
|
119
|
+
* binary is not installed on PATH. Walks the project directory tree collecting
|
|
120
|
+
* file extensions, then checks each configured server.
|
|
121
|
+
*/
|
|
122
|
+
export function scanMissingServers(
|
|
123
|
+
config: LspConfig,
|
|
124
|
+
cwd: string,
|
|
125
|
+
maxDepth: number = DEFAULT_MAX_DEPTH,
|
|
126
|
+
): MissingServer[] {
|
|
127
|
+
const foundExtensions = new Set<string>();
|
|
128
|
+
|
|
129
|
+
walkProject(cwd, maxDepth, (directory, entryNames) => {
|
|
130
|
+
for (const name of entryNames) {
|
|
131
|
+
try {
|
|
132
|
+
if (!fs.statSync(path.join(directory, name)).isFile()) continue;
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const ext = path.extname(name).slice(1).toLowerCase();
|
|
137
|
+
if (ext) foundExtensions.add(ext);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const missing: MissingServer[] = [];
|
|
142
|
+
|
|
143
|
+
for (const [name, server] of Object.entries(config.servers)) {
|
|
144
|
+
if (commandExists(server.command)) continue;
|
|
145
|
+
|
|
146
|
+
const matching = server.fileTypes.filter((ft) => foundExtensions.has(ft));
|
|
147
|
+
if (matching.length === 0) continue;
|
|
148
|
+
|
|
149
|
+
missing.push({ name, command: server.command, foundExtensions: matching });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return missing;
|
|
153
|
+
}
|