@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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Generic settings overlay for SuPi extensions.
|
|
2
|
+
//
|
|
3
|
+
// Uses pi-tui's SettingsList with scope toggle (Tab), extension grouping,
|
|
4
|
+
// and search. Each extension declares its settings via registerSettings().
|
|
5
|
+
|
|
6
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
Container,
|
|
10
|
+
Input,
|
|
11
|
+
Key,
|
|
12
|
+
matchesKey,
|
|
13
|
+
type SettingItem,
|
|
14
|
+
SettingsList,
|
|
15
|
+
Text,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
import {
|
|
18
|
+
getRegisteredSettings,
|
|
19
|
+
type SettingsScope,
|
|
20
|
+
type SettingsSection,
|
|
21
|
+
} from "./settings-registry.ts";
|
|
22
|
+
|
|
23
|
+
// ── Input submenu component ──────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a pi-tui Input-backed submenu component with enter-to-confirm
|
|
27
|
+
* and escape-to-cancel handling.
|
|
28
|
+
*
|
|
29
|
+
* @param currentValue - Initial value for the text input.
|
|
30
|
+
* @param label - Label text displayed above the input.
|
|
31
|
+
* @param done - Callback invoked with the confirmed value, or undefined on cancel.
|
|
32
|
+
*/
|
|
33
|
+
export function createInputSubmenu(
|
|
34
|
+
currentValue: string,
|
|
35
|
+
label: string,
|
|
36
|
+
done: (selectedValue?: string) => void,
|
|
37
|
+
): {
|
|
38
|
+
render: (width: number) => string[];
|
|
39
|
+
invalidate: () => void;
|
|
40
|
+
handleInput: (data: string) => boolean;
|
|
41
|
+
} {
|
|
42
|
+
const input = new Input();
|
|
43
|
+
input.setValue(currentValue);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
render: (_width: number) => {
|
|
47
|
+
const lines = [` ${label}`];
|
|
48
|
+
lines.push(...input.render(_width));
|
|
49
|
+
lines.push(" enter confirm • esc cancel");
|
|
50
|
+
return lines;
|
|
51
|
+
},
|
|
52
|
+
invalidate: () => {
|
|
53
|
+
input.invalidate();
|
|
54
|
+
},
|
|
55
|
+
handleInput: (data: string) => {
|
|
56
|
+
if (matchesKey(data, Key.escape)) {
|
|
57
|
+
done();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (matchesKey(data, Key.enter)) {
|
|
61
|
+
done(input.getValue());
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
input.handleInput(data);
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Types ────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
interface OverlayState {
|
|
73
|
+
scope: SettingsScope;
|
|
74
|
+
cwd: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Pure helpers ─────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function getScopeLabel(scope: SettingsScope): string {
|
|
80
|
+
return scope === "project" ? "Project" : "Global";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildFlatItems(
|
|
84
|
+
sections: SettingsSection[],
|
|
85
|
+
scope: SettingsScope,
|
|
86
|
+
cwd: string,
|
|
87
|
+
): SettingItem[] {
|
|
88
|
+
const items: SettingItem[] = [];
|
|
89
|
+
for (const section of sections) {
|
|
90
|
+
const sectionItems = section.loadValues(scope, cwd);
|
|
91
|
+
for (const item of sectionItems) {
|
|
92
|
+
items.push({
|
|
93
|
+
...item,
|
|
94
|
+
id: `${section.id}.${item.id}`,
|
|
95
|
+
label: `${section.label}: ${item.label}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return items;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findSectionAndId(
|
|
103
|
+
sections: SettingsSection[],
|
|
104
|
+
flatId: string,
|
|
105
|
+
): { section: SettingsSection; itemId: string } | null {
|
|
106
|
+
const dotIndex = flatId.indexOf(".");
|
|
107
|
+
if (dotIndex === -1) return null;
|
|
108
|
+
const sectionId = flatId.slice(0, dotIndex);
|
|
109
|
+
const itemId = flatId.slice(dotIndex + 1);
|
|
110
|
+
const section = sections.find((s) => s.id === sectionId);
|
|
111
|
+
if (!section) return null;
|
|
112
|
+
return { section, itemId };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Component ────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
interface SettingsOverlayDeps {
|
|
118
|
+
state: OverlayState;
|
|
119
|
+
container: Container;
|
|
120
|
+
settingsList: SettingsList | null;
|
|
121
|
+
tui: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[0];
|
|
122
|
+
theme: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[1];
|
|
123
|
+
done: () => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createSettingsList(deps: SettingsOverlayDeps): SettingsList {
|
|
127
|
+
const sections = getRegisteredSettings();
|
|
128
|
+
const items = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
|
|
129
|
+
const onChange = (flatId: string, newValue: string) => {
|
|
130
|
+
const found = findSectionAndId(sections, flatId);
|
|
131
|
+
if (found) {
|
|
132
|
+
found.section.persistChange(deps.state.scope, deps.state.cwd, found.itemId, newValue);
|
|
133
|
+
}
|
|
134
|
+
// Re-read all values to reflect persisted changes, but keep the list
|
|
135
|
+
// instance (and its selectedIndex) intact.
|
|
136
|
+
const updatedItems = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
|
|
137
|
+
for (const updated of updatedItems) {
|
|
138
|
+
const existing = items.find((i) => i.id === updated.id);
|
|
139
|
+
if (existing && existing.currentValue !== updated.currentValue) {
|
|
140
|
+
settingsList.updateValue(updated.id, updated.currentValue);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
deps.tui.requestRender();
|
|
144
|
+
};
|
|
145
|
+
const settingsList = new SettingsList(
|
|
146
|
+
items,
|
|
147
|
+
Math.min(items.length + 4, 20),
|
|
148
|
+
getSettingsListTheme(),
|
|
149
|
+
onChange,
|
|
150
|
+
() => deps.done(),
|
|
151
|
+
{ enableSearch: true },
|
|
152
|
+
);
|
|
153
|
+
return settingsList;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function rebuildSettingsList(deps: SettingsOverlayDeps): SettingsList {
|
|
157
|
+
const settingsList = createSettingsList(deps);
|
|
158
|
+
deps.settingsList = settingsList;
|
|
159
|
+
|
|
160
|
+
deps.container.clear();
|
|
161
|
+
deps.container.addChild(createHeaderComponent(deps));
|
|
162
|
+
deps.container.addChild(settingsList);
|
|
163
|
+
|
|
164
|
+
return settingsList;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createHeaderComponent(deps: SettingsOverlayDeps): Text {
|
|
168
|
+
const { theme, state } = deps;
|
|
169
|
+
const scopeLabel = getScopeLabel(state.scope);
|
|
170
|
+
const otherScope = state.scope === "project" ? "Global" : "Project";
|
|
171
|
+
const headerText = new Text(
|
|
172
|
+
`${theme.fg("accent", theme.bold("SuPi Settings"))} ${theme.fg("text", `Scope: ${scopeLabel}`)} ${theme.fg("dim", `(tab → ${otherScope})`)}`,
|
|
173
|
+
0,
|
|
174
|
+
0,
|
|
175
|
+
);
|
|
176
|
+
return headerText;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function handleScopeToggle(deps: SettingsOverlayDeps): void {
|
|
180
|
+
deps.state.scope = deps.state.scope === "project" ? "global" : "project";
|
|
181
|
+
rebuildSettingsList(deps);
|
|
182
|
+
deps.tui.requestRender();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Entry point ──────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export function openSettingsOverlay(ctx: ExtensionContext): void {
|
|
188
|
+
const sections = getRegisteredSettings();
|
|
189
|
+
if (sections.length === 0) {
|
|
190
|
+
ctx.ui.notify("No settings registered by SuPi extensions", "info");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
void ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
195
|
+
const state: OverlayState = { scope: "project", cwd: ctx.cwd };
|
|
196
|
+
const container = new Container();
|
|
197
|
+
|
|
198
|
+
const deps: SettingsOverlayDeps = {
|
|
199
|
+
state,
|
|
200
|
+
container,
|
|
201
|
+
settingsList: null,
|
|
202
|
+
tui,
|
|
203
|
+
theme,
|
|
204
|
+
done,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
rebuildSettingsList(deps);
|
|
208
|
+
|
|
209
|
+
const component = {
|
|
210
|
+
render: (width: number) => container.render(width),
|
|
211
|
+
invalidate: () => container.invalidate(),
|
|
212
|
+
handleInput: (data: string) => {
|
|
213
|
+
if (matchesKey(data, Key.tab)) {
|
|
214
|
+
handleScopeToggle(deps);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
// Delegate input to the settings list (always set after rebuildSettingsList)
|
|
218
|
+
deps.settingsList?.handleInput?.(data);
|
|
219
|
+
deps.tui.requestRender();
|
|
220
|
+
return true;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return component;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared terminal title formatting and signaling utilities.
|
|
3
|
+
*
|
|
4
|
+
* Centralized place for pi title convention (π prefix), completion (✓)
|
|
5
|
+
* and waiting (●) indicators, and the audible terminal bell.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Unicode checkmark shown when the agent finishes a turn. */
|
|
10
|
+
export const DONE_SYMBOL = "\u2713";
|
|
11
|
+
/** Unicode dot shown when waiting for user input. */
|
|
12
|
+
export const WAITING_SYMBOL = "\u25CF";
|
|
13
|
+
|
|
14
|
+
/** Minimal UI surface needed for title operations. */
|
|
15
|
+
export interface TitleTarget {
|
|
16
|
+
ui: {
|
|
17
|
+
setTitle?(title: string): void;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format pi's canonical terminal title from session name and cwd.
|
|
23
|
+
* Falls back gracefully when either is missing.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* formatTitle("my-session", "/home/projects/foo") // "π - my-session - foo"
|
|
27
|
+
* formatTitle(undefined, "/home/projects/foo") // "π - foo"
|
|
28
|
+
* formatTitle("my-session") // "π - my-session"
|
|
29
|
+
* formatTitle() // "π"
|
|
30
|
+
*/
|
|
31
|
+
export function formatTitle(sessionName?: string, cwd?: string): string {
|
|
32
|
+
const base = cwd ? path.basename(cwd) : undefined;
|
|
33
|
+
if (sessionName && base) return `π - ${sessionName} - ${base}`;
|
|
34
|
+
if (sessionName) return `π - ${sessionName}`;
|
|
35
|
+
if (base) return `π - ${base}`;
|
|
36
|
+
return "π";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Sound the audible terminal bell (ASCII BEL). */
|
|
40
|
+
export function signalBell(): void {
|
|
41
|
+
process.stdout.write("\x07");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Set the terminal title to indicate the agent is waiting for user input.
|
|
46
|
+
* Prefixes with ● and sounds the terminal bell.
|
|
47
|
+
*/
|
|
48
|
+
export function signalWaiting(ctx: TitleTarget, title: string): void {
|
|
49
|
+
ctx.ui.setTitle?.(`${WAITING_SYMBOL} ${title}`);
|
|
50
|
+
signalBell();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Set the terminal title to indicate the agent turn has completed.
|
|
55
|
+
* Prefixes with ✓ and sounds the terminal bell.
|
|
56
|
+
*/
|
|
57
|
+
export function signalDone(ctx: TitleTarget, title: string): void {
|
|
58
|
+
ctx.ui.setTitle?.(`${DONE_SYMBOL} ${title}`);
|
|
59
|
+
signalBell();
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-lsp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "SuPi LSP extension — Language Server Protocol integration for pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -16,21 +16,26 @@
|
|
|
16
16
|
"pi-coding-agent"
|
|
17
17
|
],
|
|
18
18
|
"files": [
|
|
19
|
-
"
|
|
20
|
-
"
|
|
19
|
+
"src/**/*.ts",
|
|
20
|
+
"src/**/*.json",
|
|
21
21
|
"!__tests__"
|
|
22
22
|
],
|
|
23
|
-
"
|
|
24
|
-
"@
|
|
25
|
-
"@mariozechner/pi-tui": "~0.66.0",
|
|
26
|
-
"@sinclair/typebox": ">=0.34.0"
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@mrclrchtr/supi-core": "1.1.2"
|
|
27
25
|
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
26
|
+
"bundledDependencies": [
|
|
27
|
+
"@mrclrchtr/supi-core"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-ai": "*",
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
33
|
+
"typebox": "*"
|
|
30
34
|
},
|
|
31
35
|
"pi": {
|
|
32
36
|
"extensions": [
|
|
33
|
-
"./lsp.ts"
|
|
37
|
+
"./src/lsp.ts"
|
|
34
38
|
]
|
|
35
|
-
}
|
|
39
|
+
},
|
|
40
|
+
"main": "src/index.ts"
|
|
36
41
|
}
|
|
@@ -46,9 +46,17 @@ export const CLIENT_CAPABILITIES: ClientCapabilities = {
|
|
|
46
46
|
},
|
|
47
47
|
publishDiagnostics: {
|
|
48
48
|
relatedInformation: true,
|
|
49
|
+
versionSupport: true,
|
|
50
|
+
},
|
|
51
|
+
diagnostic: {
|
|
52
|
+
dynamicRegistration: false,
|
|
53
|
+
relatedDocumentSupport: true,
|
|
49
54
|
},
|
|
50
55
|
},
|
|
51
56
|
workspace: {
|
|
52
57
|
workspaceFolders: false,
|
|
58
|
+
diagnostics: {
|
|
59
|
+
refreshSupport: false,
|
|
60
|
+
},
|
|
53
61
|
},
|
|
54
62
|
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// LSP Client diagnostic refresh — pull diagnostics and push settle logic.
|
|
2
|
+
// Extracted from client.ts to keep file sizes manageable.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import type { DocumentDiagnosticReport, TextDocumentIdentifier } from "../types.ts";
|
|
6
|
+
import { uriToFile } from "../utils.ts";
|
|
7
|
+
import type { DiagnosticCacheEntry, LspClient } from "./client.ts";
|
|
8
|
+
import type { JsonRpcClient } from "./transport.ts";
|
|
9
|
+
|
|
10
|
+
interface ClientAccess {
|
|
11
|
+
openDocs: Map<string, { version: number; languageId: string }>;
|
|
12
|
+
rpc: JsonRpcClient | null;
|
|
13
|
+
diagnosticStore: Map<string, DiagnosticCacheEntry>;
|
|
14
|
+
releaseDiagnosticWaiters(uri: string): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function accessClient(client: LspClient): ClientAccess {
|
|
18
|
+
return client as unknown as ClientAccess;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Re-read and re-sync all currently open, existing documents for a client.
|
|
23
|
+
* Uses pull diagnostics when the server supports them, otherwise falls back
|
|
24
|
+
* to push-diagnostic settling. Never throws.
|
|
25
|
+
*/
|
|
26
|
+
export async function refreshClientOpenDiagnostics(
|
|
27
|
+
client: LspClient,
|
|
28
|
+
options: { maxWaitMs?: number; quietMs?: number } = {},
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const maxWaitMs = options.maxWaitMs ?? 3_000;
|
|
31
|
+
const quietMs = options.quietMs ?? 200;
|
|
32
|
+
|
|
33
|
+
if (client.status !== "running") return;
|
|
34
|
+
|
|
35
|
+
const syncStart = Date.now();
|
|
36
|
+
|
|
37
|
+
// Re-sync all open documents that still exist on disk
|
|
38
|
+
const openDocs = accessClient(client).openDocs as Map<
|
|
39
|
+
string,
|
|
40
|
+
{ version: number; languageId: string }
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
for (const [uri, doc] of openDocs) {
|
|
44
|
+
const filePath = uriToFile(uri);
|
|
45
|
+
try {
|
|
46
|
+
if (!existsSync(filePath)) {
|
|
47
|
+
clearFileState(client, uri);
|
|
48
|
+
sendNotification(client, "textDocument/didClose", {
|
|
49
|
+
textDocument: { uri } satisfies TextDocumentIdentifier,
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const content = readFileSync(filePath, "utf-8");
|
|
54
|
+
doc.version++;
|
|
55
|
+
sendDidChange(client, uri, doc.version, content);
|
|
56
|
+
} catch {
|
|
57
|
+
// Read error — skip this file, keep it open
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (openDocs.size === 0) return;
|
|
62
|
+
|
|
63
|
+
// Try pull diagnostics if server supports them
|
|
64
|
+
if (client.hasDiagnosticProvider) {
|
|
65
|
+
try {
|
|
66
|
+
await pullDiagnosticsForOpenDocs(client, syncStart, maxWaitMs);
|
|
67
|
+
return;
|
|
68
|
+
} catch {
|
|
69
|
+
// Pull diagnostics failed — fall back to push settle
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Fall back to push-diagnostic settling
|
|
74
|
+
await waitForDiagnosticSettle(client, syncStart, maxWaitMs, quietMs);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Send a didChange notification through the client's RPC. */
|
|
78
|
+
function sendDidChange(client: LspClient, uri: string, version: number, content: string): void {
|
|
79
|
+
sendNotification(client, "textDocument/didChange", {
|
|
80
|
+
textDocument: { uri, version },
|
|
81
|
+
contentChanges: [{ text: content }],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Send an RPC notification through the client. */
|
|
86
|
+
function sendNotification(client: LspClient, method: string, params: unknown): void {
|
|
87
|
+
const rpc = accessClient(client).rpc;
|
|
88
|
+
if (rpc) rpc.sendNotification(method, params);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Clear open doc and diagnostic state for a URI. */
|
|
92
|
+
function clearFileState(client: LspClient, uri: string): void {
|
|
93
|
+
accessClient(client).openDocs.delete(uri);
|
|
94
|
+
accessClient(client).diagnosticStore.delete(uri);
|
|
95
|
+
accessClient(client).releaseDiagnosticWaiters(uri);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Request pull diagnostics for all open documents.
|
|
100
|
+
* Throws if no diagnostics were successfully retrieved.
|
|
101
|
+
*/
|
|
102
|
+
async function pullDiagnosticsForOpenDocs(
|
|
103
|
+
client: LspClient,
|
|
104
|
+
syncStart: number,
|
|
105
|
+
maxWaitMs: number,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const deadline = syncStart + maxWaitMs;
|
|
108
|
+
const uris = Array.from((accessClient(client).openDocs as Map<string, unknown>).keys());
|
|
109
|
+
const results = await Promise.allSettled(
|
|
110
|
+
uris.map(async (uri) => {
|
|
111
|
+
const remaining = deadline - Date.now();
|
|
112
|
+
if (remaining <= 0) throw new Error("pull diagnostic timeout");
|
|
113
|
+
|
|
114
|
+
return pullDiagnosticsForUri(client, uri, remaining);
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const anySuccess = results.some((result) => result.status === "fulfilled" && result.value);
|
|
119
|
+
const hadFailure = results.some((result) => result.status === "rejected");
|
|
120
|
+
|
|
121
|
+
if ((hadFailure || !anySuccess) && uris.length > 0) {
|
|
122
|
+
throw new Error("pull diagnostics incomplete");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Pull diagnostics for a single URI and apply the report to the cache. */
|
|
127
|
+
export async function pullDiagnosticsForUri(
|
|
128
|
+
client: LspClient,
|
|
129
|
+
uri: string,
|
|
130
|
+
timeoutMs: number,
|
|
131
|
+
): Promise<boolean> {
|
|
132
|
+
const report = await pullDocumentDiagnostics(client, uri, timeoutMs);
|
|
133
|
+
if (!report) return false;
|
|
134
|
+
applyPullReport(client, uri, report);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Pull diagnostics for a single document with timeout. */
|
|
139
|
+
async function pullDocumentDiagnostics(
|
|
140
|
+
client: LspClient,
|
|
141
|
+
uri: string,
|
|
142
|
+
timeoutMs: number,
|
|
143
|
+
): Promise<DocumentDiagnosticReport | null> {
|
|
144
|
+
const rpc = accessClient(client).rpc;
|
|
145
|
+
if (!rpc || client.status !== "running") {
|
|
146
|
+
throw new Error("client not running");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const previousResultId = accessClient(client).diagnosticStore.get(uri)?.resultId;
|
|
150
|
+
return rpc.sendRequest(
|
|
151
|
+
"textDocument/diagnostic",
|
|
152
|
+
{
|
|
153
|
+
textDocument: { uri },
|
|
154
|
+
previousResultId,
|
|
155
|
+
},
|
|
156
|
+
{ timeoutMs },
|
|
157
|
+
) as Promise<DocumentDiagnosticReport>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Apply a pull diagnostic report to the cache, including related documents. */
|
|
161
|
+
function applyPullReport(client: LspClient, uri: string, report: DocumentDiagnosticReport): void {
|
|
162
|
+
if (report.kind === "full") {
|
|
163
|
+
accessClient(client).diagnosticStore.set(uri, {
|
|
164
|
+
diagnostics: report.items,
|
|
165
|
+
receivedAt: Date.now(),
|
|
166
|
+
resultId: report.resultId,
|
|
167
|
+
});
|
|
168
|
+
} else if (report.kind === "unchanged" && report.resultId) {
|
|
169
|
+
const current = accessClient(client).diagnosticStore.get(uri);
|
|
170
|
+
if (current) current.resultId = report.resultId;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
applyRelatedDocuments(client, report);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Extract and store related document diagnostics from a pull report. */
|
|
177
|
+
function applyRelatedDocuments(client: LspClient, report: DocumentDiagnosticReport): void {
|
|
178
|
+
const related = (report as unknown as Record<string, unknown>).relatedDocuments;
|
|
179
|
+
if (!related || typeof related !== "object") return;
|
|
180
|
+
|
|
181
|
+
for (const [relatedUri, relatedReport] of Object.entries(
|
|
182
|
+
related as Record<string, DocumentDiagnosticReport>,
|
|
183
|
+
)) {
|
|
184
|
+
if (relatedReport.kind === "full" && relatedReport.items) {
|
|
185
|
+
accessClient(client).diagnosticStore.set(relatedUri, {
|
|
186
|
+
diagnostics: relatedReport.items,
|
|
187
|
+
receivedAt: Date.now(),
|
|
188
|
+
resultId: relatedReport.resultId,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Wait until no publishDiagnostics notifications arrive for quietMs
|
|
196
|
+
* after syncStart, or until maxWaitMs elapses.
|
|
197
|
+
*/
|
|
198
|
+
async function waitForDiagnosticSettle(
|
|
199
|
+
client: LspClient,
|
|
200
|
+
syncStart: number,
|
|
201
|
+
maxWaitMs: number,
|
|
202
|
+
quietMs: number,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const deadline = syncStart + maxWaitMs;
|
|
205
|
+
|
|
206
|
+
while (Date.now() < deadline) {
|
|
207
|
+
const lastReceived = lastDiagnosticReceivedTimeAfter(client, syncStart) || syncStart;
|
|
208
|
+
const elapsed = Date.now() - lastReceived;
|
|
209
|
+
if (elapsed >= quietMs) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await new Promise((resolve) =>
|
|
214
|
+
setTimeout(resolve, Math.min(quietMs - elapsed, deadline - Date.now(), 50)),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Get the most recent receivedAt timestamp after a given time. */
|
|
220
|
+
function lastDiagnosticReceivedTimeAfter(client: LspClient, afterTime: number): number {
|
|
221
|
+
let latest = 0;
|
|
222
|
+
const store = accessClient(client).diagnosticStore as Map<string, { receivedAt: number }>;
|
|
223
|
+
for (const entry of store.values()) {
|
|
224
|
+
if (entry.receivedAt > afterTime && entry.receivedAt > latest) {
|
|
225
|
+
latest = entry.receivedAt;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return latest;
|
|
229
|
+
}
|