@akira-tl/forgerelay 0.3.7 → 0.4.1
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/CHANGELOG.md +30 -0
- package/capabilities/code-intelligence/GUIDE.md +11 -0
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +20 -0
- package/dist/config.js +1 -0
- package/dist/lsp/code-intelligence-error.js +8 -0
- package/dist/lsp/code-intelligence-types.js +1 -0
- package/dist/lsp/code-intelligence.js +595 -0
- package/dist/lsp/language-server-config.js +313 -0
- package/dist/lsp/normalization/hover.js +29 -0
- package/dist/lsp/position-encoding.js +88 -0
- package/dist/server.js +22 -2
- package/docs/configuration.md +65 -0
- package/docs/roadmap.md +15 -0
- package/package.json +4 -2
- package/scripts/debug/accept.mjs +15 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { access, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
export class LanguageServerConfigurationError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "LanguageServerConfigurationError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const definitionSchema = z.object({
|
|
15
|
+
enabled: z.boolean().optional(),
|
|
16
|
+
command: z.string().min(1).optional(),
|
|
17
|
+
args: z.array(z.string()).optional(),
|
|
18
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
19
|
+
languages: z.array(z.string().min(1)).min(1).optional(),
|
|
20
|
+
extensions: z.array(z.string().regex(/^\./)).min(1).optional(),
|
|
21
|
+
languageIdByExtension: z.record(z.string().regex(/^\./), z.string().min(1)).optional(),
|
|
22
|
+
projectMarkers: z.array(z.string().min(1)).optional(),
|
|
23
|
+
}).strict();
|
|
24
|
+
const configSchema = z.record(z.string().min(1), definitionSchema);
|
|
25
|
+
const BUILTIN_DEFINITIONS = {
|
|
26
|
+
typescript: {
|
|
27
|
+
executableCandidates: ["typescript-language-server"],
|
|
28
|
+
args: ["--stdio"],
|
|
29
|
+
languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
|
30
|
+
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
31
|
+
languageIdByExtension: {
|
|
32
|
+
".ts": "typescript",
|
|
33
|
+
".tsx": "typescriptreact",
|
|
34
|
+
".js": "javascript",
|
|
35
|
+
".jsx": "javascriptreact",
|
|
36
|
+
".mjs": "javascript",
|
|
37
|
+
".cjs": "javascript",
|
|
38
|
+
},
|
|
39
|
+
projectMarkers: ["tsconfig.json", "jsconfig.json", "package.json"],
|
|
40
|
+
},
|
|
41
|
+
pyright: {
|
|
42
|
+
executableCandidates: ["pyright-langserver"],
|
|
43
|
+
args: ["--stdio"],
|
|
44
|
+
languages: ["python"],
|
|
45
|
+
extensions: [".py", ".pyi"],
|
|
46
|
+
languageIdByExtension: { ".py": "python", ".pyi": "python" },
|
|
47
|
+
projectMarkers: ["pyrightconfig.json", "pyproject.toml", "setup.cfg", "setup.py"],
|
|
48
|
+
},
|
|
49
|
+
"rust-analyzer": {
|
|
50
|
+
executableCandidates: ["rust-analyzer"],
|
|
51
|
+
languages: ["rust"],
|
|
52
|
+
extensions: [".rs"],
|
|
53
|
+
languageIdByExtension: { ".rs": "rust" },
|
|
54
|
+
projectMarkers: ["Cargo.toml"],
|
|
55
|
+
},
|
|
56
|
+
gopls: {
|
|
57
|
+
executableCandidates: ["gopls"],
|
|
58
|
+
languages: ["go"],
|
|
59
|
+
extensions: [".go"],
|
|
60
|
+
languageIdByExtension: { ".go": "go" },
|
|
61
|
+
projectMarkers: ["go.work", "go.mod"],
|
|
62
|
+
},
|
|
63
|
+
clangd: {
|
|
64
|
+
executableCandidates: ["clangd"],
|
|
65
|
+
languages: ["c", "cpp", "objective-c", "objective-cpp"],
|
|
66
|
+
extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx", ".m", ".mm"],
|
|
67
|
+
languageIdByExtension: {
|
|
68
|
+
".c": "c",
|
|
69
|
+
".cc": "cpp",
|
|
70
|
+
".cpp": "cpp",
|
|
71
|
+
".cxx": "cpp",
|
|
72
|
+
".h": "cpp",
|
|
73
|
+
".hh": "cpp",
|
|
74
|
+
".hpp": "cpp",
|
|
75
|
+
".hxx": "cpp",
|
|
76
|
+
".m": "objective-c",
|
|
77
|
+
".mm": "objective-cpp",
|
|
78
|
+
},
|
|
79
|
+
projectMarkers: ["compile_commands.json", "compile_flags.txt", ".clangd"],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
export async function resolveLanguageProject(input) {
|
|
83
|
+
const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
|
|
84
|
+
const sourcePath = await resolveWorkspaceSourcePath(workspaceRoot, input.sourcePath);
|
|
85
|
+
const projectConfig = await loadProjectLanguageServerConfig(workspaceRoot);
|
|
86
|
+
const globalConfig = parseLanguageServerConfig(input.globalConfig ?? {}, "global ForgeRelay config");
|
|
87
|
+
const definitions = await effectiveDefinitions(globalConfig, projectConfig, input.env ?? process.env);
|
|
88
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
89
|
+
const candidates = [];
|
|
90
|
+
for (const definition of definitions) {
|
|
91
|
+
if (!definition.extensions.includes(extension))
|
|
92
|
+
continue;
|
|
93
|
+
const projectRoot = await findLanguageProjectRoot(workspaceRoot, dirname(sourcePath), definition.projectMarkers);
|
|
94
|
+
if (!projectRoot)
|
|
95
|
+
continue;
|
|
96
|
+
candidates.push({ definition, projectRoot });
|
|
97
|
+
}
|
|
98
|
+
if (candidates.length === 0) {
|
|
99
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `No available Language-server definition matches ${relative(workspaceRoot, sourcePath) || "."}.`);
|
|
100
|
+
}
|
|
101
|
+
const sourceRank = { builtin: 0, global: 1, project: 2 };
|
|
102
|
+
const highestRank = Math.max(...candidates.map((candidate) => sourceRank[candidate.definition.source]));
|
|
103
|
+
const highest = candidates.filter((candidate) => sourceRank[candidate.definition.source] === highestRank);
|
|
104
|
+
const deepestLength = Math.max(...highest.map((candidate) => candidate.projectRoot.length));
|
|
105
|
+
const nearest = highest.filter((candidate) => candidate.projectRoot.length === deepestLength);
|
|
106
|
+
if (nearest.length !== 1) {
|
|
107
|
+
throw new LanguageServerConfigurationError("code.configuration_ambiguous", `Multiple Language-server definitions match ${relative(workspaceRoot, sourcePath)} at the same priority: ${nearest.map((candidate) => candidate.definition.id).join(", ")}.`);
|
|
108
|
+
}
|
|
109
|
+
return nearest[0];
|
|
110
|
+
}
|
|
111
|
+
export async function loadProjectLanguageServerConfig(workspaceRoot) {
|
|
112
|
+
const path = join(workspaceRoot, ".forgerelay", "language-servers.json");
|
|
113
|
+
try {
|
|
114
|
+
return parseLanguageServerConfig(JSON.parse(await readFile(path, "utf8")), path);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (isMissingFile(error))
|
|
118
|
+
return {};
|
|
119
|
+
if (error instanceof LanguageServerConfigurationError)
|
|
120
|
+
throw error;
|
|
121
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
122
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Unable to load Language-server configuration at ${path}: ${reason}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function parseLanguageServerConfig(value, label) {
|
|
126
|
+
const parsed = configSchema.safeParse(value ?? {});
|
|
127
|
+
if (!parsed.success) {
|
|
128
|
+
const details = parsed.error.issues
|
|
129
|
+
.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
|
|
130
|
+
.join("; ");
|
|
131
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Invalid Language-server configuration in ${label}: ${details}`);
|
|
132
|
+
}
|
|
133
|
+
return parsed.data;
|
|
134
|
+
}
|
|
135
|
+
async function effectiveDefinitions(globalConfig, projectConfig, env) {
|
|
136
|
+
const ids = new Set([
|
|
137
|
+
...Object.keys(BUILTIN_DEFINITIONS),
|
|
138
|
+
...Object.keys(globalConfig),
|
|
139
|
+
...Object.keys(projectConfig),
|
|
140
|
+
]);
|
|
141
|
+
const definitions = [];
|
|
142
|
+
for (const id of ids) {
|
|
143
|
+
const builtin = BUILTIN_DEFINITIONS[id];
|
|
144
|
+
const global = globalConfig[id];
|
|
145
|
+
const project = projectConfig[id];
|
|
146
|
+
const source = project
|
|
147
|
+
? "project"
|
|
148
|
+
: global
|
|
149
|
+
? "global"
|
|
150
|
+
: "builtin";
|
|
151
|
+
const merged = {
|
|
152
|
+
id,
|
|
153
|
+
source,
|
|
154
|
+
...(builtin ?? {}),
|
|
155
|
+
...(global ?? {}),
|
|
156
|
+
...(project ?? {}),
|
|
157
|
+
env: {
|
|
158
|
+
...(builtin?.env ?? {}),
|
|
159
|
+
...(global?.env ?? {}),
|
|
160
|
+
...(project?.env ?? {}),
|
|
161
|
+
},
|
|
162
|
+
languageIdByExtension: {
|
|
163
|
+
...(builtin?.languageIdByExtension ?? {}),
|
|
164
|
+
...(global?.languageIdByExtension ?? {}),
|
|
165
|
+
...(project?.languageIdByExtension ?? {}),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
if (merged.enabled === false)
|
|
169
|
+
continue;
|
|
170
|
+
const languages = merged.languages ?? [];
|
|
171
|
+
const extensions = merged.extensions?.map((entry) => entry.toLowerCase()) ?? [];
|
|
172
|
+
if (languages.length === 0 || extensions.length === 0)
|
|
173
|
+
continue;
|
|
174
|
+
const languageIdByExtension = normalizeLanguageIds(id, merged, languages, extensions);
|
|
175
|
+
let command = merged.command;
|
|
176
|
+
if (!command && builtin?.executableCandidates) {
|
|
177
|
+
command = await findExecutable(builtin.executableCandidates, env);
|
|
178
|
+
if (!command)
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!command) {
|
|
182
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} requires a command.`);
|
|
183
|
+
}
|
|
184
|
+
const normalized = {
|
|
185
|
+
id,
|
|
186
|
+
command,
|
|
187
|
+
args: merged.args ?? [],
|
|
188
|
+
env: merged.env ?? {},
|
|
189
|
+
languages,
|
|
190
|
+
extensions,
|
|
191
|
+
languageIdByExtension,
|
|
192
|
+
projectMarkers: merged.projectMarkers ?? [],
|
|
193
|
+
source,
|
|
194
|
+
};
|
|
195
|
+
definitions.push({
|
|
196
|
+
...normalized,
|
|
197
|
+
fingerprint: createHash("sha256").update(JSON.stringify(normalized)).digest("hex"),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return definitions;
|
|
201
|
+
}
|
|
202
|
+
function normalizeLanguageIds(id, definition, languages, extensions) {
|
|
203
|
+
const mapping = Object.fromEntries(Object.entries(definition.languageIdByExtension ?? {})
|
|
204
|
+
.map(([extension, languageId]) => [extension.toLowerCase(), languageId]));
|
|
205
|
+
for (let index = 0; index < extensions.length; index += 1) {
|
|
206
|
+
const extension = extensions[index];
|
|
207
|
+
if (mapping[extension])
|
|
208
|
+
continue;
|
|
209
|
+
if (languages.length === 1) {
|
|
210
|
+
mapping[extension] = languages[0];
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (languages.length === extensions.length) {
|
|
214
|
+
mapping[extension] = languages[index];
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} must map extension ${extension} to a languageId when multiple language IDs do not align one-to-one with extensions.`);
|
|
218
|
+
}
|
|
219
|
+
for (const [extension, languageId] of Object.entries(mapping)) {
|
|
220
|
+
if (!extensions.includes(extension))
|
|
221
|
+
continue;
|
|
222
|
+
if (!languages.includes(languageId)) {
|
|
223
|
+
throw new LanguageServerConfigurationError("code.configuration_invalid", `Language-server definition ${id} maps ${extension} to unknown languageId ${languageId}.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return mapping;
|
|
227
|
+
}
|
|
228
|
+
async function findLanguageProjectRoot(workspaceRoot, startDirectory, markers) {
|
|
229
|
+
if (markers.length === 0)
|
|
230
|
+
return workspaceRoot;
|
|
231
|
+
let current = startDirectory;
|
|
232
|
+
while (isWithin(workspaceRoot, current)) {
|
|
233
|
+
for (const marker of markers) {
|
|
234
|
+
try {
|
|
235
|
+
await access(join(current, marker));
|
|
236
|
+
return current;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Try the next marker or parent directory.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (current === workspaceRoot)
|
|
243
|
+
break;
|
|
244
|
+
current = dirname(current);
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
async function canonicalWorkspaceRoot(inputPath) {
|
|
249
|
+
try {
|
|
250
|
+
return await realpath(resolve(inputPath));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence Workspace root does not exist: ${inputPath}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function resolveWorkspaceSourcePath(workspaceRoot, inputPath) {
|
|
257
|
+
const candidate = resolve(workspaceRoot, inputPath);
|
|
258
|
+
if (!isWithin(workspaceRoot, candidate)) {
|
|
259
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path must remain inside the Workspace: ${inputPath}`);
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const [canonicalRoot, canonicalCandidate] = await Promise.all([
|
|
263
|
+
realpath(workspaceRoot),
|
|
264
|
+
realpath(candidate),
|
|
265
|
+
]);
|
|
266
|
+
if (!isWithin(canonicalRoot, canonicalCandidate)) {
|
|
267
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path resolves outside the Workspace: ${inputPath}`);
|
|
268
|
+
}
|
|
269
|
+
return canonicalCandidate;
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error instanceof LanguageServerConfigurationError)
|
|
273
|
+
throw error;
|
|
274
|
+
throw new LanguageServerConfigurationError("code.language_service_unavailable", `Code-intelligence source path does not exist: ${inputPath}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function findExecutable(candidates, env) {
|
|
278
|
+
for (const candidate of candidates) {
|
|
279
|
+
if (isAbsolute(candidate)) {
|
|
280
|
+
if (await executable(candidate))
|
|
281
|
+
return candidate;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const pathEntries = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
285
|
+
const extensions = process.platform === "win32"
|
|
286
|
+
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
|
287
|
+
: [""];
|
|
288
|
+
for (const directory of pathEntries) {
|
|
289
|
+
for (const extension of extensions) {
|
|
290
|
+
const path = join(directory, process.platform === "win32" ? `${candidate}${extension}` : candidate);
|
|
291
|
+
if (await executable(path))
|
|
292
|
+
return path;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
async function executable(path) {
|
|
299
|
+
try {
|
|
300
|
+
await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function isWithin(root, candidate) {
|
|
308
|
+
const rel = relative(root, candidate);
|
|
309
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
310
|
+
}
|
|
311
|
+
function isMissingFile(error) {
|
|
312
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
313
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function normalizeHoverContents(contents) {
|
|
2
|
+
if (Array.isArray(contents)) {
|
|
3
|
+
if (contents.length === 1)
|
|
4
|
+
return normalizeMarkedString(contents[0]);
|
|
5
|
+
return {
|
|
6
|
+
contents: contents.map(renderMarkedString).join("\n\n"),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
if (isMarkupContent(contents)) {
|
|
10
|
+
return { contents: contents.value };
|
|
11
|
+
}
|
|
12
|
+
return normalizeMarkedString(contents);
|
|
13
|
+
}
|
|
14
|
+
function isMarkupContent(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && "kind" in value;
|
|
16
|
+
}
|
|
17
|
+
function normalizeMarkedString(value) {
|
|
18
|
+
if (typeof value === "string")
|
|
19
|
+
return { contents: value };
|
|
20
|
+
return {
|
|
21
|
+
contents: value.value,
|
|
22
|
+
language: value.language,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function renderMarkedString(value) {
|
|
26
|
+
if (typeof value === "string")
|
|
27
|
+
return value;
|
|
28
|
+
return `\`\`\`${value.language}\n${value.value}\n\`\`\``;
|
|
29
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PositionEncodingKind } from "vscode-languageserver-protocol";
|
|
2
|
+
import { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
3
|
+
export function lspPositionFromUser(text, line, column, encoding) {
|
|
4
|
+
if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
|
|
5
|
+
throw new CodeIntelligenceError("code.invalid_position", `Code-intelligence positions are 1-based positive integers; received line=${line}, column=${column}.`);
|
|
6
|
+
}
|
|
7
|
+
const lines = text.split(/\r?\n/);
|
|
8
|
+
const sourceLine = lines[line - 1];
|
|
9
|
+
if (sourceLine === undefined) {
|
|
10
|
+
throw new CodeIntelligenceError("code.invalid_position", `Line ${line} is outside the document (${lines.length} lines).`);
|
|
11
|
+
}
|
|
12
|
+
const codePoints = Array.from(sourceLine);
|
|
13
|
+
const codePointIndex = column - 1;
|
|
14
|
+
if (codePointIndex > codePoints.length) {
|
|
15
|
+
throw new CodeIntelligenceError("code.invalid_position", `Column ${column} is outside line ${line} (${codePoints.length + 1} valid insertion positions).`);
|
|
16
|
+
}
|
|
17
|
+
const prefix = codePoints.slice(0, codePointIndex).join("");
|
|
18
|
+
return {
|
|
19
|
+
line: line - 1,
|
|
20
|
+
character: encodedLength(prefix, encoding),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function wholeDocumentRange(text, encoding) {
|
|
24
|
+
const lines = text.split(/\r?\n/);
|
|
25
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
26
|
+
return {
|
|
27
|
+
start: { line: 0, character: 0 },
|
|
28
|
+
end: {
|
|
29
|
+
line: lastLine,
|
|
30
|
+
character: encodedLength(lines[lastLine] ?? "", encoding),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function rangeFromLsp(text, range, encoding) {
|
|
35
|
+
return {
|
|
36
|
+
start: positionFromLsp(text, range.start, encoding),
|
|
37
|
+
end: positionFromLsp(text, range.end, encoding),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function positionFromLsp(text, position, encoding) {
|
|
41
|
+
const lines = text.split(/\r?\n/);
|
|
42
|
+
const sourceLine = lines[position.line];
|
|
43
|
+
if (sourceLine === undefined) {
|
|
44
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned line ${position.line} outside a ${lines.length}-line document.`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
line: position.line + 1,
|
|
48
|
+
column: decodedCodePointOffset(sourceLine, position.character, encoding) + 1,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function encodedLength(text, encoding) {
|
|
52
|
+
if (encoding === PositionEncodingKind.UTF8)
|
|
53
|
+
return Buffer.byteLength(text, "utf8");
|
|
54
|
+
if (encoding === PositionEncodingKind.UTF32)
|
|
55
|
+
return Array.from(text).length;
|
|
56
|
+
return text.length;
|
|
57
|
+
}
|
|
58
|
+
function decodedCodePointOffset(text, encodedOffset, encoding) {
|
|
59
|
+
if (!Number.isInteger(encodedOffset) || encodedOffset < 0) {
|
|
60
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned invalid character offset ${encodedOffset}.`);
|
|
61
|
+
}
|
|
62
|
+
if (encoding === PositionEncodingKind.UTF32) {
|
|
63
|
+
if (encodedOffset > Array.from(text).length) {
|
|
64
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
|
|
65
|
+
}
|
|
66
|
+
return encodedOffset;
|
|
67
|
+
}
|
|
68
|
+
if (encoding === PositionEncodingKind.UTF16) {
|
|
69
|
+
if (encodedOffset > text.length) {
|
|
70
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
|
|
71
|
+
}
|
|
72
|
+
return Array.from(text.slice(0, encodedOffset)).length;
|
|
73
|
+
}
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
let codePoints = 0;
|
|
76
|
+
for (const character of text) {
|
|
77
|
+
if (bytes === encodedOffset)
|
|
78
|
+
return codePoints;
|
|
79
|
+
bytes += Buffer.byteLength(character, "utf8");
|
|
80
|
+
codePoints += 1;
|
|
81
|
+
if (bytes > encodedOffset) {
|
|
82
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned UTF-8 offset ${encodedOffset} inside a code point.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (bytes === encodedOffset)
|
|
86
|
+
return codePoints;
|
|
87
|
+
throw new CodeIntelligenceError("code.invalid_position", `Language server returned character offset ${encodedOffset} outside its line.`);
|
|
88
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { deletePath, renamePath } from "./file-mutations.js";
|
|
|
21
21
|
import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, } from "./artifact-tools.js";
|
|
22
22
|
import { ArtifactError } from "./artifact-error.js";
|
|
23
23
|
import { loadConfig } from "./config.js";
|
|
24
|
+
import { CodeIntelligenceError, CodeIntelligenceManager } from "./lsp/code-intelligence.js";
|
|
24
25
|
import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
25
26
|
import { checkHookConfiguration } from "./hook-cli.js";
|
|
26
27
|
import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
@@ -743,7 +744,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
743
744
|
});
|
|
744
745
|
});
|
|
745
746
|
}
|
|
746
|
-
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
|
|
747
|
+
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence) {
|
|
747
748
|
const toolDescriptions = buildToolDescriptions(config);
|
|
748
749
|
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
|
|
749
750
|
const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
@@ -751,6 +752,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
751
752
|
const reviewChangesAvailable = config.widgets === "changes";
|
|
752
753
|
const capabilityRegistry = createCapabilityRegistry({
|
|
753
754
|
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
755
|
+
codeIntelligence: {
|
|
756
|
+
available: true,
|
|
757
|
+
run: async (input, context) => {
|
|
758
|
+
try {
|
|
759
|
+
return {
|
|
760
|
+
value: await codeIntelligence.run(context.workspaceRoot, input),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
if (error instanceof CodeIntelligenceError) {
|
|
765
|
+
throw new CapabilityError(error.code, error.message);
|
|
766
|
+
}
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
},
|
|
754
771
|
reviewChanges: {
|
|
755
772
|
available: reviewChangesAvailable,
|
|
756
773
|
unavailableReason: reviewChangesAvailable
|
|
@@ -2108,6 +2125,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2108
2125
|
const workspaces = new WorkspaceRegistry(config, workspaceStore);
|
|
2109
2126
|
const reviewCheckpoints = createReviewCheckpointManager();
|
|
2110
2127
|
const processSessions = new ProcessManager();
|
|
2128
|
+
const codeIntelligence = new CodeIntelligenceManager(config);
|
|
2111
2129
|
const localAgentProviders = config.subagents
|
|
2112
2130
|
? getLocalAgentProviderAvailabilitySnapshot()
|
|
2113
2131
|
: [];
|
|
@@ -2154,6 +2172,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2154
2172
|
processesCompleted: processStats.completed,
|
|
2155
2173
|
cachedWorkspaces: workspaces.cachedWorkspaceCount,
|
|
2156
2174
|
reviewStates: reviewCheckpoints.stateCount,
|
|
2175
|
+
languageServices: codeIntelligence.size,
|
|
2157
2176
|
});
|
|
2158
2177
|
};
|
|
2159
2178
|
const transportCleanupTimer = setInterval(() => {
|
|
@@ -2276,7 +2295,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2276
2295
|
});
|
|
2277
2296
|
}
|
|
2278
2297
|
};
|
|
2279
|
-
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters);
|
|
2298
|
+
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence);
|
|
2280
2299
|
await server.connect(transport);
|
|
2281
2300
|
}
|
|
2282
2301
|
else {
|
|
@@ -2306,6 +2325,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2306
2325
|
const results = await transports.closeAll();
|
|
2307
2326
|
logTransportCloseResults("server_shutdown", results);
|
|
2308
2327
|
processSessions.shutdown();
|
|
2328
|
+
await codeIntelligence.shutdown();
|
|
2309
2329
|
oauthProvider.close();
|
|
2310
2330
|
workspaceStore.close?.();
|
|
2311
2331
|
})();
|
package/docs/configuration.md
CHANGED
|
@@ -151,6 +151,71 @@ snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integrati
|
|
|
151
151
|
or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
|
|
152
152
|
force a Host to invalidate its cached schema.
|
|
153
153
|
|
|
154
|
+
### LSP code intelligence
|
|
155
|
+
|
|
156
|
+
ForgeRelay advertises `code.intelligence` through the Capability Gateway; it does
|
|
157
|
+
not add language-specific top-level MCP tools. ForgeRelay 0.4.1 supports
|
|
158
|
+
`definition` and `hover`. Both operations accept the same workspace-relative source
|
|
159
|
+
position. Hover results normalize plaintext, Markdown, and supported legacy LSP
|
|
160
|
+
payloads into one `contents` string with optional `language` and normalized `range`
|
|
161
|
+
metadata. Language Servers are external dependencies: ForgeRelay may discover an
|
|
162
|
+
executable already installed on the machine, but it never downloads or installs one
|
|
163
|
+
automatically.
|
|
164
|
+
|
|
165
|
+
Effective Language-server definitions resolve in this order:
|
|
166
|
+
|
|
167
|
+
1. project configuration in `.forgerelay/language-servers.json`;
|
|
168
|
+
2. global definitions from the `languageServers` object in
|
|
169
|
+
`~/.forgerelay/config.json`;
|
|
170
|
+
3. built-in discovery for known executables.
|
|
171
|
+
|
|
172
|
+
A project configuration file is an object keyed by definition name. Definitions
|
|
173
|
+
use structured process launch and never go through a shell:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"typescript": {
|
|
178
|
+
"command": "typescript-language-server",
|
|
179
|
+
"args": ["--stdio"],
|
|
180
|
+
"env": {},
|
|
181
|
+
"languages": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
|
182
|
+
"extensions": [".ts", ".tsx", ".js", ".jsx"],
|
|
183
|
+
"languageIdByExtension": {
|
|
184
|
+
".ts": "typescript",
|
|
185
|
+
".tsx": "typescriptreact",
|
|
186
|
+
".js": "javascript",
|
|
187
|
+
".jsx": "javascriptreact"
|
|
188
|
+
},
|
|
189
|
+
"projectMarkers": ["tsconfig.json", "jsconfig.json"]
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Global configuration uses the same definition shape under `languageServers`:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"languageServers": {
|
|
199
|
+
"typescript": {
|
|
200
|
+
"command": "/absolute/path/to/typescript-language-server",
|
|
201
|
+
"args": ["--stdio"]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Explicit configuration can set `"enabled": false` to suppress the matching
|
|
208
|
+
built-in definition. Project values override global values, and both override
|
|
209
|
+
built-in defaults. ForgeRelay resolves a Language project by walking ancestors of
|
|
210
|
+
the requested source file according to that definition's `projectMarkers`; it does
|
|
211
|
+
not recursively scan the Workspace.
|
|
212
|
+
|
|
213
|
+
Code-intelligence input positions are 1-based line and 1-based Unicode code-point
|
|
214
|
+
column values. The Workspace filesystem is the only v1 document source of truth.
|
|
215
|
+
Definition results may point outside the Workspace and are then marked
|
|
216
|
+
`external: true`; this is informational only and does not expand allowed roots or
|
|
217
|
+
file-tool authority.
|
|
218
|
+
|
|
154
219
|
`rename` is the canonical move/rename primitive for files and directories; there
|
|
155
220
|
is no separate `move` MCP tool.
|
|
156
221
|
|
package/docs/roadmap.md
CHANGED
|
@@ -225,6 +225,21 @@ Candidate servers include `typescript-language-server`/tsserver, Pyright,
|
|
|
225
225
|
`rust-analyzer`, `gopls`, and `clangd`, but ForgeRelay should treat server
|
|
226
226
|
commands/configuration as external dependencies.
|
|
227
227
|
|
|
228
|
+
0.4 is delivered as independently published patch releases rather than one large
|
|
229
|
+
0.4.0 batch. Every boundary must complete local acceptance, update release metadata,
|
|
230
|
+
create and push its matching tag, pass cloud CI, publish npm and the GitHub Release,
|
|
231
|
+
and verify publication before work begins on the next boundary:
|
|
232
|
+
|
|
233
|
+
- **0.4.0** — Language-service foundation plus the complete `definition` tracer bullet;
|
|
234
|
+
- **0.4.1** — hover/type information;
|
|
235
|
+
- **0.4.2** — references and bounded semantic-location results;
|
|
236
|
+
- **0.4.3** — hierarchical document symbols and bounded workspace symbols;
|
|
237
|
+
- **0.4.4** — push/pull diagnostics and Diagnostic snapshots;
|
|
238
|
+
- **0.4.5** — cancellation, deadlines, crash recovery/config invalidation, concurrency,
|
|
239
|
+
and full Language-service resource/lifecycle hardening;
|
|
240
|
+
- **0.4.6** — optional real-server interoperability, cross-platform checks, fresh Host
|
|
241
|
+
acceptance, documentation, and final LSP v1 closure.
|
|
242
|
+
|
|
228
243
|
## 0.5 — First-class subagent MCP
|
|
229
244
|
|
|
230
245
|
ForgeRelay already owns provider adapters and resumable local agent sessions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"debug:accept": "node scripts/debug/accept.mjs",
|
|
43
43
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
44
44
|
"start": "node dist/cli.js serve",
|
|
45
|
-
"test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
45
|
+
"test": "tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
|
|
46
46
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
47
|
"release:check": "node scripts/release-version.mjs check",
|
|
48
48
|
"release:tag-check": "node scripts/release-version.mjs tag",
|
|
@@ -79,6 +79,8 @@
|
|
|
79
79
|
"react": "^19.2.6",
|
|
80
80
|
"react-dom": "^19.2.6",
|
|
81
81
|
"semver": "^7.8.4",
|
|
82
|
+
"vscode-jsonrpc": "^9.0.1",
|
|
83
|
+
"vscode-languageserver-protocol": "^3.18.2",
|
|
82
84
|
"yaml": "^2.9.0",
|
|
83
85
|
"zod": "^4.4.3"
|
|
84
86
|
},
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -244,6 +244,7 @@ try {
|
|
|
244
244
|
"process.lifecycle",
|
|
245
245
|
"hooks.lifecycle",
|
|
246
246
|
"capability-guides.read",
|
|
247
|
+
"code.intelligence",
|
|
247
248
|
...(process.platform === "linux" ? ["artifact.native-download"] : []),
|
|
248
249
|
"ui.mcp-app",
|
|
249
250
|
"review.changes",
|
|
@@ -253,6 +254,7 @@ try {
|
|
|
253
254
|
assert.deepEqual(capabilityCatalog.map((entry) => entry.name), [
|
|
254
255
|
"hooks.check",
|
|
255
256
|
"review.changes",
|
|
257
|
+
"code.intelligence",
|
|
256
258
|
...(process.platform === "linux" ? ["artifact.download"] : []),
|
|
257
259
|
]);
|
|
258
260
|
assert.equal(capabilityCatalog[0].available, true);
|
|
@@ -323,6 +325,18 @@ try {
|
|
|
323
325
|
assert.equal(describedCapability.isError, undefined);
|
|
324
326
|
assert.equal(describedCapability.structuredContent.capability.guide.name, "lifecycle-hooks");
|
|
325
327
|
assert.equal(describedCapability.structuredContent.capability.inputSchema.type, "object");
|
|
328
|
+
const describedCodeIntelligence = callTool(oauth.accessToken, sessionId, 88, "capability", {
|
|
329
|
+
workspaceId,
|
|
330
|
+
name: "code.intelligence",
|
|
331
|
+
action: "describe",
|
|
332
|
+
});
|
|
333
|
+
assert.equal(describedCodeIntelligence.isError, undefined);
|
|
334
|
+
assert.equal(describedCodeIntelligence.structuredContent.capability.guide.name, "code-intelligence");
|
|
335
|
+
assert.equal(describedCodeIntelligence.structuredContent.capability.inputSchema.type, "object");
|
|
336
|
+
assert.deepEqual(
|
|
337
|
+
describedCodeIntelligence.structuredContent.capability.inputSchema.properties.operation.enum,
|
|
338
|
+
["definition", "hover"],
|
|
339
|
+
);
|
|
326
340
|
if (process.platform === "linux") {
|
|
327
341
|
const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
|
|
328
342
|
workspaceId,
|
|
@@ -350,6 +364,7 @@ try {
|
|
|
350
364
|
"artifacts-review",
|
|
351
365
|
"host-integration",
|
|
352
366
|
"shell-processes",
|
|
367
|
+
"code-intelligence",
|
|
353
368
|
]);
|
|
354
369
|
const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
|
|
355
370
|
workspaceId,
|