Skip to content

Commit df530d0

Browse files
committed
fix: restore semantic syntax highlighting
1 parent 19529bc commit df530d0

17 files changed

Lines changed: 728 additions & 105 deletions

File tree

packages/core/src/domain/lsp.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
LspHoverResult,
99
LspLocation,
1010
LspRuntimeMode,
11+
LspSemanticTokens,
1112
LspSessionSummary,
1213
LspToolInstallFailure,
1314
LspToolInstallJobSnapshot,
@@ -83,6 +84,7 @@ describe("LSP shared surface", () => {
8384
references: boolean;
8485
hover: boolean;
8586
documentSymbols: boolean;
87+
semanticTokens: boolean;
8688
diagnostics: boolean;
8789
};
8890
}>();
@@ -189,6 +191,11 @@ describe("LSP shared surface", () => {
189191
version?: number;
190192
}>();
191193

194+
expectTypeOf<LspSemanticTokens>().toEqualTypeOf<{
195+
resultId?: string;
196+
data: number[];
197+
}>();
198+
192199
type LspDiagnosticsUpdatedEvent = Extract<DomainEvent, { type: "lsp.diagnostics.updated" }>;
193200
expectTypeOf<LspDiagnosticsUpdatedEvent>().toMatchTypeOf<{
194201
type: "lsp.diagnostics.updated";

packages/core/src/domain/lsp.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,45 @@ export type LspServerKind = "typescript" | "python" | "go" | "rust" | "vue";
22
export type LspToolSource = "override" | "managed" | "bundled" | "system";
33
export type LspRuntimeMode = "auto" | "off";
44

5+
export const LSP_SEMANTIC_TOKEN_TYPES = [
6+
"namespace",
7+
"type",
8+
"class",
9+
"enum",
10+
"interface",
11+
"struct",
12+
"typeParameter",
13+
"parameter",
14+
"variable",
15+
"property",
16+
"enumMember",
17+
"event",
18+
"function",
19+
"method",
20+
"macro",
21+
"keyword",
22+
"modifier",
23+
"comment",
24+
"string",
25+
"number",
26+
"regexp",
27+
"operator",
28+
"decorator",
29+
] as const;
30+
31+
export const LSP_SEMANTIC_TOKEN_MODIFIERS = [
32+
"declaration",
33+
"definition",
34+
"readonly",
35+
"static",
36+
"deprecated",
37+
"abstract",
38+
"async",
39+
"modification",
40+
"documentation",
41+
"defaultLibrary",
42+
] as const;
43+
544
export interface LspRange {
645
startLine: number;
746
startColumn: number;
@@ -36,6 +75,11 @@ export interface LspDocumentSymbol {
3675
children?: LspDocumentSymbol[];
3776
}
3877

78+
export interface LspSemanticTokens {
79+
resultId?: string;
80+
data: number[];
81+
}
82+
3983
export interface LspSessionSummary {
4084
workspaceId: string;
4185
serverKind: LspServerKind;
@@ -47,6 +91,7 @@ export interface LspSessionSummary {
4791
references: boolean;
4892
hover: boolean;
4993
documentSymbols: boolean;
94+
semanticTokens: boolean;
5095
diagnostics: boolean;
5196
};
5297
}

packages/server/src/__tests__/fixtures/fake-lsp-server.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ const connection = createMessageConnection(
1414
);
1515

1616
const docs = new Map();
17-
const exitAfterInitMs = Number(process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS ?? "0");
17+
const exitAfterInitArg = process.argv.find((arg) => arg.startsWith("--exit-after-init-ms="));
18+
const exitAfterInitMs = Number(
19+
exitAfterInitArg?.slice("--exit-after-init-ms=".length) ??
20+
process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS ??
21+
"0"
22+
);
1823
const hoverDelayMs = Number(process.env.CODER_STUDIO_FAKE_LSP_HOVER_DELAY_MS ?? "0");
1924
const initDelayMs = Number(process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS ?? "0");
2025
const stderrOnInit = process.env.CODER_STUDIO_FAKE_LSP_STDERR_ON_INIT ?? "";
@@ -44,6 +49,13 @@ connection.onRequest("initialize", async () => {
4449
referencesProvider: true,
4550
hoverProvider: true,
4651
documentSymbolProvider: true,
52+
semanticTokensProvider: {
53+
legend: {
54+
tokenTypes: ["function", "variable", "class", "typeAlias", "builtinType"],
55+
tokenModifiers: ["declaration", "readonly"],
56+
},
57+
full: true,
58+
},
4759
textDocumentSync: 1,
4860
},
4961
};
@@ -221,6 +233,20 @@ connection.onRequest("textDocument/documentSymbol", ({ textDocument }) => {
221233
];
222234
});
223235

236+
connection.onRequest("textDocument/semanticTokens/full", ({ textDocument }) => {
237+
if (!textDocument.uri.endsWith("/shared.ts")) {
238+
return { data: [] };
239+
}
240+
241+
return {
242+
resultId: "semantic-1",
243+
data: [
244+
// sharedValue: variable + declaration
245+
0, 13, 11, 1, 1,
246+
],
247+
};
248+
});
249+
224250
function publishDiagnostics(uri) {
225251
const text = docs.get(uri) ?? readFileSync(new URL(uri), "utf8");
226252
const diagnostics = text.includes("missingSymbol")

packages/server/src/__tests__/lsp-commands.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class FakeLspManager {
3838
references: true,
3939
hover: true,
4040
documentSymbols: true,
41+
semanticTokens: true,
4142
diagnostics: true,
4243
},
4344
},
@@ -79,6 +80,13 @@ class FakeLspManager {
7980
async documentSymbols() {
8081
return [];
8182
}
83+
84+
async semanticTokens() {
85+
return {
86+
resultId: "semantic-1",
87+
data: [0, 13, 11, 8, 1],
88+
};
89+
}
8290
}
8391

8492
class FakeLspToolInstallManager {
@@ -206,6 +214,27 @@ describe("LSP commands", () => {
206214
expect.objectContaining({ path: "e2e/fixtures/lsp-workspace/shared.ts" }),
207215
])
208216
);
217+
218+
const semanticTokens = await dispatch(
219+
{
220+
kind: "command",
221+
id: crypto.randomUUID(),
222+
op: "lsp.semanticTokens",
223+
args: {
224+
workspaceId,
225+
path: "e2e/fixtures/lsp-workspace/shared.ts",
226+
},
227+
},
228+
ctx
229+
);
230+
231+
expect(semanticTokens.ok).toBe(true);
232+
expect(semanticTokens.data).toEqual(
233+
expect.objectContaining({
234+
resultId: "semantic-1",
235+
data: [0, 13, 11, 8, 1],
236+
})
237+
);
209238
});
210239

