Skip to content

Commit a2db5bb

Browse files
committed
feat(server): bring Vue 3 LSP online with a tsserver bridge
Volar 3.x removed its embedded TypeScript service and now expects the LSP client to relay `tsserver/request` notifications to a TypeScript Language Server with `@vue/typescript-plugin` loaded. Previously vue installs failed their verify step on Windows and, once installed, returned empty hovers because no companion bridge existed. This wires up the full path: - fix(server): make `checkCommandAvailable` fall back to `fs.existsSync` (with PATHEXT) for absolute paths. Windows `where.exe` parses `C:` as a `path:pattern` separator and rejects absolute paths, which had been failing every managed LSP install at the verify step. - feat(server): spawn a `typescript-language-server` companion alongside Volar for vue sessions, configured with `@vue/typescript-plugin` so tsserver can answer `_vue:*` commands. Forward Volar's `tsserver/request` notifications via `workspace/executeCommand ("typescript.tsserverRequest", ...)` and unwrap the tsserver wire response (Volar expects `body`, not the `{seq,type,success,body}` wrapper). - feat(server): fan hover / definition / references / typeDefinition / declaration out to both Volar and the companion, then merge results. Volar 3 deliberately ships without a TS-semantic hover plugin and relies on the editor to combine both ends. - feat(server): expose `CODER_STUDIO_VUE_TSSERVER_BRIDGE=off` so the bridge can be disabled when triaging issues. - feat(web): enrich the Monaco Vue grammar — embed `typescript`/ `javascript` inside `<script>` (auto-detected via `lang=`), embed `css`/`scss`/`less` inside `<style>`, and recognise directive shorthand (`v-...`, `:...`, `@...`, `#...`) plus mustache interpolation in `<template>`. Adds bridge + vue-spec unit tests plus an end-to-end Node probe under `scripts/probe-vue-bridge.mjs` that exercises the live Volar + tsls handshake (useful for future protocol regressions).
1 parent 73dece8 commit a2db5bb

18 files changed

Lines changed: 2183 additions & 132 deletions

File tree

packages/server/src/__tests__/provider-runtime/command-check.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,69 @@ describe("checkCommandAvailable", () => {
5757

5858
expect(execFile).toHaveBeenCalledWith("where", ["codex"], { windowsHide: true });
5959
});
60+
61+
it("checks the filesystem directly for absolute Windows paths instead of invoking where", async () => {
62+
// `where.exe` rejects `C:\...` arguments with "invalid pattern" because it
63+
// parses the first colon as a path:pattern separator. Make sure we never
64+
// hand absolute paths to it.
65+
const execFile = vi.fn();
66+
const absolutePath = "C:\\tools\\lsp\\vue\\node_modules\\.bin\\vue-language-server.cmd";
67+
68+
await expect(
69+
checkCommandAvailable(absolutePath, {
70+
platform: "win32",
71+
runCommand: execFile,
72+
existsSync: (file) => file === absolutePath,
73+
})
74+
).resolves.toBe(true);
75+
76+
expect(execFile).not.toHaveBeenCalled();
77+
});
78+
79+
it("returns false for absolute Windows paths that do not exist on disk", async () => {
80+
const execFile = vi.fn();
81+
82+
await expect(
83+
checkCommandAvailable("C:\\tools\\missing.cmd", {
84+
platform: "win32",
85+
runCommand: execFile,
86+
existsSync: () => false,
87+
pathExt: ".CMD;.EXE",
88+
})
89+
).resolves.toBe(false);
90+
91+
expect(execFile).not.toHaveBeenCalled();
92+
});
93+
94+
it("appends PATHEXT extensions when an absolute Windows path has no extension", async () => {
95+
const execFile = vi.fn();
96+
const baseline = "C:\\tools\\bin\\vue-language-server";
97+
const resolved = `${baseline}.CMD`;
98+
99+
await expect(
100+
checkCommandAvailable(baseline, {
101+
platform: "win32",
102+
runCommand: execFile,
103+
existsSync: (file) => file === resolved,
104+
pathExt: ".EXE;.CMD",
105+
})
106+
).resolves.toBe(true);
107+
108+
expect(execFile).not.toHaveBeenCalled();
109+
});
110+
111+
it("checks the filesystem directly for absolute POSIX paths", async () => {
112+
const execFile = vi.fn();
113+
const absolutePath = "/opt/coder-studio/lsp-tools/go/bin/gopls";
114+
115+
await expect(
116+
checkCommandAvailable(absolutePath, {
117+
platform: "linux",
118+
runCommand: execFile,
119+
existsSync: (file) => file === absolutePath,
120+
})
121+
).resolves.toBe(true);
122+
123+
expect(execFile).not.toHaveBeenCalled();
124+
});
60125
});

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

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,199 @@ describe("LspManager", () => {
340340
});
341341
});
342342

