@narumitw/pi-lsp 0.14.0 → 0.15.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/README.md +5 -3
- package/package.json +1 -1
- package/src/adapters.ts +137 -14
- package/src/pi-lsp.ts +20 -3
package/README.md
CHANGED
|
@@ -41,12 +41,14 @@ If no config is provided, pi-lsp ships compatible defaults for Biome, ty, and Ru
|
|
|
41
41
|
Custom config can be supplied in one of these locations:
|
|
42
42
|
|
|
43
43
|
1. `PI_LSP_CONFIG` as inline JSON or a path to a JSON file
|
|
44
|
-
2. `<workspace>/.pi/lsp.json`
|
|
45
|
-
3. `~/.pi/agent/lsp.json`
|
|
44
|
+
2. `<workspace>/.pi/pi-lsp.json`
|
|
45
|
+
3. `~/.pi/agent/pi-lsp.json`
|
|
46
46
|
|
|
47
47
|
`PI_LSP_CONFIG` only accepts JSON or a JSON file path; JavaScript and TypeScript config files are not evaluated.
|
|
48
48
|
|
|
49
|
-
`lsp.json`
|
|
49
|
+
Compatibility: a user-scoped legacy `lsp.json` is migrated automatically. A project-scoped legacy `.pi/lsp.json` remains readable with a warning but is not renamed automatically, so the extension never modifies a repository working tree. New paths take precedence when both names exist.
|
|
50
|
+
|
|
51
|
+
`pi-lsp.json` can be a plain object keyed by server name:
|
|
50
52
|
|
|
51
53
|
```json
|
|
52
54
|
{
|
package/package.json
CHANGED
package/src/adapters.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
linkSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
2
12
|
import os from "node:os";
|
|
3
13
|
import path from "node:path";
|
|
4
14
|
import process from "node:process";
|
|
@@ -75,23 +85,132 @@ export function loadConfig(cwd = process.cwd()): LspConfig {
|
|
|
75
85
|
return configured ?? { servers: DEFAULT_SERVER_CONFIGS };
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
let pendingConfigNotice: string | undefined;
|
|
89
|
+
|
|
78
90
|
function loadConfiguredConfig(cwd: string): LspConfig | undefined {
|
|
91
|
+
pendingConfigNotice = undefined;
|
|
79
92
|
const rawConfig = process.env.PI_LSP_CONFIG?.trim();
|
|
80
93
|
if (rawConfig) return parseConfigSource(rawConfig, cwd, "PI_LSP_CONFIG");
|
|
81
94
|
|
|
82
|
-
const projectConfig = path.join(cwd, ".pi", "lsp.json");
|
|
83
|
-
|
|
95
|
+
const projectConfig = path.join(cwd, ".pi", "pi-lsp.json");
|
|
96
|
+
const legacyProjectConfig = path.join(cwd, ".pi", "lsp.json");
|
|
97
|
+
if (existsSync(projectConfig)) {
|
|
98
|
+
if (existsSync(legacyProjectConfig)) {
|
|
99
|
+
pendingConfigNotice = ".pi/lsp.json ignored because .pi/pi-lsp.json takes precedence.";
|
|
100
|
+
}
|
|
101
|
+
return parseConfigFile(projectConfig);
|
|
102
|
+
}
|
|
103
|
+
if (existsSync(legacyProjectConfig)) {
|
|
104
|
+
pendingConfigNotice =
|
|
105
|
+
"Using legacy .pi/lsp.json. Rename it to .pi/pi-lsp.json; the repository file was not modified automatically.";
|
|
106
|
+
return parseConfigFile(legacyProjectConfig);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const userConfig = path.join(getAgentDir(), "pi-lsp.json");
|
|
110
|
+
const legacyUserConfig = path.join(getAgentDir(), "lsp.json");
|
|
111
|
+
if (existsSync(userConfig)) {
|
|
112
|
+
if (existsSync(legacyUserConfig)) {
|
|
113
|
+
pendingConfigNotice = "lsp.json ignored because pi-lsp.json takes precedence.";
|
|
114
|
+
}
|
|
115
|
+
return parseConfigFile(userConfig);
|
|
116
|
+
}
|
|
117
|
+
if (!existsSync(legacyUserConfig)) return undefined;
|
|
118
|
+
|
|
119
|
+
const legacyContents = readFileSync(legacyUserConfig, "utf8");
|
|
120
|
+
const legacy = normalizeConfig(JSON.parse(legacyContents), legacyUserConfig);
|
|
121
|
+
let installedIdentity: FileIdentity;
|
|
122
|
+
try {
|
|
123
|
+
installedIdentity = installFileExclusively(
|
|
124
|
+
userConfig,
|
|
125
|
+
legacyContents,
|
|
126
|
+
statSync(legacyUserConfig).mode & 0o777,
|
|
127
|
+
);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (existsSync(userConfig)) {
|
|
130
|
+
pendingConfigNotice = "lsp.json ignored because pi-lsp.json was created concurrently.";
|
|
131
|
+
return parseConfigFile(userConfig);
|
|
132
|
+
}
|
|
133
|
+
pendingConfigNotice = `LSP config migration failed: ${formatError(error)}. The legacy file was used for this session.`;
|
|
134
|
+
return legacy;
|
|
135
|
+
}
|
|
136
|
+
if (!fileContentsEqual(legacyUserConfig, legacyContents)) {
|
|
137
|
+
if (removeFileIfIdentityMatches(userConfig, installedIdentity, legacyContents)) {
|
|
138
|
+
pendingConfigNotice =
|
|
139
|
+
"lsp.json changed during migration; the stale pi-lsp.json snapshot was removed and the legacy file was used for this session.";
|
|
140
|
+
} else {
|
|
141
|
+
pendingConfigNotice =
|
|
142
|
+
"lsp.json changed during migration, but pi-lsp.json was replaced concurrently and takes precedence on the next load.";
|
|
143
|
+
}
|
|
144
|
+
return legacy;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
rmSync(legacyUserConfig);
|
|
148
|
+
pendingConfigNotice = "LSP config migrated from lsp.json to pi-lsp.json.";
|
|
149
|
+
} catch (error) {
|
|
150
|
+
pendingConfigNotice = `LSP config migrated to pi-lsp.json, but lsp.json could not be removed: ${formatError(error)}.`;
|
|
151
|
+
}
|
|
152
|
+
return legacy;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type FileIdentity = { dev: number; ino: number };
|
|
84
156
|
|
|
85
|
-
|
|
86
|
-
|
|
157
|
+
function installFileExclusively(filePath: string, contents: string, mode: number): FileIdentity {
|
|
158
|
+
const tempFile = path.join(path.dirname(filePath), `.pi-lsp.json.${randomUUID()}.tmp`);
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(tempFile, contents, { encoding: "utf8", flag: "wx", mode });
|
|
161
|
+
chmodSync(tempFile, mode);
|
|
162
|
+
const identity = lstatSync(tempFile);
|
|
163
|
+
linkSync(tempFile, filePath);
|
|
164
|
+
return { dev: identity.dev, ino: identity.ino };
|
|
165
|
+
} finally {
|
|
166
|
+
try {
|
|
167
|
+
rmSync(tempFile, { force: true });
|
|
168
|
+
} catch {
|
|
169
|
+
// Preserve the migration result if best-effort temp cleanup fails.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function removeFileIfIdentityMatches(
|
|
175
|
+
filePath: string,
|
|
176
|
+
expected: FileIdentity,
|
|
177
|
+
expectedContents: string,
|
|
178
|
+
) {
|
|
179
|
+
try {
|
|
180
|
+
const current = lstatSync(filePath);
|
|
181
|
+
if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
|
|
182
|
+
if (readFileSync(filePath, "utf8") !== expectedContents) return false;
|
|
183
|
+
rmSync(filePath);
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
87
189
|
|
|
88
|
-
|
|
190
|
+
function fileContentsEqual(filePath: string, expected: string) {
|
|
191
|
+
try {
|
|
192
|
+
return readFileSync(filePath, "utf8") === expected;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function consumeLspConfigNotice() {
|
|
199
|
+
const notice = pendingConfigNotice;
|
|
200
|
+
pendingConfigNotice = undefined;
|
|
201
|
+
return notice;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatError(error: unknown) {
|
|
205
|
+
return error instanceof Error ? error.message : String(error);
|
|
89
206
|
}
|
|
90
207
|
|
|
91
208
|
function parseConfigSource(source: string, cwd: string, label: string): LspConfig {
|
|
92
209
|
if (source.startsWith("{")) return normalizeConfig(JSON.parse(source), label);
|
|
93
210
|
const expandedSource = expandHome(source);
|
|
94
|
-
const filePath = path.isAbsolute(expandedSource)
|
|
211
|
+
const filePath = path.isAbsolute(expandedSource)
|
|
212
|
+
? expandedSource
|
|
213
|
+
: path.resolve(cwd, expandedSource);
|
|
95
214
|
return parseConfigFile(filePath);
|
|
96
215
|
}
|
|
97
216
|
|
|
@@ -115,7 +234,9 @@ function normalizeConfig(value: unknown, label: string): LspConfig {
|
|
|
115
234
|
const timeout = normalizeTimeout(value.timeout, label);
|
|
116
235
|
const servers = value.servers;
|
|
117
236
|
if (!isRecord(servers) || Array.isArray(servers)) {
|
|
118
|
-
throw new Error(
|
|
237
|
+
throw new Error(
|
|
238
|
+
`${label}.servers must be a JSON object mapping server names to LSP server config.`,
|
|
239
|
+
);
|
|
119
240
|
}
|
|
120
241
|
return { timeout, servers: normalizeServerMap(servers, `${label}.servers`) };
|
|
121
242
|
}
|
|
@@ -128,14 +249,13 @@ function normalizeConfig(value: unknown, label: string): LspConfig {
|
|
|
128
249
|
}
|
|
129
250
|
|
|
130
251
|
function normalizeServerMap(value: Record<string, unknown>, label: string) {
|
|
131
|
-
return Object.entries(value).map(([name, server]) =>
|
|
252
|
+
return Object.entries(value).map(([name, server]) =>
|
|
253
|
+
normalizeServer(name, server, `${label}.${name}`),
|
|
254
|
+
);
|
|
132
255
|
}
|
|
133
256
|
|
|
134
257
|
function isServerEntry(value: unknown) {
|
|
135
|
-
return (
|
|
136
|
-
isRecord(value) &&
|
|
137
|
-
(Array.isArray(value.command) || Array.isArray(value.extensions))
|
|
138
|
-
);
|
|
258
|
+
return isRecord(value) && (Array.isArray(value.command) || Array.isArray(value.extensions));
|
|
139
259
|
}
|
|
140
260
|
|
|
141
261
|
function normalizeServer(name: string, value: unknown, label: string): InternalLspServer {
|
|
@@ -198,7 +318,10 @@ const LANGUAGE_IDS: Record<string, string> = {
|
|
|
198
318
|
};
|
|
199
319
|
|
|
200
320
|
function commandFromEnvName(name: string): string {
|
|
201
|
-
return name
|
|
321
|
+
return name
|
|
322
|
+
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
323
|
+
.replace(/^_+|_+$/g, "")
|
|
324
|
+
.toUpperCase();
|
|
202
325
|
}
|
|
203
326
|
|
|
204
327
|
function envName(name: string, suffix: "COMMAND") {
|
package/src/pi-lsp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { loadRuntime } from "./adapters.js";
|
|
3
|
+
import { consumeLspConfigNotice, loadRuntime } from "./adapters.js";
|
|
4
4
|
import { commandExists, commandFromEnv } from "./command.js";
|
|
5
5
|
import { resolveRoot } from "./files.js";
|
|
6
6
|
import { selectDiagnosticRoutes, selectFixRoute } from "./routes.js";
|
|
@@ -133,13 +133,26 @@ export default function lsp(pi: ExtensionAPI) {
|
|
|
133
133
|
pi.registerCommand("lsp", {
|
|
134
134
|
description: "Show shared LSP extension configuration",
|
|
135
135
|
handler: async (_args, ctx) => {
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
try {
|
|
137
|
+
const { adapters } = loadRuntime(ctx.cwd);
|
|
138
|
+
const notice = consumeLspConfigNotice();
|
|
139
|
+
if (notice) ctx.ui.notify(notice, "warning");
|
|
140
|
+
ctx.ui.notify(buildStatusMessage(adapters, ctx.cwd), statusLevel(adapters, ctx.cwd));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
ctx.ui.notify(`LSP config ignored: ${formatError(error)}`, "warning");
|
|
143
|
+
}
|
|
138
144
|
},
|
|
139
145
|
});
|
|
140
146
|
|
|
141
147
|
pi.on("session_start", (_event, ctx) => {
|
|
142
148
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
149
|
+
try {
|
|
150
|
+
loadRuntime(ctx.cwd);
|
|
151
|
+
const notice = consumeLspConfigNotice();
|
|
152
|
+
if (notice) ctx.ui.notify(notice, "warning");
|
|
153
|
+
} catch (error) {
|
|
154
|
+
ctx.ui.notify(`LSP config ignored: ${formatError(error)}`, "warning");
|
|
155
|
+
}
|
|
143
156
|
});
|
|
144
157
|
|
|
145
158
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
@@ -147,6 +160,10 @@ export default function lsp(pi: ExtensionAPI) {
|
|
|
147
160
|
});
|
|
148
161
|
}
|
|
149
162
|
|
|
163
|
+
function formatError(error: unknown) {
|
|
164
|
+
return error instanceof Error ? error.message : String(error);
|
|
165
|
+
}
|
|
166
|
+
|
|
150
167
|
function textFromResult(result: { content?: Array<{ type?: string; text?: string }> }) {
|
|
151
168
|
return result.content?.find((item) => item.type === "text")?.text ?? "";
|
|
152
169
|
}
|