pi-lsp-adapter 0.1.0
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/LICENSE +21 -0
- package/README.md +506 -0
- package/package.json +79 -0
- package/src/commands/registerCommands.ts +228 -0
- package/src/commands/status.ts +79 -0
- package/src/config/loadConfig.ts +377 -0
- package/src/config/paths.ts +58 -0
- package/src/config/trust.ts +85 -0
- package/src/detect/filetypes.ts +86 -0
- package/src/detect/root.ts +32 -0
- package/src/index.ts +132 -0
- package/src/install/installers.ts +513 -0
- package/src/install/lockfile.ts +159 -0
- package/src/install/manager.ts +177 -0
- package/src/install/version.ts +40 -0
- package/src/lsp/client.ts +465 -0
- package/src/lsp/processRegistry.ts +339 -0
- package/src/lsp/runtimeManager.ts +552 -0
- package/src/registry/builtin.ts +171 -0
- package/src/registry/schema.ts +185 -0
- package/src/resolve/languages.ts +96 -0
- package/src/resolve/resolveServer.ts +315 -0
- package/src/state.ts +20 -0
- package/src/statusLine.ts +21 -0
- package/src/tools/lspFormat.ts +594 -0
- package/src/tools/registerLspTools.ts +241 -0
- package/src/tools/registerLspWarmup.ts +25 -0
- package/src/tools/resultCache.ts +162 -0
- package/src/ui/lspPanel.ts +123 -0
- package/src/util/deepMerge.ts +38 -0
- package/src/util/errors.ts +22 -0
- package/src/util/hash.ts +10 -0
- package/src/util/helpers.ts +41 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isPlainObject, normalizeProcessEnv } from "../util/helpers.js";
|
|
5
|
+
import { applyLanguageDefaults } from "./languages.js";
|
|
6
|
+
import type {
|
|
7
|
+
InstalledServerMetadata,
|
|
8
|
+
JsonObject,
|
|
9
|
+
JsonValue,
|
|
10
|
+
ResolvedServerConfig,
|
|
11
|
+
ServerDefinition,
|
|
12
|
+
} from "../registry/schema.js";
|
|
13
|
+
import { deepClone } from "../util/deepMerge.js";
|
|
14
|
+
import { ConfigError, MissingEnvironmentVariableError } from "../util/errors.js";
|
|
15
|
+
|
|
16
|
+
export interface ResolveServerConfigInput {
|
|
17
|
+
server: ServerDefinition;
|
|
18
|
+
rootDir: string;
|
|
19
|
+
rootMarker?: string;
|
|
20
|
+
install?: InstalledServerMetadata;
|
|
21
|
+
processEnv?: NodeJS.ProcessEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function resolveServerConfig(input: ResolveServerConfigInput): Promise<ResolvedServerConfig> {
|
|
25
|
+
const rootDir = resolve(input.rootDir);
|
|
26
|
+
const processEnv = normalizeProcessEnv(input.processEnv ?? process.env);
|
|
27
|
+
const env = resolveServerEnv(input.server.env ?? {}, processEnv, rootDir);
|
|
28
|
+
const settings = resolveJsonObject(input.server.settings, { env, rootDir, fieldPath: "settings" });
|
|
29
|
+
const initializationOptions = resolveJsonObject(input.server.initializationOptions, {
|
|
30
|
+
env,
|
|
31
|
+
rootDir,
|
|
32
|
+
fieldPath: "initializationOptions",
|
|
33
|
+
});
|
|
34
|
+
const cwd = resolvePathString(expandEnvReferences(input.server.cwd ?? rootDir, env, "cwd"), rootDir, env);
|
|
35
|
+
|
|
36
|
+
const languageDefaults = await applyLanguageDefaults({
|
|
37
|
+
server: input.server,
|
|
38
|
+
rootDir,
|
|
39
|
+
env,
|
|
40
|
+
settings,
|
|
41
|
+
initializationOptions,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const command = await resolveCommand(input.server.command, {
|
|
45
|
+
env: languageDefaults.env,
|
|
46
|
+
rootDir,
|
|
47
|
+
install: input.install,
|
|
48
|
+
workspaceDir: languageDefaults.workspaceDir,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
server: input.server,
|
|
53
|
+
rootDir,
|
|
54
|
+
rootMarker: input.rootMarker,
|
|
55
|
+
command,
|
|
56
|
+
cwd,
|
|
57
|
+
env: languageDefaults.env,
|
|
58
|
+
settings: languageDefaults.settings,
|
|
59
|
+
initializationOptions: languageDefaults.initializationOptions,
|
|
60
|
+
install: input.install,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ResolutionContext {
|
|
65
|
+
env: Record<string, string>;
|
|
66
|
+
rootDir: string;
|
|
67
|
+
fieldPath: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CommandResolutionContext {
|
|
71
|
+
env: Record<string, string>;
|
|
72
|
+
rootDir: string;
|
|
73
|
+
install?: InstalledServerMetadata;
|
|
74
|
+
workspaceDir?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveServerEnv(
|
|
78
|
+
serverEnv: Record<string, string>,
|
|
79
|
+
processEnv: Record<string, string>,
|
|
80
|
+
rootDir: string,
|
|
81
|
+
): Record<string, string> {
|
|
82
|
+
const env = { ...processEnv };
|
|
83
|
+
const resolvedOverrides: Record<string, string> = {};
|
|
84
|
+
const resolving = new Set<string>();
|
|
85
|
+
|
|
86
|
+
const resolveOverride = (key: string): string => {
|
|
87
|
+
const cached = resolvedOverrides[key];
|
|
88
|
+
if (cached !== undefined) return cached;
|
|
89
|
+
|
|
90
|
+
const rawValue = serverEnv[key];
|
|
91
|
+
if (rawValue === undefined) {
|
|
92
|
+
const processValue = processEnv[key];
|
|
93
|
+
if (processValue !== undefined) return processValue;
|
|
94
|
+
throw new MissingEnvironmentVariableError(key);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (resolving.has(key)) {
|
|
98
|
+
const fallback = processEnv[key];
|
|
99
|
+
if (fallback !== undefined) return fallback;
|
|
100
|
+
throw new ConfigError(`Circular environment variable reference involving ${key}.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resolving.add(key);
|
|
104
|
+
const interpolated = expandEnvReferences(rawValue, env, `env.${key}`, (variableName) => {
|
|
105
|
+
if (variableName === key) {
|
|
106
|
+
const fallback = processEnv[variableName];
|
|
107
|
+
if (fallback !== undefined) return fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (Object.prototype.hasOwnProperty.call(serverEnv, variableName)) {
|
|
111
|
+
return resolveOverride(variableName);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return env[variableName];
|
|
115
|
+
});
|
|
116
|
+
resolving.delete(key);
|
|
117
|
+
|
|
118
|
+
const resolved = isPathLikeEnvKey(key) ? resolvePathList(interpolated, rootDir, env) : interpolated;
|
|
119
|
+
resolvedOverrides[key] = resolved;
|
|
120
|
+
env[key] = resolved;
|
|
121
|
+
return resolved;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (const key of Object.keys(serverEnv)) {
|
|
125
|
+
resolveOverride(key);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return env;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveJsonObject(value: JsonObject, context: ResolutionContext): JsonObject {
|
|
132
|
+
return resolveJsonValue(deepClone(value), context) as JsonObject;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveJsonValue(value: JsonValue, context: ResolutionContext): JsonValue {
|
|
136
|
+
if (typeof value === "string") {
|
|
137
|
+
const interpolated = expandEnvReferences(value, context.env, context.fieldPath);
|
|
138
|
+
return isPathLikeField(context.fieldPath)
|
|
139
|
+
? resolvePathString(interpolated, context.rootDir, context.env)
|
|
140
|
+
: interpolated;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (Array.isArray(value)) {
|
|
144
|
+
return value.map((entry, index) =>
|
|
145
|
+
resolveJsonValue(entry, { ...context, fieldPath: `${context.fieldPath}[${index}]` }),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isPlainObject(value)) {
|
|
150
|
+
const resolved: JsonObject = {};
|
|
151
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
152
|
+
const childPath = `${context.fieldPath}.${key}`;
|
|
153
|
+
resolved[key] = resolveJsonValue(entry, { ...context, fieldPath: childPath });
|
|
154
|
+
}
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function resolveCommand(command: string[], context: CommandResolutionContext): Promise<string[]> {
|
|
162
|
+
const placeholders: Record<string, string | undefined> = {
|
|
163
|
+
installBin: getInstallBinDir(context.install),
|
|
164
|
+
installDir: getInstallDir(context.install),
|
|
165
|
+
platform: getPlatformToken(),
|
|
166
|
+
workspaceDir: context.workspaceDir,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const resolved: string[] = [];
|
|
170
|
+
for (const [index, part] of command.entries()) {
|
|
171
|
+
const field = `command[${index}]`;
|
|
172
|
+
const withTemplates = replaceCommandPlaceholders(part, placeholders);
|
|
173
|
+
if (containsUnresolvedPlaceholder(withTemplates)) {
|
|
174
|
+
throw new ConfigError(`Unresolved placeholder in ${field}: ${withTemplates}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const interpolated = expandEnvReferences(withTemplates, context.env, field);
|
|
178
|
+
const expanded = expandTilde(interpolated, context.env);
|
|
179
|
+
const pathResolved = shouldResolveCommandPart(expanded)
|
|
180
|
+
? resolvePathString(expanded, context.rootDir, context.env)
|
|
181
|
+
: expanded;
|
|
182
|
+
resolved.push(await resolveWildcardPath(pathResolved, field));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return resolved;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function replaceCommandPlaceholders(value: string, placeholders: Record<string, string | undefined>): string {
|
|
189
|
+
return value.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (match, name: string) => placeholders[name] ?? match);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getInstallBinDir(install: InstalledServerMetadata | undefined): string | undefined {
|
|
193
|
+
if (install?.binDir) return install.binDir;
|
|
194
|
+
const commandPath = install?.resolvedCommand[0];
|
|
195
|
+
if (!commandPath || !commandPath.includes("/")) return undefined;
|
|
196
|
+
return dirname(commandPath);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getInstallDir(install: InstalledServerMetadata | undefined): string | undefined {
|
|
200
|
+
if (install?.packageDir) return install.packageDir;
|
|
201
|
+
const binDir = getInstallBinDir(install);
|
|
202
|
+
if (!binDir) return undefined;
|
|
203
|
+
return dirname(binDir);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function containsUnresolvedPlaceholder(value: string): boolean {
|
|
207
|
+
return /\{[A-Za-z][A-Za-z0-9]*\}/u.test(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function resolveWildcardPath(value: string, field: string): Promise<string> {
|
|
211
|
+
if (!value.includes("*")) return value;
|
|
212
|
+
|
|
213
|
+
const filename = basename(value);
|
|
214
|
+
if (filename !== "org.eclipse.equinox.launcher_*.jar") {
|
|
215
|
+
throw new ConfigError(`Unsupported wildcard in ${field}: ${value}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const directory = dirname(value);
|
|
219
|
+
const matches = (await readdir(directory))
|
|
220
|
+
.filter((entry) => entry.startsWith("org.eclipse.equinox.launcher_") && entry.endsWith(".jar"))
|
|
221
|
+
.sort();
|
|
222
|
+
const match = matches.at(-1);
|
|
223
|
+
if (!match) {
|
|
224
|
+
throw new ConfigError(`No JDT LS launcher jar found in ${directory}.`);
|
|
225
|
+
}
|
|
226
|
+
return join(directory, match);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getPlatformToken(): string {
|
|
230
|
+
return `${process.platform}-${process.arch}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function expandEnvReferences(
|
|
234
|
+
value: string,
|
|
235
|
+
env: Record<string, string>,
|
|
236
|
+
field: string,
|
|
237
|
+
resolveVariable: (variableName: string) => string | undefined = (variableName) => env[variableName],
|
|
238
|
+
): string {
|
|
239
|
+
return value.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (_match, variableName: string) => {
|
|
240
|
+
const replacement = resolveVariable(variableName);
|
|
241
|
+
if (replacement === undefined) {
|
|
242
|
+
throw new MissingEnvironmentVariableError(variableName, field);
|
|
243
|
+
}
|
|
244
|
+
return replacement;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolvePathList(value: string, rootDir: string, env: Record<string, string>): string {
|
|
249
|
+
if (!value.includes(delimiter)) {
|
|
250
|
+
return resolvePathString(value, rootDir, env);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return value
|
|
254
|
+
.split(delimiter)
|
|
255
|
+
.map((entry) => (entry === "" ? entry : resolvePathString(entry, rootDir, env)))
|
|
256
|
+
.join(delimiter);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function resolvePathString(value: string, rootDir: string, env: Record<string, string>): string {
|
|
260
|
+
const expanded = expandTilde(value, env);
|
|
261
|
+
if (expanded === "") return expanded;
|
|
262
|
+
if (isAbsolute(expanded)) return expanded;
|
|
263
|
+
return resolve(rootDir, expanded);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function expandTilde(value: string, env: Record<string, string>): string {
|
|
267
|
+
if (value === "~") return getHomeDir(env);
|
|
268
|
+
if (value.startsWith(`~${getPathSeparator(value)}`)) {
|
|
269
|
+
return join(getHomeDir(env), value.slice(2));
|
|
270
|
+
}
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getPathSeparator(value: string): "/" | "\\" {
|
|
275
|
+
return value.startsWith("~\\") ? "\\" : "/";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getHomeDir(env: Record<string, string>): string {
|
|
279
|
+
return env.HOME ?? env.USERPROFILE ?? homedir();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function shouldResolveCommandPart(value: string): boolean {
|
|
283
|
+
if (value === "" || value.startsWith("-")) return false;
|
|
284
|
+
return value.startsWith("~") || value.startsWith(".") || value.includes("/") || value.includes("\\");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isPathLikeEnvKey(key: string): boolean {
|
|
288
|
+
const upper = key.toUpperCase();
|
|
289
|
+
return (
|
|
290
|
+
upper === "PATH" ||
|
|
291
|
+
upper.endsWith("PATH") ||
|
|
292
|
+
upper.endsWith("_DIR") ||
|
|
293
|
+
upper.endsWith("_HOME") ||
|
|
294
|
+
upper.endsWith("ROOT") ||
|
|
295
|
+
upper.includes("CACHE")
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isPathLikeField(fieldPath: string): boolean {
|
|
300
|
+
const key =
|
|
301
|
+
fieldPath
|
|
302
|
+
.replace(/\[[0-9]+\]$/u, "")
|
|
303
|
+
.split(".")
|
|
304
|
+
.at(-1)
|
|
305
|
+
?.toLowerCase() ?? "";
|
|
306
|
+
return (
|
|
307
|
+
key.includes("path") ||
|
|
308
|
+
key.endsWith("dir") ||
|
|
309
|
+
key.endsWith("directory") ||
|
|
310
|
+
key.endsWith("folder") ||
|
|
311
|
+
key.endsWith("file") ||
|
|
312
|
+
key.endsWith("root") ||
|
|
313
|
+
key.includes("workspace")
|
|
314
|
+
);
|
|
315
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LoadLspConfigResult } from "./config/loadConfig.js";
|
|
2
|
+
import type { LspInstallManager } from "./install/manager.js";
|
|
3
|
+
import type { LspProcessRegistry } from "./lsp/processRegistry.js";
|
|
4
|
+
import type { LspRuntimeManager } from "./lsp/runtimeManager.js";
|
|
5
|
+
import type { LspResultCache } from "./tools/resultCache.js";
|
|
6
|
+
|
|
7
|
+
export interface LspExtensionState {
|
|
8
|
+
ownerId: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
config: LoadLspConfigResult;
|
|
11
|
+
installManager: LspInstallManager;
|
|
12
|
+
processRegistry: LspProcessRegistry;
|
|
13
|
+
runtimeManager: LspRuntimeManager;
|
|
14
|
+
resultCache: LspResultCache;
|
|
15
|
+
lastRecovery?: {
|
|
16
|
+
terminated: number;
|
|
17
|
+
removed: number;
|
|
18
|
+
kept: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { LspExtensionState } from "./state.js";
|
|
3
|
+
|
|
4
|
+
interface StatusLineContext {
|
|
5
|
+
ui: {
|
|
6
|
+
theme: { fg: (name: ThemeColor, text: string) => string };
|
|
7
|
+
setStatus: (key: string, value: string | undefined) => void;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatLspStatusLine(state: LspExtensionState): string {
|
|
12
|
+
const active = new Set(state.runtimeManager.activeClients().map((client) => client.serverId)).size;
|
|
13
|
+
const total = Object.keys(state.config.catalog.servers).length;
|
|
14
|
+
const warnings = state.config.warnings.length;
|
|
15
|
+
return `LSP: ${active}/${total} servers${warnings > 0 ? `, ${warnings} warning(s)` : ""}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setLspStatusLine(ctx: StatusLineContext, state: LspExtensionState): void {
|
|
19
|
+
const color: ThemeColor = state.config.warnings.length > 0 ? "warning" : "accent";
|
|
20
|
+
ctx.ui.setStatus("lsp", ctx.ui.theme.fg(color, formatLspStatusLine(state)));
|
|
21
|
+
}
|