343+
it("attaches a typescript companion + tsserver bridge to vue sessions when both ends resolve", async () => {
344+
const vueSummary = {
345+
workspaceId: "ws-1",
346+
serverKind: "vue" as const,
347+
status: "ready" as const,
348+
capabilities: {
349+
definition: true,
350+
references: true,
351+
hover: true,
352+
documentSymbols: true,
353+
diagnostics: true,
354+
},
355+
};
356+
const sessionDeps: Array<unknown> = [];
357+
const createSession = vi.fn((deps) => {
358+
sessionDeps.push(deps);
359+
return {
360+
start: vi.fn(async () => vueSummary),
361+
stop: vi.fn(async () => {}),
362+
getSummary: () => vueSummary,
363+
openDocument: async () => 1,
364+
changeDocument: async () => 2,
365+
closeDocument: async () => {},
366+
definition: async () => [],
367+
declaration: async () => [],
368+
typeDefinition: async () => [],
369+
references: async () => [],
370+
hover: async () => null,
371+
documentSymbols: async () => [],
372+
};
373+
});
374+
const resolve = vi.fn(async (input: { serverKind: "vue" | "typescript" }) =>
375+
input.serverKind === "vue"
376+
? {
377+
kind: "ready" as const,
378+
serverKind: "vue" as const,
379+
displayName: "Vue language server",
380+
source: "managed" as const,
381+
command: "/tmp/coder-studio/lsp-tools/vue/3.3.2/node_modules/.bin/vue-language-server",
382+
args: ["--stdio"],
383+
}
384+
: {
385+
kind: "ready" as const,
386+
serverKind: "typescript" as const,
387+
displayName: "TypeScript language server",
388+
source: "bundled" as const,
389+
command: "/usr/local/bin/node",
390+
args: ["/bundled/lib/cli.mjs", "--stdio"],
391+
}
392+
);
393+
394+
const manager = new LspManager({
395+
workspaceMgr: {
396+
get: () => ({
397+
id: "ws-1",
398+
path: process.cwd(),
399+
targetRuntime: "native",
400+
openedAt: 1,
401+
lastActiveAt: 1,
402+
uiState: { leftPanelWidth: 250, bottomPanelHeight: 200, focusMode: false },
403+
}),
404+
},
405+
eventBus: { emit: vi.fn() },
406+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
407+
requestTimeoutMs: 1000,
408+
idleTtlMs: 1000,
409+
restartLimit: 2,
410+
lspToolMgr: { resolve } as never,
411+
createSession,
412+
vueBridgeMode: "auto",
413+
});
414+
415+
await expect(
416+
manager.ensureSession({ workspaceId: "ws-1", path: "src/App.vue" })
417+
).resolves.toMatchObject({ kind: "ready", summary: { serverKind: "vue" } });
418+
419+
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ serverKind: "vue" }));
420+
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ serverKind: "typescript" }));
421+
422+
const created = sessionDeps[0] as { spec: { companion?: { initializationOptions?: unknown } } };
423+
expect(created).toMatchObject({
424+
spec: {
425+
serverKind: "vue",
426+
command: "/tmp/coder-studio/lsp-tools/vue/3.3.2/node_modules/.bin/vue-language-server",
427+
bridges: { tsserverRequest: true },
428+
companion: {
429+
command: "/usr/local/bin/node",
430+
args: ["/bundled/lib/cli.mjs", "--stdio"],
431+
},
432+
},
433+
});
434+
// Plugin location comes back in the host's path style; normalize for the
435+
// assertion so it works on both POSIX and Windows.
436+
const plugins = (
437+
created.spec.companion?.initializationOptions as {
438+
plugins?: Array<{ name: string; location: string; languages: string[] }>;
439+
}
440+
)?.plugins;
441+
expect(plugins).toHaveLength(1);
442+
expect(plugins?.[0]?.name).toBe("@vue/typescript-plugin");
443+
expect(plugins?.[0]?.languages).toEqual(["vue"]);
444+
expect(plugins?.[0]?.location.replace(/\\/g, "/")).toMatch(
445+
/tmp.coder-studio.lsp-tools.vue.3\.3\.2.node_modules.@vue.language-server$/
446+
);
447+
});
448+
449+
it("omits the vue tsserver bridge when CODER_STUDIO_VUE_TSSERVER_BRIDGE is off", async () => {
450+
const sessionDeps: Array<unknown> = [];
451+
const createSession = vi.fn((deps) => {
452+
sessionDeps.push(deps);
453+
return {
454+
start: vi.fn(async () => ({
455+
workspaceId: "ws-1",
456+
serverKind: "vue" as const,
457+
status: "ready" as const,
458+
capabilities: {
459+
definition: true,
460+
references: true,
461+
hover: true,
462+
documentSymbols: true,
463+
diagnostics: true,
464+
},
465+
})),
466+
stop: vi.fn(async () => {}),
467+
getSummary: () =>
468+
({
469+
workspaceId: "ws-1",
470+
serverKind: "vue",
471+
status: "ready",
472+
capabilities: {
473+
definition: true,
474+
references: true,
475+
hover: true,
476+
documentSymbols: true,
477+
diagnostics: true,
478+
},
479+
}) as never,
480+
openDocument: async () => 1,
481+
changeDocument: async () => 2,
482+
closeDocument: async () => {},
483+
definition: async () => [],
484+
declaration: async () => [],
485+
typeDefinition: async () => [],
486+
references: async () => [],
487+
hover: async () => null,
488+
documentSymbols: async () => [],
489+
};
490+
});
491+
const resolve = vi.fn(async () => ({
492+
kind: "ready" as const,
493+
serverKind: "vue" as const,
494+
displayName: "Vue language server",
495+
source: "managed" as const,
496+
command: "/install/node_modules/.bin/vue-language-server",
497+
args: ["--stdio"],
498+
}));
499+
500+
const manager = new LspManager({
501+
workspaceMgr: {
502+
get: () => ({
503+
id: "ws-1",
504+
path: process.cwd(),
505+
targetRuntime: "native",
506+
openedAt: 1,
507+
lastActiveAt: 1,
508+
uiState: { leftPanelWidth: 250, bottomPanelHeight: 200, focusMode: false },
509+
}),
510+
},
511+
eventBus: { emit: vi.fn() },
512+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
513+
requestTimeoutMs: 1000,
514+
idleTtlMs: 1000,
515+
restartLimit: 2,
516+
lspToolMgr: { resolve } as never,
517+
createSession,
518+
vueBridgeMode: "off",
519+
});
520+
521+
await manager.ensureSession({ workspaceId: "ws-1", path: "src/App.vue" });
522+
523+
// Only the vue resolve call — no typescript companion resolution either.
524+
expect(resolve).toHaveBeenCalledTimes(1);
525+
526+
const [created] = sessionDeps;
527+
expect(created).toMatchObject({
528+
spec: {
529+
serverKind: "vue",
530+
bridges: undefined,
531+
companion: undefined,
532+
},
533+
});
534+
});
535+
343536
it("coalesces concurrent ensureSession calls for the same workspace and server kind", async () => {
344537
let resolveStart: ((summary: typeof readySummary) => void) | null = null;
345538
const startPromise = new Promise<typeof readySummary>((resolve) => {

0 commit comments

Comments
 (0)