211240
it("exposes lsp runtime status and install commands", async () => {

packages/server/src/commands/lsp.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,12 @@ registerCommand(
197197
}),
198198
async (args, ctx) => ctx.lspMgr.documentSymbols(args)
199199
);
200+
201+
registerCommand(
202+
"lsp.semanticTokens",
203+
z.object({
204+
workspaceId: z.string(),
205+
path: z.string(),
206+
}),
207+
async (args, ctx) => ctx.lspMgr.semanticTokens(args)
208+
);

packages/server/src/lsp/manager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
LspHoverResult,
66
LspLocation,
77
LspRuntimeMode,
8+
LspSemanticTokens,
89
LspSessionSummary,
910
Workspace,
1011
} from "@coder-studio/core";
@@ -37,6 +38,7 @@ interface LspSessionLike {
3738
references(input: { path: string; line: number; column: number }): Promise<LspLocation[] | null>;
3839
hover(input: { path: string; line: number; column: number }): Promise<LspHoverResult | null>;
3940
documentSymbols(input: { path: string }): Promise<LspDocumentSymbol[] | null>;
41+
semanticTokens(input: { path: string }): Promise<LspSemanticTokens | null>;
4042
}
4143

4244
interface ManagedSessionEntry {
@@ -340,6 +342,14 @@ export class LspManager {
340342
return session ? await session.documentSymbols(input) : null;
341343
}
342344

345+
async semanticTokens(input: { workspaceId: string; path: string }) {
346+
if (this.runtimeMode === "off") {
347+
return null;
348+
}
349+
const session = await this.getSessionForPath(input.workspaceId, input.path);
350+
return session ? await session.semanticTokens(input) : null;
351+
}
352+
343353
async disposeWorkspace(workspaceId: string): Promise<void> {
344354
const keys = Array.from(this.sessions.keys()).filter((key) =>
345355
key.startsWith(`${workspaceId}::`)

packages/server/src/lsp/session.test.ts

Lines changed: 61 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { join } from "node:path";
2+
import { LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES } from "@coder-studio/core";
23
import { describe, expect, it, vi } from "vitest";
34
import { LspSession } from "./session.js";
45

@@ -53,7 +54,8 @@ describe.sequential("LspSession", () => {
5354
},
5455
});
5556

56-
await session.start();
57+
const summary = await session.start();
58+
expect(summary.capabilities.semanticTokens).toBe(true);
5759
await session.openDocument({
5860
path: "e2e/fixtures/lsp-workspace/broken.ts",
5961
languageId: "typescript",
@@ -102,6 +104,21 @@ describe.sequential("LspSession", () => {
102104

103105
expect(symbols?.[0]?.name).toBe("sharedValue");
104106

107+
const semanticTokens = await session.semanticTokens({
108+
path: "e2e/fixtures/lsp-workspace/shared.ts",
109+
});
110+
111+
expect(semanticTokens).toEqual({
112+
resultId: "semantic-1",
113+
data: [
114+
0,
115+
13,
116+
11,
117+
LSP_SEMANTIC_TOKEN_TYPES.indexOf("variable"),
118+
1 << LSP_SEMANTIC_TOKEN_MODIFIERS.indexOf("declaration"),
119+
],
120+
});
121+
105122
expect(diagnostics).toHaveBeenCalledWith(
106123
expect.objectContaining({
107124
workspaceId: "ws-1",
@@ -493,66 +510,55 @@ describe.sequential("LspSession", () => {
493510
it("kills the companion process when the primary exits", async () => {
494511
// If Volar crashes we must not leave the TypeScript companion alive
495512
// (otherwise idle-TTL cleanup leaks a process per session).
496-
const previous = process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS;
497-
process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS = "150";
498-
499-
try {
500-
const session = new LspSession({
501-
workspaceId: "ws-1",
502-
workspacePath: process.cwd(),
503-
spec: {
504-
serverKind: "vue",
505-
// Primary exits 150ms after initialize.
513+
const session = new LspSession({
514+
workspaceId: "ws-1",
515+
workspacePath: process.cwd(),
516+
spec: {
517+
serverKind: "vue",
518+
// Primary exits 150ms after initialize.
519+
command: "node",
520+
args: [FAKE_LSP, "--exit-after-init-ms=150"],
521+
rootPath: process.cwd(),
522+
companion: {
523+
// Companion stays alive normally.
506524
command: "node",
507525
args: [FAKE_LSP],
508-
rootPath: process.cwd(),
509-
companion: {
510-
// Companion stays alive normally.
511-
command: "node",
512-
args: [FAKE_LSP],
513-
},
514-
bridges: { tsserverRequest: true },
515-
},
516-
onDiagnostics: vi.fn(),
517-
requestTimeoutMs: 2000,
518-
logger: {
519-
info: vi.fn(),
520-
warn: vi.fn(),
521-
error: vi.fn(),
522526
},
523-
});
527+
bridges: { tsserverRequest: true },
528+
},
529+
onDiagnostics: vi.fn(),
530+
requestTimeoutMs: 2000,
531+
logger: {
532+
info: vi.fn(),
533+
warn: vi.fn(),
534+
error: vi.fn(),
535+
},
536+
});
524537

525-
// Pull the companion field via a typed accessor for inspection.
526-
type WithCompanion = LspSession & {
527-
companion: null | { child: { killed: boolean } };
528-
};
538+
// Pull the companion field via a typed accessor for inspection.
539+
type WithCompanion = LspSession & {
540+
companion: null | { child: { killed: boolean } };
541+
};
529542

530-
await session.start();
531-
// Companion was spawned alongside primary.
532-
expect((session as WithCompanion).companion).not.toBeNull();
533-
const companionChild = (session as WithCompanion).companion?.child;
534-
expect(companionChild).toBeDefined();
535-
536-
// Wait long enough for the primary to exit and the termination handler
537-
// to fire.
538-
await vi.waitFor(
539-
() => {
540-
expect((session as WithCompanion).companion).toBeNull();
541-
},
542-
{ timeout: 2000 }
543-
);
544-
// The companion's process should have received SIGTERM.
545-
expect(companionChild?.killed).toBe(true);
546-
expect(session.getSummary().status).toBe("stopped");
543+
await session.start();
544+
// Companion was spawned alongside primary.
545+
expect((session as WithCompanion).companion).not.toBeNull();
546+
const companionChild = (session as WithCompanion).companion?.child;
547+
expect(companionChild).toBeDefined();
547548

548-
await session.stop();
549-
} finally {
550-
if (previous === undefined) {
551-
delete process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS;
552-
} else {
553-
process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS = previous;
554-
}
555-
}
549+
// Wait long enough for the primary to exit and the termination handler
550+
// to fire.
551+
await vi.waitFor(
552+
() => {
553+
expect((session as WithCompanion).companion).toBeNull();
554+
},
555+
{ timeout: 2000 }
556+
);
557+
// The companion's process should have received SIGTERM.
558+
expect(companionChild?.killed).toBe(true);
559+
expect(session.getSummary().status).toBe("stopped");
560+
561+
await session.stop();
556562
});
557563

558564
it("stops the companion when the session is explicitly stopped", async () => {

0 commit comments

Comments
 (0)