@akira-tl/forgerelay 0.4.3 → 0.4.5
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 +25 -0
- package/capabilities/code-intelligence/GUIDE.md +9 -1
- package/dist/capability-registry.js +7 -2
- package/dist/logger.js +11 -1
- package/dist/lsp/code-intelligence.js +125 -93
- package/dist/lsp/normalization/diagnostics.js +29 -0
- package/dist/lsp/runtime/diagnostic-snapshots.js +113 -0
- package/dist/lsp/runtime/document-synchronizer.js +102 -0
- package/dist/lsp/runtime/manager.js +207 -20
- package/dist/lsp/runtime/semantic-requests.js +116 -0
- package/dist/lsp/test-support/server-fixture.js +1 -1
- package/dist/server.js +27 -6
- package/docs/configuration.md +28 -5
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.5] - 2026-08-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added Host-to-LSP cancellation propagation, bounded per-service semantic concurrency/queueing, and stable request timeout/cancellation/capacity errors without exposing arbitrary Agent-controlled timeout values.
|
|
12
|
+
- Added aggregate Language-service runtime telemetry for service/process/request/document/diagnostic/stderr retention without logging source contents or paths.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Language services now retry one unexpected server crash, enter a bounded cooldown after repeated crashes, and invalidate only affected services when the effective server-definition fingerprint changes.
|
|
17
|
+
- Language-service lifecycle now distinguishes truly idle services from cancellation-ignoring requests, evicts only the least-recently-used safe idle service at capacity, shares one service across logical workspaces on the same physical project, and releases managed-worktree services before finalization.
|
|
18
|
+
- Idle shutdown and repeated open/query/config/crash cycles now explicitly release and bound server processes, synchronized documents, diagnostic snapshots, request state, stderr tails, and service counts.
|
|
19
|
+
|
|
20
|
+
## [0.4.4] - 2026-08-11
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Added `code.intelligence` `diagnostics` with one normalized Agent-facing contract for traditional push diagnostics and LSP 3.17 pull diagnostics.
|
|
25
|
+
- Added bounded latest Diagnostic snapshots with per-document freshness/version metadata, replacement/clear semantics, and no historical accumulation.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Pull diagnostics are preferred when a Language server advertises `diagnosticProvider`; ForgeRelay sends the previous `resultId`, handles `unchanged` reports, and keeps pull state independent from asynchronous push snapshots on mixed-capability servers.
|
|
30
|
+
- Diagnostic collections reuse the 100 default / 1000 hard request limit while runtime caches independently bound document count and retained diagnostics per document. Filesystem synchronization makes stale push snapshots explicit and pull reports fresh against the synchronized document version.
|
|
31
|
+
|
|
7
32
|
## [0.4.3] - 2026-08-11
|
|
8
33
|
|
|
9
34
|
### Added
|
|
@@ -4,12 +4,20 @@ Use the `code.intelligence` Capability for read-only semantic code navigation ba
|
|
|
4
4
|
|
|
5
5
|
ForgeRelay does not install Language servers. It discovers supported executables when available and accepts explicit definitions from the global ForgeRelay config or `<workspace>/.forgerelay/language-servers.json`. Project definitions override global definitions, and global definitions override built-in discovery. An explicit definition may disable discovery with `enabled: false`.
|
|
6
6
|
|
|
7
|
-
ForgeRelay 0.4.
|
|
7
|
+
ForgeRelay 0.4.4 adds `diagnostics` alongside `definition`, `hover`, `references`, `documentSymbols`, and `workspaceSymbols`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
|
|
8
8
|
|
|
9
9
|
Code-intelligence results use ForgeRelay-owned shapes rather than raw LSP wire types. Definition returns normalized locations. Hover returns one `contents` string, an optional legacy `language`, and an optional ForgeRelay-normalized `range`; plaintext, Markdown `MarkupContent`, and supported legacy `MarkedString` payloads are normalized before reaching the Agent. References uses the same normalized location shape, defaults to `limit: 100`, and accepts limits up to 1000. Its result reports `returned`, `truncated`, and `total` when the complete Language-server response makes the total known.
|
|
10
10
|
|
|
11
11
|
Document symbols preserve hierarchical server responses as a tree and keep flat `SymbolInformation` responses flat. Names, stable symbol-kind names, ranges, selection ranges, details, container names, and children are normalized without exposing LSP union types. Workspace symbols are always returned as a flat list with stable symbol metadata and normalized locations. Symbol limits use the same default 100 / hard maximum 1000 collection budget; document-symbol limits count total tree nodes, while workspace-symbol results report the server response total directly when known.
|
|
12
12
|
|
|
13
|
+
Diagnostics use the same normalized result contract for push and pull providers. When a Language server advertises LSP pull diagnostics, ForgeRelay prefers pull, supplies the previous `resultId` when available, and treats an `unchanged` report as a refresh of the same bounded snapshot for the newly synchronized filesystem version. Otherwise ForgeRelay uses traditional `publishDiagnostics` snapshots. Push diagnostics retain only the latest snapshot for each synchronized document: no diagnostic history is accumulated. A result reports `provider`, bounded normalized diagnostics, `returned`/`truncated`/`total`, and `freshness`. `freshness.state` distinguishes fresh, stale, missing, and unknown snapshots; filesystem changes advance ForgeRelay's synchronized document version, making an older push snapshot detectably stale until the Language server publishes a replacement. Pull and push state are bounded independently so a mixed-capability server cannot overwrite pull `resultId` state with an asynchronous push. All diagnostic state is released with the Language service.
|
|
14
|
+
|
|
13
15
|
Semantic locations may identify External code locations outside the Workspace, but that does not expand ForgeRelay's allowed roots or grant the file tools permission to read those paths.
|
|
14
16
|
|
|
15
17
|
Language-server definitions use structured process configuration rather than shell command strings. A project configuration entry may contain `command`, `args`, `env`, `languages`, `extensions`, `languageIdByExtension`, `projectMarkers`, and `enabled` fields. Use `languageIdByExtension` when one server definition covers multiple language IDs whose extensions do not map one-to-one by array position. The server command is launched directly without a shell.
|
|
18
|
+
|
|
19
|
+
Semantic Language-server requests use an internal bounded deadline; the Agent contract does not expose arbitrary timeout controls. Host cancellation is propagated to in-flight LSP requests, and ForgeRelay bounds both per-service semantic concurrency and queued work. Document synchronization is serialized before semantic requests cross the concurrency gate, while independent semantic reads may execute concurrently after that barrier. A server that ignores cancellation keeps its underlying request slot occupied until it actually settles, preventing repeated cancellations from creating an unbounded set of pending JSON-RPC requests.
|
|
20
|
+
|
|
21
|
+
An unexpected Language-server process exit fails its pending JSON-RPC work immediately instead of waiting for the semantic deadline. ForgeRelay discards that service and retries the current semantic operation at most once. A second consecutive crash for the same Language project/server-definition fingerprint enters a short cooldown before another process may be started; a successful retry clears the crash state. Effective server definitions are fingerprinted, so a changed project/global/built-in definition selects a new service identity on the next code-intelligence resolution and retires only an idle service with the same project root/server id but the old fingerprint. ForgeRelay does not add a recursive config watcher.
|
|
22
|
+
|
|
23
|
+
Language services are shared by physical Language project identity rather than logical Workspace id, so multiple conversations over the same checkout do not multiply server processes. Idle services are reclaimed after a bounded TTL, and the global service cap evicts the least-recently-used truly idle service; a request that has returned to the Host but whose server ignored cancellation still counts as active until the underlying LSP request settles. Managed-worktree finalization releases idle Language services rooted in that worktree before removing it and refuses finalization while semantic work is still active. Debug runtime telemetry reports only aggregate service/process/request/document/diagnostic/stderr counts and never source contents.
|
|
@@ -79,7 +79,7 @@ export class CapabilityRegistry {
|
|
|
79
79
|
throw new CapabilityError("invalid_arguments", `Invalid arguments for capability ${name}: ${details}`);
|
|
80
80
|
}
|
|
81
81
|
try {
|
|
82
|
-
return await definition.run(parsed.data, context);
|
|
82
|
+
return await definition.run(parsed.data, context, options);
|
|
83
83
|
}
|
|
84
84
|
catch (error) {
|
|
85
85
|
if (error instanceof CapabilityError)
|
|
@@ -140,6 +140,11 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
140
140
|
query: z.string(),
|
|
141
141
|
limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
|
|
142
142
|
}).strict(),
|
|
143
|
+
z.object({
|
|
144
|
+
operation: z.literal("diagnostics"),
|
|
145
|
+
path: z.string().min(1),
|
|
146
|
+
limit: z.number().int().min(1).max(MAX_CODE_INTELLIGENCE_RESULT_LIMIT).optional(),
|
|
147
|
+
}).strict(),
|
|
143
148
|
]);
|
|
144
149
|
return new CapabilityRegistry([
|
|
145
150
|
{
|
|
@@ -181,7 +186,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
181
186
|
available: dependencies.codeIntelligence?.available ?? false,
|
|
182
187
|
reason: dependencies.codeIntelligence?.unavailableReason,
|
|
183
188
|
}),
|
|
184
|
-
run: async (input, context) => dependencies.codeIntelligence.run(input, context),
|
|
189
|
+
run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
|
|
185
190
|
}]
|
|
186
191
|
: []),
|
|
187
192
|
...(dependencies.downloadArtifact
|
package/dist/logger.js
CHANGED
|
@@ -223,7 +223,17 @@ function formatRuntimeResources(entry) {
|
|
|
223
223
|
const completed = numberField(entry.processesCompleted) ?? 0;
|
|
224
224
|
const workspaces = numberField(entry.cachedWorkspaces) ?? 0;
|
|
225
225
|
const reviewStates = numberField(entry.reviewStates) ?? 0;
|
|
226
|
-
|
|
226
|
+
const languageServices = numberField(entry.languageServices) ?? 0;
|
|
227
|
+
const languageServicesActive = numberField(entry.languageServicesActive) ?? 0;
|
|
228
|
+
const languageProcesses = numberField(entry.languageProcessesRunning) ?? 0;
|
|
229
|
+
const languageRequestsActive = numberField(entry.languageRequestsActive) ?? 0;
|
|
230
|
+
const languageRequestsQueued = numberField(entry.languageRequestsQueued) ?? 0;
|
|
231
|
+
const languageDocuments = numberField(entry.languageOpenDocuments) ?? 0;
|
|
232
|
+
const languageDiagnosticSnapshots = numberField(entry.languageDiagnosticSnapshots) ?? 0;
|
|
233
|
+
const languageDiagnosticsRetained = numberField(entry.languageDiagnosticsRetained) ?? 0;
|
|
234
|
+
const languageStderrBytes = numberField(entry.languageStderrBytes) ?? 0;
|
|
235
|
+
const languageCooldowns = numberField(entry.languageCrashCooldowns) ?? 0;
|
|
236
|
+
return `runtime rss=${rssMb}MB heap=${heapUsedMb}/${heapTotalMb}MB transports=${transports} processes=${running} running/${completed} completed workspaces=${workspaces} review=${reviewStates} lsp=${languageServices} services/${languageServicesActive} active/${languageProcesses} processes requests=${languageRequestsActive} active/${languageRequestsQueued} queued docs=${languageDocuments} diagnostics=${languageDiagnosticSnapshots} snapshots/${languageDiagnosticsRetained} retained stderr=${languageStderrBytes}B cooldowns=${languageCooldowns}`;
|
|
227
237
|
}
|
|
228
238
|
function bytesToMegabytes(value) {
|
|
229
239
|
return value === undefined ? 0 : Math.round(value / (1024 * 1024));
|
|
@@ -3,7 +3,7 @@ import { readFile, realpath } from "node:fs/promises";
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { createMessageConnection } from "vscode-jsonrpc/node";
|
|
6
|
-
import { DefinitionRequest,
|
|
6
|
+
import { DefinitionRequest, DocumentDiagnosticReportKind, DocumentDiagnosticRequest, DocumentSymbolRequest, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, PublishDiagnosticsNotification, ShutdownRequest, WorkspaceSymbolRequest, } from "vscode-languageserver-protocol";
|
|
7
7
|
import { LanguageServerConfigurationError, } from "./language-server-config.js";
|
|
8
8
|
import { terminateProcessTree } from "../process-platform.js";
|
|
9
9
|
import { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
@@ -11,7 +11,10 @@ import { DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT } from "./code-intelligence-type
|
|
|
11
11
|
import { normalizeHoverContents } from "./normalization/hover.js";
|
|
12
12
|
import { normalizeDocumentSymbols, normalizeWorkspaceSymbols, } from "./normalization/symbols.js";
|
|
13
13
|
import { isWithin, locationEntries, normalizeLocations, workspaceDisplayPath, } from "./normalization/locations.js";
|
|
14
|
-
import { lspPositionFromUser, rangeFromLsp
|
|
14
|
+
import { lspPositionFromUser, rangeFromLsp } from "./position-encoding.js";
|
|
15
|
+
import { DiagnosticSnapshotStore } from "./runtime/diagnostic-snapshots.js";
|
|
16
|
+
import { DocumentSynchronizer } from "./runtime/document-synchronizer.js";
|
|
17
|
+
import { SemanticRequestCoordinator } from "./runtime/semantic-requests.js";
|
|
15
18
|
export { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
16
19
|
const STDERR_TAIL_BYTES = 64 * 1024;
|
|
17
20
|
export class LanguageService {
|
|
@@ -26,7 +29,9 @@ export class LanguageService {
|
|
|
26
29
|
initializePromise;
|
|
27
30
|
positionEncoding = PositionEncodingKind.UTF16;
|
|
28
31
|
capabilities;
|
|
29
|
-
documents
|
|
32
|
+
documents;
|
|
33
|
+
diagnosticSnapshots;
|
|
34
|
+
semanticRequests;
|
|
30
35
|
stderrTail = Buffer.alloc(0);
|
|
31
36
|
closed = false;
|
|
32
37
|
constructor(workspaceRoot, project, policy) {
|
|
@@ -34,6 +39,13 @@ export class LanguageService {
|
|
|
34
39
|
this.project = project;
|
|
35
40
|
this.policy = policy;
|
|
36
41
|
this.key = languageServiceKey(project);
|
|
42
|
+
this.documents = new DocumentSynchronizer(project.definition);
|
|
43
|
+
this.diagnosticSnapshots = new DiagnosticSnapshotStore(policy.maxDiagnosticDocuments, policy.maxDiagnosticsPerDocument);
|
|
44
|
+
this.semanticRequests = new SemanticRequestCoordinator({
|
|
45
|
+
maxConcurrent: policy.maxConcurrentSemanticRequests,
|
|
46
|
+
maxQueued: policy.maxQueuedSemanticRequests,
|
|
47
|
+
deadlineMs: policy.requestTimeoutMs,
|
|
48
|
+
});
|
|
37
49
|
}
|
|
38
50
|
acquire() {
|
|
39
51
|
this.inFlight += 1;
|
|
@@ -43,19 +55,37 @@ export class LanguageService {
|
|
|
43
55
|
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
44
56
|
this.lastUsedAt = Date.now();
|
|
45
57
|
}
|
|
46
|
-
|
|
58
|
+
get isIdle() {
|
|
59
|
+
return this.inFlight === 0 &&
|
|
60
|
+
this.semanticRequests.activeCount === 0 &&
|
|
61
|
+
this.semanticRequests.queuedCount === 0;
|
|
62
|
+
}
|
|
63
|
+
get runtimeStats() {
|
|
64
|
+
const child = this.child;
|
|
65
|
+
return {
|
|
66
|
+
operationInFlight: this.inFlight,
|
|
67
|
+
semanticRequestsActive: this.semanticRequests.activeCount,
|
|
68
|
+
semanticRequestsQueued: this.semanticRequests.queuedCount,
|
|
69
|
+
openDocuments: this.documents.size,
|
|
70
|
+
diagnosticSnapshots: this.diagnosticSnapshots.size,
|
|
71
|
+
diagnosticsRetained: this.diagnosticSnapshots.retainedDiagnostics,
|
|
72
|
+
stderrBytes: this.stderrTail.length,
|
|
73
|
+
processRunning: Boolean(child && child.exitCode === null && child.signalCode === null),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async definition(input, signal) {
|
|
47
77
|
try {
|
|
48
78
|
await this.ensureStarted();
|
|
49
79
|
if (!this.capabilities?.definitionProvider) {
|
|
50
80
|
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise definition support.`);
|
|
51
81
|
}
|
|
52
82
|
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
53
|
-
const document = await this.
|
|
83
|
+
const document = await this.syncDocumentOrdered(sourcePath);
|
|
54
84
|
const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
|
|
55
|
-
const response = await
|
|
85
|
+
const response = await this.semanticRequests.run(`Definition request for ${input.path}`, signal, (token) => this.connection.sendRequest(DefinitionRequest.type, {
|
|
56
86
|
textDocument: { uri: document.uri },
|
|
57
87
|
position,
|
|
58
|
-
}
|
|
88
|
+
}, token));
|
|
59
89
|
const locations = await normalizeLocations(locationEntries(response), this.workspaceRoot, this.positionEncoding);
|
|
60
90
|
return {
|
|
61
91
|
operation: "definition",
|
|
@@ -73,19 +103,19 @@ export class LanguageService {
|
|
|
73
103
|
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
74
104
|
}
|
|
75
105
|
}
|
|
76
|
-
async hover(input) {
|
|
106
|
+
async hover(input, signal) {
|
|
77
107
|
try {
|
|
78
108
|
await this.ensureStarted();
|
|
79
109
|
if (!this.capabilities?.hoverProvider) {
|
|
80
110
|
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise hover support.`);
|
|
81
111
|
}
|
|
82
112
|
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
83
|
-
const document = await this.
|
|
113
|
+
const document = await this.syncDocumentOrdered(sourcePath);
|
|
84
114
|
const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
|
|
85
|
-
const response = await
|
|
115
|
+
const response = await this.semanticRequests.run(`Hover request for ${input.path}`, signal, (token) => this.connection.sendRequest(HoverRequest.type, {
|
|
86
116
|
textDocument: { uri: document.uri },
|
|
87
117
|
position,
|
|
88
|
-
}
|
|
118
|
+
}, token));
|
|
89
119
|
const common = {
|
|
90
120
|
operation: "hover",
|
|
91
121
|
selectedServer: this.project.definition.id,
|
|
@@ -111,20 +141,20 @@ export class LanguageService {
|
|
|
111
141
|
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
112
142
|
}
|
|
113
143
|
}
|
|
114
|
-
async references(input) {
|
|
144
|
+
async references(input, signal) {
|
|
115
145
|
try {
|
|
116
146
|
await this.ensureStarted();
|
|
117
147
|
if (!this.capabilities?.referencesProvider) {
|
|
118
148
|
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise references support.`);
|
|
119
149
|
}
|
|
120
150
|
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
121
|
-
const document = await this.
|
|
151
|
+
const document = await this.syncDocumentOrdered(sourcePath);
|
|
122
152
|
const position = lspPositionFromUser(document.text, input.line, input.column, this.positionEncoding);
|
|
123
|
-
const response = await
|
|
153
|
+
const response = await this.semanticRequests.run(`References request for ${input.path}`, signal, (token) => this.connection.sendRequest(ReferencesRequest.type, {
|
|
124
154
|
textDocument: { uri: document.uri },
|
|
125
155
|
position,
|
|
126
156
|
context: { includeDeclaration: true },
|
|
127
|
-
}
|
|
157
|
+
}, token));
|
|
128
158
|
const entries = locationEntries(response ?? []);
|
|
129
159
|
const limit = input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT;
|
|
130
160
|
const selected = entries.slice(0, limit);
|
|
@@ -148,17 +178,17 @@ export class LanguageService {
|
|
|
148
178
|
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
149
179
|
}
|
|
150
180
|
}
|
|
151
|
-
async documentSymbols(input) {
|
|
181
|
+
async documentSymbols(input, signal) {
|
|
152
182
|
try {
|
|
153
183
|
await this.ensureStarted();
|
|
154
184
|
if (!this.capabilities?.documentSymbolProvider) {
|
|
155
185
|
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise document-symbol support.`);
|
|
156
186
|
}
|
|
157
187
|
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
158
|
-
const document = await this.
|
|
159
|
-
const response = await
|
|
188
|
+
const document = await this.syncDocumentOrdered(sourcePath);
|
|
189
|
+
const response = await this.semanticRequests.run(`Document-symbol request for ${input.path}`, signal, (token) => this.connection.sendRequest(DocumentSymbolRequest.type, {
|
|
160
190
|
textDocument: { uri: document.uri },
|
|
161
|
-
}
|
|
191
|
+
}, token));
|
|
162
192
|
const normalized = normalizeDocumentSymbols(response, document.text, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
|
|
163
193
|
return {
|
|
164
194
|
operation: "documentSymbols",
|
|
@@ -173,15 +203,15 @@ export class LanguageService {
|
|
|
173
203
|
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
174
204
|
}
|
|
175
205
|
}
|
|
176
|
-
async workspaceSymbols(input) {
|
|
206
|
+
async workspaceSymbols(input, signal) {
|
|
177
207
|
try {
|
|
178
208
|
await this.ensureStarted();
|
|
179
209
|
if (!this.capabilities?.workspaceSymbolProvider) {
|
|
180
210
|
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not advertise workspace-symbol support.`);
|
|
181
211
|
}
|
|
182
212
|
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
183
|
-
await this.
|
|
184
|
-
const response = await
|
|
213
|
+
await this.syncDocumentOrdered(sourcePath);
|
|
214
|
+
const response = await this.semanticRequests.run(`Workspace-symbol request for ${JSON.stringify(input.query)}`, signal, (token) => this.connection.sendRequest(WorkspaceSymbolRequest.type, { query: input.query }, token));
|
|
185
215
|
const normalized = await normalizeWorkspaceSymbols(response, this.workspaceRoot, this.positionEncoding, input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT);
|
|
186
216
|
return {
|
|
187
217
|
operation: "workspaceSymbols",
|
|
@@ -196,6 +226,54 @@ export class LanguageService {
|
|
|
196
226
|
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
197
227
|
}
|
|
198
228
|
}
|
|
229
|
+
async diagnostics(input, signal) {
|
|
230
|
+
try {
|
|
231
|
+
await this.ensureStarted();
|
|
232
|
+
const sourcePath = await workspaceSourcePath(this.workspaceRoot, input.path);
|
|
233
|
+
const document = await this.syncDocumentOrdered(sourcePath);
|
|
234
|
+
const limit = input.limit ?? DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT;
|
|
235
|
+
const common = {
|
|
236
|
+
operation: "diagnostics",
|
|
237
|
+
selectedServer: this.project.definition.id,
|
|
238
|
+
projectRoot: workspaceDisplayPath(this.workspaceRoot, this.project.projectRoot),
|
|
239
|
+
path: workspaceDisplayPath(this.workspaceRoot, sourcePath),
|
|
240
|
+
};
|
|
241
|
+
const diagnosticProvider = this.capabilities?.diagnosticProvider;
|
|
242
|
+
if (diagnosticProvider) {
|
|
243
|
+
const previousResultId = this.diagnosticSnapshots.previousPullResultId(document.uri);
|
|
244
|
+
const response = await this.semanticRequests.run(`Diagnostic request for ${input.path}`, signal, (token) => this.connection.sendRequest(DocumentDiagnosticRequest.type, {
|
|
245
|
+
textDocument: { uri: document.uri },
|
|
246
|
+
...(diagnosticProvider.identifier === undefined ? {} : { identifier: diagnosticProvider.identifier }),
|
|
247
|
+
...(previousResultId === undefined ? {} : { previousResultId }),
|
|
248
|
+
}, token));
|
|
249
|
+
if (response.kind === DocumentDiagnosticReportKind.Full) {
|
|
250
|
+
this.diagnosticSnapshots.capturePull(response.items, document, this.positionEncoding, response.resultId);
|
|
251
|
+
}
|
|
252
|
+
else if (!this.diagnosticSnapshots.markPullUnchanged(document, response.resultId)) {
|
|
253
|
+
throw new CodeIntelligenceError("code.result_outside_policy", `Language server ${this.project.definition.id} returned unchanged diagnostics without a previous full report.`);
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
...common,
|
|
257
|
+
provider: "pull",
|
|
258
|
+
...this.diagnosticSnapshots.readPull(document, limit),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const snapshot = this.diagnosticSnapshots.readPush(document, limit);
|
|
262
|
+
if (snapshot.freshness.state === "missing" && !this.diagnosticSnapshots.hasObservedPushDiagnostics()) {
|
|
263
|
+
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not provide pull diagnostics and has not published push diagnostics.`);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
...common,
|
|
267
|
+
provider: "push",
|
|
268
|
+
...snapshot,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error instanceof CodeIntelligenceError)
|
|
273
|
+
throw error;
|
|
274
|
+
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
199
277
|
async shutdown() {
|
|
200
278
|
if (this.closed)
|
|
201
279
|
return;
|
|
@@ -203,18 +281,7 @@ export class LanguageService {
|
|
|
203
281
|
const connection = this.connection;
|
|
204
282
|
const child = this.child;
|
|
205
283
|
if (connection) {
|
|
206
|
-
|
|
207
|
-
if (!document.openNotified)
|
|
208
|
-
continue;
|
|
209
|
-
try {
|
|
210
|
-
await connection.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
211
|
-
textDocument: { uri: document.uri },
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// The server may already be gone.
|
|
216
|
-
}
|
|
217
|
-
}
|
|
284
|
+
await this.documents.closeAll(connection);
|
|
218
285
|
try {
|
|
219
286
|
await withTimeout(connection.sendRequest(ShutdownRequest.type), this.policy.shutdownTimeoutMs, () => new Error("Language-server shutdown timed out."));
|
|
220
287
|
await connection.sendNotification(ExitNotification.type);
|
|
@@ -228,12 +295,16 @@ export class LanguageService {
|
|
|
228
295
|
connection.dispose();
|
|
229
296
|
}
|
|
230
297
|
this.documents.clear();
|
|
298
|
+
this.diagnosticSnapshots.clear();
|
|
231
299
|
if (child && child.exitCode === null && child.signalCode === null) {
|
|
232
300
|
terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
|
|
233
301
|
}
|
|
234
302
|
this.child = undefined;
|
|
235
303
|
this.connection = undefined;
|
|
236
304
|
this.initializePromise = undefined;
|
|
305
|
+
this.capabilities = undefined;
|
|
306
|
+
this.positionEncoding = PositionEncodingKind.UTF16;
|
|
307
|
+
this.stderrTail = Buffer.alloc(0);
|
|
237
308
|
}
|
|
238
309
|
async ensureStarted() {
|
|
239
310
|
if (this.closed) {
|
|
@@ -283,6 +354,16 @@ export class LanguageService {
|
|
|
283
354
|
onExitDuringInitialization = (code, signal) => reject(new CodeIntelligenceError("code.language_service_start_failed", `Language server ${definition.id} exited during initialization (${signal ?? code ?? "unknown"}).${this.stderrSuffix()}`));
|
|
284
355
|
child.once("exit", onExitDuringInitialization);
|
|
285
356
|
});
|
|
357
|
+
child.on("exit", () => {
|
|
358
|
+
if (this.closed || this.child !== child)
|
|
359
|
+
return;
|
|
360
|
+
try {
|
|
361
|
+
connection.dispose();
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Pending semantic requests will surface the unexpected process exit.
|
|
365
|
+
}
|
|
366
|
+
});
|
|
286
367
|
const rootUri = pathToFileURL(this.project.projectRoot).href;
|
|
287
368
|
const initializeParams = {
|
|
288
369
|
processId: process.pid,
|
|
@@ -324,6 +405,10 @@ export class LanguageService {
|
|
|
324
405
|
dynamicRegistration: false,
|
|
325
406
|
hierarchicalDocumentSymbolSupport: true,
|
|
326
407
|
},
|
|
408
|
+
diagnostic: {
|
|
409
|
+
dynamicRegistration: false,
|
|
410
|
+
relatedDocumentSupport: false,
|
|
411
|
+
},
|
|
327
412
|
},
|
|
328
413
|
},
|
|
329
414
|
};
|
|
@@ -347,6 +432,7 @@ export class LanguageService {
|
|
|
347
432
|
this.connection = undefined;
|
|
348
433
|
this.child = undefined;
|
|
349
434
|
this.documents.clear();
|
|
435
|
+
this.diagnosticSnapshots.clear();
|
|
350
436
|
}
|
|
351
437
|
registerClientHandlers(connection) {
|
|
352
438
|
connection.onRequest("workspace/configuration", (params) => Array.isArray(params?.items) ? params.items.map(() => null) : []);
|
|
@@ -357,50 +443,12 @@ export class LanguageService {
|
|
|
357
443
|
connection.onRequest("window/showMessageRequest", () => null);
|
|
358
444
|
connection.onNotification("window/logMessage", () => undefined);
|
|
359
445
|
connection.onNotification("window/showMessage", () => undefined);
|
|
446
|
+
connection.onNotification(PublishDiagnosticsNotification.type, (params) => {
|
|
447
|
+
this.diagnosticSnapshots.capturePush(params, this.documents.get(params.uri), this.positionEncoding);
|
|
448
|
+
});
|
|
360
449
|
}
|
|
361
|
-
async
|
|
362
|
-
|
|
363
|
-
const text = await readFile(sourcePath, "utf8");
|
|
364
|
-
const existing = this.documents.get(uri);
|
|
365
|
-
const languageId = languageIdForPath(this.project.definition, sourcePath);
|
|
366
|
-
const synchronization = textDocumentSynchronization(this.capabilities?.textDocumentSync);
|
|
367
|
-
if (!existing) {
|
|
368
|
-
const document = { uri, languageId, version: 1, text, openNotified: false };
|
|
369
|
-
this.documents.set(uri, document);
|
|
370
|
-
if (synchronization.openClose) {
|
|
371
|
-
await this.connection.sendNotification(DidOpenTextDocumentNotification.type, {
|
|
372
|
-
textDocument: {
|
|
373
|
-
uri: document.uri,
|
|
374
|
-
languageId: document.languageId,
|
|
375
|
-
version: document.version,
|
|
376
|
-
text: document.text,
|
|
377
|
-
},
|
|
378
|
-
});
|
|
379
|
-
document.openNotified = true;
|
|
380
|
-
}
|
|
381
|
-
return document;
|
|
382
|
-
}
|
|
383
|
-
if (existing.text !== text) {
|
|
384
|
-
const previousText = existing.text;
|
|
385
|
-
existing.version += 1;
|
|
386
|
-
existing.text = text;
|
|
387
|
-
if (synchronization.change === TextDocumentSyncKind.Full) {
|
|
388
|
-
await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
389
|
-
textDocument: { uri, version: existing.version },
|
|
390
|
-
contentChanges: [{ text }],
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
else if (synchronization.change === TextDocumentSyncKind.Incremental) {
|
|
394
|
-
await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
395
|
-
textDocument: { uri, version: existing.version },
|
|
396
|
-
contentChanges: [{
|
|
397
|
-
range: wholeDocumentRange(previousText, this.positionEncoding),
|
|
398
|
-
text,
|
|
399
|
-
}],
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return existing;
|
|
450
|
+
async syncDocumentOrdered(sourcePath) {
|
|
451
|
+
return this.documents.sync(sourcePath, this.connection, this.capabilities?.textDocumentSync, this.positionEncoding);
|
|
404
452
|
}
|
|
405
453
|
appendStderr(chunk) {
|
|
406
454
|
this.stderrTail = Buffer.concat([this.stderrTail, chunk]);
|
|
@@ -420,22 +468,6 @@ export function languageServiceKey(project) {
|
|
|
420
468
|
project.definition.fingerprint,
|
|
421
469
|
]);
|
|
422
470
|
}
|
|
423
|
-
function languageIdForPath(definition, path) {
|
|
424
|
-
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
425
|
-
return definition.languageIdByExtension[extension] ?? definition.languages[0];
|
|
426
|
-
}
|
|
427
|
-
function textDocumentSynchronization(value) {
|
|
428
|
-
if (typeof value === "number") {
|
|
429
|
-
return {
|
|
430
|
-
openClose: value !== TextDocumentSyncKind.None,
|
|
431
|
-
change: value,
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
return {
|
|
435
|
-
openClose: value?.openClose === true,
|
|
436
|
-
change: value?.change ?? TextDocumentSyncKind.None,
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
471
|
async function workspaceSourcePath(workspaceRoot, inputPath) {
|
|
440
472
|
const root = resolve(workspaceRoot);
|
|
441
473
|
const path = resolve(root, inputPath);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { rangeFromLsp } from "../position-encoding.js";
|
|
2
|
+
export function normalizeDiagnostic(diagnostic, documentText, encoding) {
|
|
3
|
+
return {
|
|
4
|
+
range: rangeFromLsp(documentText, diagnostic.range, encoding),
|
|
5
|
+
...(diagnostic.severity === undefined ? {} : { severity: diagnosticSeverityName(diagnostic.severity) }),
|
|
6
|
+
...(diagnostic.code === undefined ? {} : { code: diagnostic.code }),
|
|
7
|
+
...(diagnostic.source === undefined ? {} : { source: diagnostic.source }),
|
|
8
|
+
message: typeof diagnostic.message === "string" ? diagnostic.message : diagnostic.message.value,
|
|
9
|
+
...(diagnostic.tags?.length
|
|
10
|
+
? { tags: diagnostic.tags.map(diagnosticTagName) }
|
|
11
|
+
: {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function diagnosticSeverityName(value) {
|
|
15
|
+
switch (value) {
|
|
16
|
+
case 1: return "error";
|
|
17
|
+
case 2: return "warning";
|
|
18
|
+
case 3: return "information";
|
|
19
|
+
case 4: return "hint";
|
|
20
|
+
default: return `unknown:${value}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function diagnosticTagName(value) {
|
|
24
|
+
switch (value) {
|
|
25
|
+
case 1: return "unnecessary";
|
|
26
|
+
case 2: return "deprecated";
|
|
27
|
+
default: return `unknown:${value}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { normalizeDiagnostic } from "../normalization/diagnostics.js";
|
|
2
|
+
export class DiagnosticSnapshotStore {
|
|
3
|
+
maxDocuments;
|
|
4
|
+
maxDiagnosticsPerDocument;
|
|
5
|
+
pushSnapshots = new Map();
|
|
6
|
+
pullSnapshots = new Map();
|
|
7
|
+
pushObserved = false;
|
|
8
|
+
constructor(maxDocuments, maxDiagnosticsPerDocument) {
|
|
9
|
+
this.maxDocuments = maxDocuments;
|
|
10
|
+
this.maxDiagnosticsPerDocument = maxDiagnosticsPerDocument;
|
|
11
|
+
}
|
|
12
|
+
capturePush(params, document, encoding) {
|
|
13
|
+
this.pushObserved = true;
|
|
14
|
+
if (!document)
|
|
15
|
+
return;
|
|
16
|
+
const snapshot = this.normalizeSnapshot(params.diagnostics, document, encoding, params.version === undefined ? {} : { publishedVersion: params.version });
|
|
17
|
+
this.setBounded(this.pushSnapshots, params.uri, snapshot);
|
|
18
|
+
}
|
|
19
|
+
capturePull(diagnostics, document, encoding, resultId) {
|
|
20
|
+
const snapshot = this.normalizeSnapshot(diagnostics, document, encoding, resultId === undefined ? {} : { resultId });
|
|
21
|
+
this.setBounded(this.pullSnapshots, document.uri, snapshot);
|
|
22
|
+
}
|
|
23
|
+
markPullUnchanged(document, resultId) {
|
|
24
|
+
const previous = this.pullSnapshots.get(document.uri);
|
|
25
|
+
if (!previous)
|
|
26
|
+
return false;
|
|
27
|
+
const snapshot = {
|
|
28
|
+
...previous,
|
|
29
|
+
snapshotDocumentVersion: document.version,
|
|
30
|
+
resultId,
|
|
31
|
+
};
|
|
32
|
+
this.setBounded(this.pullSnapshots, document.uri, snapshot);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
previousPullResultId(uri) {
|
|
36
|
+
return this.pullSnapshots.get(uri)?.resultId;
|
|
37
|
+
}
|
|
38
|
+
readPush(document, limit) {
|
|
39
|
+
return this.read(this.pushSnapshots, document, limit, true);
|
|
40
|
+
}
|
|
41
|
+
readPull(document, limit) {
|
|
42
|
+
return this.read(this.pullSnapshots, document, limit, false);
|
|
43
|
+
}
|
|
44
|
+
hasObservedPushDiagnostics() {
|
|
45
|
+
return this.pushObserved;
|
|
46
|
+
}
|
|
47
|
+
clear() {
|
|
48
|
+
this.pushSnapshots.clear();
|
|
49
|
+
this.pullSnapshots.clear();
|
|
50
|
+
this.pushObserved = false;
|
|
51
|
+
}
|
|
52
|
+
get size() {
|
|
53
|
+
return this.pushSnapshots.size + this.pullSnapshots.size;
|
|
54
|
+
}
|
|
55
|
+
get retainedDiagnostics() {
|
|
56
|
+
let total = 0;
|
|
57
|
+
for (const snapshot of this.pushSnapshots.values())
|
|
58
|
+
total += snapshot.diagnostics.length;
|
|
59
|
+
for (const snapshot of this.pullSnapshots.values())
|
|
60
|
+
total += snapshot.diagnostics.length;
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
normalizeSnapshot(diagnostics, document, encoding, metadata) {
|
|
64
|
+
return {
|
|
65
|
+
diagnostics: diagnostics
|
|
66
|
+
.slice(0, this.maxDiagnosticsPerDocument)
|
|
67
|
+
.map((diagnostic) => normalizeDiagnostic(diagnostic, document.text, encoding)),
|
|
68
|
+
total: diagnostics.length,
|
|
69
|
+
snapshotDocumentVersion: document.version,
|
|
70
|
+
...metadata,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
setBounded(snapshots, uri, snapshot) {
|
|
74
|
+
snapshots.delete(uri);
|
|
75
|
+
snapshots.set(uri, snapshot);
|
|
76
|
+
while (snapshots.size > this.maxDocuments) {
|
|
77
|
+
const oldestUri = snapshots.keys().next().value;
|
|
78
|
+
if (!oldestUri)
|
|
79
|
+
break;
|
|
80
|
+
snapshots.delete(oldestUri);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
read(snapshots, document, limit, usePublishedVersion) {
|
|
84
|
+
const snapshot = snapshots.get(document.uri);
|
|
85
|
+
if (!snapshot) {
|
|
86
|
+
return {
|
|
87
|
+
diagnostics: [],
|
|
88
|
+
returned: 0,
|
|
89
|
+
truncated: false,
|
|
90
|
+
freshness: {
|
|
91
|
+
state: "missing",
|
|
92
|
+
documentVersion: document.version,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const diagnostics = snapshot.diagnostics.slice(0, limit);
|
|
97
|
+
const freshness = usePublishedVersion && snapshot.publishedVersion !== undefined
|
|
98
|
+
? (snapshot.publishedVersion === document.version ? "fresh" : "stale")
|
|
99
|
+
: (snapshot.snapshotDocumentVersion === document.version ? "fresh" : "stale");
|
|
100
|
+
return {
|
|
101
|
+
diagnostics,
|
|
102
|
+
returned: diagnostics.length,
|
|
103
|
+
truncated: snapshot.total > diagnostics.length,
|
|
104
|
+
total: snapshot.total,
|
|
105
|
+
freshness: {
|
|
106
|
+
state: freshness,
|
|
107
|
+
documentVersion: document.version,
|
|
108
|
+
snapshotDocumentVersion: snapshot.snapshotDocumentVersion,
|
|
109
|
+
...(snapshot.publishedVersion === undefined ? {} : { publishedVersion: snapshot.publishedVersion }),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|