diff --git a/.claude/agent-memory/review-review-pr-test-analyzer/lsp-test-patterns.md b/.claude/agent-memory/review-review-pr-test-analyzer/lsp-test-patterns.md index 94e0b96..7665067 100644 --- a/.claude/agent-memory/review-review-pr-test-analyzer/lsp-test-patterns.md +++ b/.claude/agent-memory/review-review-pr-test-analyzer/lsp-test-patterns.md @@ -13,3 +13,5 @@ LSP package test layout: `packages/lsp/test/unit/{client,multiplexer,index,serve - The multiplexer tests use a hand-written `FakeManager` (in multiplexer.test.ts), NOT the real `LSPManager`. So the multiplexer's upstream protocol + relay wiring are covered, but the real `LSPManager.getClients` spawn/dedup logic and `diagnosticsForFile`/`diagnostics` merge across heterogeneous real servers are NOT exercised by these tests. - Recurring untested branches to check on any LSP PR: `LSPManager` constructor `serverIds` scoping + the "empty array = no restriction" guard (index.ts ~L267); `getClients` filename matching for extensionless files (Dockerfile/Containerfile, index.ts ~L378-389); `closeFile`/`openWithText` on the real manager; `didChange` empty `contentChanges` early-return (multiplexer.ts ~L209-211). - Concurrent-open fix lives in client.ts (`files[filePath] = 0` set BEFORE the didOpen await). The regression test in client.test.ts genuinely guards it: reverting the order makes the count assertion fail. Treat this as the reference pattern for async-ordering regression tests here. +- Native TypeScript tests cover resolver behavior (classic/no-tsgo, v7 and file markers, native-preview, missing tsc, parent-hoisted TypeScript), but not `TypescriptServer.spawn` command/arguments or the Windows `.cmd` branch. +- The pull-diagnostics fixture advertises `diagnosticProvider` unconditionally and always returns one fixed `full` report. It does not expose request counts or model `unchanged`, errors, or changing/empty reports; the push fixture answers unknown requests, so current client tests cannot prove capability negotiation/gating, didChange refresh, preservation, failure swallowing, or `onDiagnostics` relay side effects. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 401e4c2..ec462c8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -121,7 +121,7 @@ packages/lsp/src/ ├── language.ts # File extension → language ID mapping └── server/ # Server definitions (30+ files) ├── index.ts # LSP_SERVERS registry array - ├── typescript.ts # TypeScript/JavaScript server + ├── typescript.ts # TypeScript/JavaScript server (auto-selects native TS 7) ├── pyright.ts # Python server └── ... # One file per language server ``` diff --git a/CLAUDE.md b/CLAUDE.md index adc383d..461e607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ Claude/MCP Client <-> StdioTransport <-> McpServer <-> Providers | Language | Server | Root Detection | |----------|--------|----------------| -| TypeScript/JavaScript | typescript-language-server | package-lock.json, bun.lock, etc. | +| TypeScript/JavaScript | typescript-language-server (auto-upgrades to native TypeScript 7 `tsc --lsp` when the project ships it) | package-lock.json, bun.lock, etc. | | TypeScript/JavaScript | oxlint | .oxlintrc.json, package.json | | Deno | deno lsp | deno.json, deno.jsonc | | Python | pyright-langserver | pyproject.toml, setup.py, requirements.txt | diff --git a/apps/docs/content/docs/3.configuration/1.overview.md b/apps/docs/content/docs/3.configuration/1.overview.md index f101f3b..250e0b3 100644 --- a/apps/docs/content/docs/3.configuration/1.overview.md +++ b/apps/docs/content/docs/3.configuration/1.overview.md @@ -80,6 +80,31 @@ formatter: false lsp: false ``` +## Native TypeScript 7 (typescript-go) + +The `typescript` server automatically uses the native TypeScript 7 compiler +([typescript-go](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/)) +when your project ships one — no configuration needed. At startup it inspects +the project's `node_modules` and, if it finds a native build, spawns +`tsc --lsp --stdio` (or `tsgo --lsp --stdio` from +[`@typescript/native-preview`](https://www.npmjs.com/package/@typescript/native-preview)). +Otherwise it falls back to the classic `typescript-language-server --stdio`. + +A project is treated as native TypeScript 7 when any of these hold: + +- `node_modules/typescript` is at major version 7 or higher +- the `typescript` package ships `lib/getExePath.js` (native launcher) +- the `typescript` package is missing `lib/tsserver.js` (classic entry point) +- a `node_modules/.bin/tsgo` binary is present (`@typescript/native-preview`) + +Because it is one server that swaps its own binary, there is a single set of +diagnostics either way — you never get duplicates. To use it, just install a +native TypeScript 7 build in your project: + +```bash +npm install --save-dev @typescript/native-preview +``` + ## Environment Variables | Variable | Description | Default | diff --git a/apps/docs/content/docs/4.reference/2.supported-languages.md b/apps/docs/content/docs/4.reference/2.supported-languages.md index 5b4279d..04d9b03 100644 --- a/apps/docs/content/docs/4.reference/2.supported-languages.md +++ b/apps/docs/content/docs/4.reference/2.supported-languages.md @@ -11,8 +11,8 @@ Code Please supports formatting and LSP diagnostics for many languages. | Language | Formatter | LSP Server | |----------|-----------|------------| -| TypeScript | Biome, Prettier | typescript-language-server | -| JavaScript | Biome, Prettier | typescript-language-server | +| TypeScript | Biome, Prettier | native `tsc` / `tsgo` (TypeScript 7, auto-selected) or typescript-language-server | +| JavaScript | Biome, Prettier | native `tsc` / `tsgo` (TypeScript 7, auto-selected) or typescript-language-server | | Python | ruff, uv | pyright | | Go | gofmt | gopls | | Rust | - | rust-analyzer | @@ -34,6 +34,7 @@ Code Please supports formatting and LSP diagnostics for many languages. | Server | Extensions | Root Detection | |--------|------------|----------------| | typescript-language-server | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs` | `package.json`, `tsconfig.json` | +| tsc / tsgo (native TypeScript 7, auto-selected when the project ships it) | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts` | `package.json`, `tsconfig.json` | | oxlint | `.ts`, `.tsx`, `.js`, `.jsx` | `.oxlintrc.json`, `package.json` | | deno | `.ts`, `.tsx`, `.js`, `.jsx` | `deno.json`, `deno.jsonc` | | eslint | `.ts`, `.tsx`, `.js`, `.jsx`, `.vue` | `eslint.config.js`, `.eslintrc` | diff --git a/packages/lsp/CLAUDE.md b/packages/lsp/CLAUDE.md index 2070251..4d1866d 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -231,6 +231,16 @@ lsp: - `root: string` - Custom project root path (must be non-empty) - `command: string[]` - Custom spawn command (must have at least one element) +**Native TypeScript 7 (typescript-go):** The single `typescript` server auto-selects +its binary per project root at spawn time. When the workspace ships a native +TypeScript 7 build — `node_modules/typescript` at major version ≥ 7 (or missing +`lib/tsserver.js` / carrying `lib/getExePath.js`), or a `@typescript/native-preview` +`tsgo` bin — it spawns `tsc --lsp --stdio` (or `tsgo --lsp --stdio`); otherwise it +falls back to the classic `typescript-language-server --stdio`. There is no separate +server id or config toggle, so there are never duplicate diagnostics. The native +servers report diagnostics via the LSP pull model (`textDocument/diagnostic`), which +the client handles transparently alongside push (`publishDiagnostics`). + **Config Utilities:** ```typescript import { getServerRoot, isServerEnabled, loadLspConfig } from '@pleaseai/code-lsp' diff --git a/packages/lsp/src/client.ts b/packages/lsp/src/client.ts index 8133a3d..856bdaa 100644 --- a/packages/lsp/src/client.ts +++ b/packages/lsp/src/client.ts @@ -9,6 +9,7 @@ import type { Diagnostic } from 'vscode-languageserver-types' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { + CancellationTokenSource, createMessageConnection, StreamMessageReader, @@ -62,32 +63,95 @@ export async function createLSPClient(input: { const diagnostics = new Map() const files: Record = {} + const diagnosticPulls: Record = {} + const diagnosticPullTokens: Record = {} + let nextDiagnosticPullID = 0 let diagnosticsListeners: Array<{ path: string resolve: () => void timer?: ReturnType }> = [] + // Whether the server reports diagnostics via the pull model + // (textDocument/diagnostic) rather than pushing publishDiagnostics. + // Populated from the initialize result below. + let supportsPullDiagnostics = false + + const notifyDiagnosticsListeners = (filePath: string): void => { + const listeners = diagnosticsListeners.filter(l => l.path === filePath) + for (const listener of listeners) { + if (listener.timer) { clearTimeout(listener.timer) } + listener.timer = setTimeout(() => { + diagnosticsListeners = diagnosticsListeners.filter( + l => l !== listener, + ) + listener.resolve() + }, DIAGNOSTICS_DEBOUNCE_MS) + } + } + + // Store diagnostics for a file and notify any waiters. Shared by the push + // (publishDiagnostics) handler and the pull (textDocument/diagnostic) path. + const applyDiagnostics = (filePath: string, diags: Diagnostic[]): void => { + diagnostics.set(filePath, diags) + notifyDiagnosticsListeners(filePath) + input.onDiagnostics?.(filePath, input.serverID) + } + + // Pull diagnostics for a single file (LSP textDocument/diagnostic). Used for + // servers that advertise a diagnostic provider instead of pushing them + // (e.g. typescript-go / tsgo). Failures are swallowed — absent diagnostics + // must never break opening a file. `pullID` uniquely identifies this request; + // the response is discarded if a newer open/change/close superseded it, so an + // out-of-order reply can't overwrite fresher diagnostics with stale ones. + const pullDiagnostics = async (filePath: string): Promise => { + if (!supportsPullDiagnostics) { return } + const normalizedPath = normalizePath(filePath) + diagnosticPullTokens[normalizedPath]?.cancel() + diagnosticPullTokens[normalizedPath]?.dispose() + const cancellation = new CancellationTokenSource() + diagnosticPullTokens[normalizedPath] = cancellation + const pullID = ++nextDiagnosticPullID + diagnosticPulls[normalizedPath] = pullID + try { + const report = (await withTimeout( + connection.sendRequest('textDocument/diagnostic', { + textDocument: { uri: pathToFileURL(normalizedPath).href }, + }, cancellation.token), + DIAGNOSTICS_WAIT_TIMEOUT_MS, + )) as { kind?: string, items?: Diagnostic[] } | null + // Drop a response superseded by a newer pull or close. + if (diagnosticPulls[normalizedPath] !== pullID) { return } + // A "full" report carries items; an "unchanged" report means keep the + // previously reported set, so leave the map as-is. + if (report?.kind === 'full' && Array.isArray(report.items)) { + applyDiagnostics(normalizedPath, report.items) + } + else { + notifyDiagnosticsListeners(normalizedPath) + } + } + catch { + // Settle waiters with the latest known diagnostics when the server is not + // ready or the request fails, unless a newer pull/close superseded it. + if (diagnosticPulls[normalizedPath] === pullID) { + notifyDiagnosticsListeners(normalizedPath) + } + } + finally { + if (diagnosticPulls[normalizedPath] === pullID) { + delete diagnosticPullTokens[normalizedPath] + cancellation.cancel() + cancellation.dispose() + } + } + } + // Handle diagnostics notifications connection.onNotification( 'textDocument/publishDiagnostics', (params: { uri: string, diagnostics: Diagnostic[] }) => { - const filePath = normalizePath(fileURLToPath(params.uri)) - diagnostics.set(filePath, params.diagnostics) - - // Notify listeners with debounce - const listener = diagnosticsListeners.find(l => l.path === filePath) - if (listener) { - if (listener.timer) { clearTimeout(listener.timer) } - listener.timer = setTimeout(() => { - diagnosticsListeners = diagnosticsListeners.filter( - l => l !== listener, - ) - listener.resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - - input.onDiagnostics?.(filePath, input.serverID) + applyDiagnostics(normalizePath(fileURLToPath(params.uri)), params.diagnostics) }, ) @@ -108,7 +172,7 @@ export async function createLSPClient(input: { connection.listen() // Initialize LSP connection - await withTimeout( + const initializeResult = (await withTimeout( connection.sendRequest('initialize', { rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, @@ -125,12 +189,19 @@ export async function createLSPClient(input: { textDocument: { synchronization: { didOpen: true, didChange: true }, publishDiagnostics: { versionSupport: true }, + // Advertise pull-diagnostics support so servers that report via the + // pull model (e.g. typescript-go / tsgo) enable their provider. + diagnostic: { dynamicRegistration: false }, rename: { prepareSupport: true }, }, }, }), INITIALIZE_TIMEOUT_MS, - ) + )) as { capabilities?: { diagnosticProvider?: unknown } } | undefined + + // A server that advertises a diagnostic provider reports diagnostics via the + // pull model (textDocument/diagnostic) instead of pushing publishDiagnostics. + supportsPullDiagnostics = Boolean(initializeResult?.capabilities?.diagnosticProvider) await connection.sendNotification('initialized', {}) @@ -169,6 +240,7 @@ export async function createLSPClient(input: { }, contentChanges: [{ text }], }) + void pullDiagnostics(filePath) return } @@ -185,6 +257,7 @@ export async function createLSPClient(input: { text, }, }) + void pullDiagnostics(filePath) }, async close(fileInput: { path: string }) { @@ -200,7 +273,12 @@ export async function createLSPClient(input: { }, }) delete files[filePath] - diagnostics.delete(normalizePath(filePath)) + const normalizedPath = normalizePath(filePath) + diagnosticPullTokens[normalizedPath]?.cancel() + diagnosticPullTokens[normalizedPath]?.dispose() + delete diagnosticPullTokens[normalizedPath] + delete diagnosticPulls[normalizedPath] + diagnostics.delete(normalizedPath) }, }, diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index abe55c3..0cd8fda 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -1,15 +1,134 @@ /** * TypeScript Language Server + * + * Prefers a project-local native TypeScript 7 (typescript-go) language server — + * `tsc --lsp --stdio` (or `tsgo --lsp --stdio` from `@typescript/native-preview`) + * — when the workspace ships one, and falls back to the classic + * `typescript-language-server --stdio` otherwise. + * + * Selection happens per project root at spawn time, so the single `typescript` + * server transparently upgrades to the native server without a separate server + * id or config toggle (and therefore without duplicate diagnostics). The native + * servers report diagnostics via the LSP pull model, which the client handles. */ import type { LSPServerInfo } from './types' import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' import process from 'node:process' import { attachLSPProcessHandlers, log, nearestRoot } from './utils' +const TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'] + +/** Walk up from `start` to the nearest `node_modules/typescript` package dir. */ +function findTypeScriptPackageRoot(start: string): string | undefined { + let dir = path.resolve(start) + while (true) { + const pkgJson = path.join(dir, 'node_modules', 'typescript', 'package.json') + if (fs.existsSync(pkgJson)) { return path.dirname(pkgJson) } + const parent = path.dirname(dir) + if (parent === dir) { return undefined } + dir = parent + } +} + +/** Resolve a package-owned bin declared by the package and exposed by its shared shim. */ +function resolvePackageBin(packageRoot: string, name: string): string | undefined { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), + ) as { bin?: string | Record } + const relativeTarget = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[name] + if (!relativeTarget) { return undefined } + + const target = path.resolve(packageRoot, relativeTarget) + if (!fs.existsSync(target)) { return undefined } + const ext = process.platform === 'win32' ? '.cmd' : '' + const packageDir = path.dirname(packageRoot) + const nodeModules = path.basename(packageDir).startsWith('@') + ? path.dirname(packageDir) + : packageDir + const shim = path.join(nodeModules, '.bin', `${name}${ext}`) + if (!fs.existsSync(shim)) { return undefined } + + // npm-style symlinks and package-manager-generated wrapper scripts are both + // valid shims. For wrappers, require a reference to the package's declared + // target so a stale or unrelated shared shim is never selected. + const stat = fs.lstatSync(shim) + if (stat.isSymbolicLink()) { + const resolvedShim = path.resolve(path.dirname(shim), fs.readlinkSync(shim)) + return fs.existsSync(resolvedShim) + && fs.realpathSync(resolvedShim) === fs.realpathSync(target) + ? shim + : undefined + } + const normalizeShimPath = (value: string): string => { + const normalized = value.replaceAll('\\', '/') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized + } + const wrapper = normalizeShimPath(fs.readFileSync(shim, 'utf8')) + const relativeFromBin = normalizeShimPath(path.relative(path.dirname(shim), target)) + const normalizedTarget = normalizeShimPath(target) + return wrapper.includes(relativeFromBin) || wrapper.includes(normalizedTarget) + ? shim + : undefined + } + catch { + return undefined + } +} + +/** Whether the installed `typescript` package is the native (TS 7+) build. */ +function isNativeTypeScriptPackage(packageRoot: string): boolean { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), + ) as { version?: unknown } + const major = typeof pkg.version === 'string' + ? Number.parseInt(pkg.version.split('.')[0] ?? '', 10) + : Number.NaN + if (!Number.isNaN(major) && major >= 7) { return true } + } + catch { + // Fall through to file-based markers. + } + const libRoot = path.join(packageRoot, 'lib') + if (!fs.existsSync(libRoot)) { return false } + // The native build ships getExePath.js and drops the classic tsserver.js entry. + if (fs.existsSync(path.join(libRoot, 'getExePath.js'))) { return true } + return !fs.existsSync(path.join(libRoot, 'tsserver.js')) +} + +/** + * Resolve a project-local native TypeScript 7 LSP command for `root`, if the + * workspace provides one. Exported for testing. + */ +export function resolveNativeTypeScriptServer( + root: string, +): { command: string, label: string } | undefined { + // Native `typescript` package (TS 7+) → its node_modules/.bin/tsc. + const packageRoot = findTypeScriptPackageRoot(root) + if (packageRoot && isNativeTypeScriptPackage(packageRoot)) { + const tsc = resolvePackageBin(packageRoot, 'tsc') + if (tsc) { return { command: tsc, label: 'native tsc' } } + } + + // `@typescript/native-preview` installs a `tsgo` bin (can coexist with TS <= 6). + let dir = path.resolve(root) + while (true) { + const nativePreviewRoot = path.join(dir, 'node_modules', '@typescript', 'native-preview') + const tsgo = resolvePackageBin(nativePreviewRoot, 'tsgo') + if (tsgo) { return { command: tsgo, label: 'native tsgo' } } + const parent = path.dirname(dir) + if (parent === dir) { return undefined } + dir = parent + } +} + export const TypescriptServer: LSPServerInfo = { id: 'typescript', - extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'], + extensions: TS_EXTENSIONS, root: nearestRoot( [ 'package-lock.json', @@ -21,6 +140,20 @@ export const TypescriptServer: LSPServerInfo = { ['deno.json', 'deno.jsonc'], ), async spawn(root) { + // Prefer a project-local native TypeScript 7 (typescript-go) server. + const native = resolveNativeTypeScriptServer(root) + if (native) { + log.info({ command: native.command }, `Using ${native.label} language server (--lsp --stdio)`) + const proc = spawn(native.command, ['--lsp', '--stdio'], { + cwd: root, + env: { ...process.env, BUN_BE_BUN: '1' }, + shell: process.platform === 'win32', + }) + attachLSPProcessHandlers(proc, 'typescript') + return { process: proc } + } + + // Classic fallback: typescript-language-server. const tsserver = Bun.which('typescript-language-server') if (!tsserver) { // Try via bunx diff --git a/packages/lsp/test/fixture/fake-lsp-server.js b/packages/lsp/test/fixture/fake-lsp-server.js index 03533f4..fff3423 100644 --- a/packages/lsp/test/fixture/fake-lsp-server.js +++ b/packages/lsp/test/fixture/fake-lsp-server.js @@ -1,55 +1,13 @@ // Simple JSON-RPC 2.0 LSP-like fake server over stdio // Implements a minimal LSP handshake and publishes diagnostics -import { Buffer } from 'node:buffer' -import process from 'node:process' +import { send, start } from './fake-lsp-transport.js' let nextId = 1 // Tracks how many didOpen notifications were received per uri, so tests can // assert that concurrent opens do not emit a duplicate didOpen. const openCounts = {} -function encode(message) { - const json = JSON.stringify(message) - const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n` - return Buffer.concat([ - Buffer.from(header, 'utf8'), - Buffer.from(json, 'utf8'), - ]) -} - -function decodeFrames(buffer) { - const results = [] - let idx = buffer.indexOf('\r\n\r\n') - while (idx !== -1) { - const header = buffer.slice(0, idx).toString('utf8') - const m = /Content-Length:\s*(\d+)/i.exec(header) - const len = m ? Number.parseInt(m[1], 10) : 0 - const bodyStart = idx + 4 - const bodyEnd = bodyStart + len - if (buffer.length < bodyEnd) - break - const body = buffer.slice(bodyStart, bodyEnd).toString('utf8') - results.push(body) - buffer = buffer.slice(bodyEnd) - idx = buffer.indexOf('\r\n\r\n') - } - return { messages: results, rest: buffer } -} - -let readBuffer = Buffer.alloc(0) - -process.stdin.on('data', (chunk) => { - readBuffer = Buffer.concat([readBuffer, chunk]) - const { messages, rest } = decodeFrames(readBuffer) - readBuffer = rest - for (const m of messages) handle(m) -}) - -function send(msg) { - process.stdout.write(encode(msg)) -} - function sendNotification(method, params) { send({ jsonrpc: '2.0', method, params }) } @@ -60,14 +18,7 @@ function sendRequest(method, params) { return id } -function handle(raw) { - let data - try { - data = JSON.parse(raw) - } - catch { - return - } +start((data) => { // Initialize request if (data.method === 'initialize') { @@ -211,4 +162,4 @@ function handle(raw) { if (typeof data.id !== 'undefined') { send({ jsonrpc: '2.0', id: data.id, result: null }) } -} +}) diff --git a/packages/lsp/test/fixture/fake-lsp-transport.js b/packages/lsp/test/fixture/fake-lsp-transport.js new file mode 100644 index 0000000..a9ba9ee --- /dev/null +++ b/packages/lsp/test/fixture/fake-lsp-transport.js @@ -0,0 +1,49 @@ +import { Buffer } from 'node:buffer' +import process from 'node:process' + +function encode(message) { + const json = JSON.stringify(message) + const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n` + return Buffer.concat([ + Buffer.from(header, 'utf8'), + Buffer.from(json, 'utf8'), + ]) +} + +function decodeFrames(buffer) { + const messages = [] + let headerEnd = buffer.indexOf('\r\n\r\n') + while (headerEnd !== -1) { + const header = buffer.slice(0, headerEnd).toString('utf8') + const match = /Content-Length:\s*(\d+)/i.exec(header) + const length = match ? Number.parseInt(match[1], 10) : 0 + const bodyStart = headerEnd + 4 + const bodyEnd = bodyStart + length + if (buffer.length < bodyEnd) { break } + messages.push(buffer.slice(bodyStart, bodyEnd).toString('utf8')) + buffer = buffer.slice(bodyEnd) + headerEnd = buffer.indexOf('\r\n\r\n') + } + return { messages, rest: buffer } +} + +export function send(message) { + process.stdout.write(encode(message)) +} + +export function start(handler) { + let readBuffer = Buffer.alloc(0) + process.stdin.on('data', (chunk) => { + readBuffer = Buffer.concat([readBuffer, chunk]) + const { messages, rest } = decodeFrames(readBuffer) + readBuffer = rest + for (const raw of messages) { + try { + handler(JSON.parse(raw)) + } + catch { + // Ignore malformed test input. + } + } + }) +} diff --git a/packages/lsp/test/fixture/fake-pull-lsp-server.js b/packages/lsp/test/fixture/fake-pull-lsp-server.js new file mode 100644 index 0000000..9c158f0 --- /dev/null +++ b/packages/lsp/test/fixture/fake-pull-lsp-server.js @@ -0,0 +1,82 @@ +// Minimal JSON-RPC 2.0 LSP-like fake server that reports diagnostics via the +// PULL model (textDocument/diagnostic) instead of pushing publishDiagnostics. +// Mirrors how typescript-go (tsgo) behaves, so the client's pull path can be +// tested without the real binary. + +import { send, start } from './fake-lsp-transport.js' + +start((data) => { + + // Initialize request - advertise a diagnostic provider (pull model). + if (data.method === 'initialize') { + send({ + jsonrpc: '2.0', + id: data.id, + result: { + capabilities: { + textDocumentSync: { openClose: true, change: 2 }, + diagnosticProvider: { + identifier: 'fake', + interFileDependencies: true, + workspaceDiagnostics: false, + }, + }, + }, + }) + return + } + + // Deliberately do NOT push publishDiagnostics on didOpen/didChange — this + // server only answers pull requests. + if ( + data.method === 'initialized' + || data.method === 'textDocument/didOpen' + || data.method === 'textDocument/didChange' + || data.method === 'textDocument/didClose' + || data.method === 'workspace/didChangeConfiguration' + ) { + return + } + + // Pull diagnostics request - return a full report with one error. + if (data.method === 'textDocument/diagnostic') { + const uri = data.params?.textDocument?.uri ?? '' + if (uri.includes('failed-pull')) { + send({ + jsonrpc: '2.0', + id: data.id, + error: { code: -32603, message: 'Simulated pull failure' }, + }) + return + } + if (uri.includes('unchanged-pull')) { + send({ jsonrpc: '2.0', id: data.id, result: { kind: 'unchanged' } }) + return + } + send({ + jsonrpc: '2.0', + id: data.id, + result: { + kind: 'full', + items: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + severity: 1, // Error + code: 2322, + source: 'fake', + message: 'Pull diagnostic from fake LSP server', + }, + ], + }, + }) + return + } + + // Respond OK to any other request. + if (typeof data.id !== 'undefined') { + send({ jsonrpc: '2.0', id: data.id, result: null }) + } +}) diff --git a/packages/lsp/test/unit/client.test.ts b/packages/lsp/test/unit/client.test.ts index c9d502b..73307aa 100644 --- a/packages/lsp/test/unit/client.test.ts +++ b/packages/lsp/test/unit/client.test.ts @@ -13,6 +13,15 @@ function spawnFakeServer() { } } +function spawnFakePullServer() { + const serverPath = path.join(import.meta.dir, '../fixture/fake-pull-lsp-server.js') + return { + process: spawn(process.execPath, [serverPath], { + stdio: 'pipe', + }), + } +} + describe('LSPClient', () => { let client: LSPClientInfo | null = null @@ -117,6 +126,89 @@ describe('LSPClient', () => { expect(client.diagnostics.size).toBeGreaterThan(0) }) + test('pulls diagnostics for servers that advertise a diagnostic provider', async () => { + const handle = spawnFakePullServer() + + client = await createLSPClient({ + serverID: 'fake-pull', + server: handle, + root: process.cwd(), + projectPath: process.cwd(), + }) + + const testFile = path.join(process.cwd(), 'package.json') + // The pull server never pushes publishDiagnostics; diagnostics must be + // fetched asynchronously via textDocument/diagnostic during open(). + const wait = client.waitForDiagnostics({ path: testFile }) + await client.notify.open({ path: testFile }) + await wait + + const diags = client.diagnostics.get(path.normalize(testFile)) ?? [] + expect(diags.length).toBeGreaterThan(0) + expect(diags[0]?.severity).toBe(1) + expect(diags[0]?.message).toContain('Pull diagnostic') + }) + + test('waitForDiagnostics resolves for pull-model servers', async () => { + const handle = spawnFakePullServer() + + client = await createLSPClient({ + serverID: 'fake-pull', + server: handle, + root: process.cwd(), + projectPath: process.cwd(), + }) + + const testFile = path.join(process.cwd(), 'package.json') + const wait = client.waitForDiagnostics({ path: testFile }) + await client.notify.open({ path: testFile }) + await wait + + expect(client.diagnostics.get(path.normalize(testFile))?.length).toBeGreaterThan(0) + }) + + test('resolves all diagnostics waiters for the same file', async () => { + const handle = spawnFakePullServer() + + client = await createLSPClient({ + serverID: 'fake-pull', + server: handle, + root: process.cwd(), + projectPath: process.cwd(), + }) + + const testFile = path.join(process.cwd(), 'package.json') + const waits = [ + client.waitForDiagnostics({ path: testFile }), + client.waitForDiagnostics({ path: testFile }), + ] + await client.notify.open({ path: testFile }) + + const startedAt = performance.now() + await Promise.all(waits) + expect(performance.now() - startedAt).toBeLessThan(1_000) + }) + + test.each(['unchanged', 'failed'])('settles waiters for %s pull diagnostics', async (result) => { + const handle = spawnFakePullServer() + + client = await createLSPClient({ + serverID: 'fake-pull', + server: handle, + root: process.cwd(), + projectPath: process.cwd(), + }) + + const testFile = path.join(process.cwd(), `${result}-pull.ts`) + const wait = client.waitForDiagnostics({ path: testFile }) + await client.notify.open({ path: testFile, text: 'const value = 1' }) + + const startedAt = performance.now() + await wait + expect(performance.now() - startedAt).toBeLessThan(1_000) + expect(client.diagnostics.get(path.normalize(testFile))).toBeUndefined() + }) + test('opens file with explicit buffer text (no disk read)', async () => { const handle = spawnFakeServer() diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts new file mode 100644 index 0000000..9ea7b5a --- /dev/null +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -0,0 +1,242 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, test } from 'bun:test' +import { resolveNativeTypeScriptServer } from '../../src/server/typescript' + +async function makeBin( + dir: string, + name: string, + packageName = name === 'tsgo' ? '@typescript/native-preview' : 'typescript', +): Promise { + const packageDir = path.join(dir, 'node_modules', ...packageName.split('/')) + const relativeTarget = `bin/${name}.js` + const target = path.join(packageDir, relativeTarget) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, '#!/usr/bin/env node\n') + + const packageJson = path.join(packageDir, 'package.json') + const pkg = await fs.readFile(packageJson, 'utf8') + .then(text => JSON.parse(text) as Record) + .catch(() => ({ name: packageName, version: '1.0.0' })) + pkg.bin = { ...(typeof pkg.bin === 'object' ? pkg.bin : {}), [name]: relativeTarget } + await fs.writeFile(packageJson, JSON.stringify(pkg)) + + const binDir = path.join(dir, 'node_modules', '.bin') + await fs.mkdir(binDir, { recursive: true }) + const ext = process.platform === 'win32' ? '.cmd' : '' + const bin = path.join(binDir, `${name}${ext}`) + if (process.platform === 'win32') { + await fs.writeFile(bin, `@node "%~dp0\\..\\${packageName}\\${relativeTarget.replaceAll('/', '\\')}" %*\n`) + } + else { + await fs.symlink(path.relative(binDir, target), bin) + } + return bin +} + +async function writeTypeScriptPackage( + dir: string, + opts: { version?: string, tsserver?: boolean, getExePath?: boolean }, +): Promise { + const pkgDir = path.join(dir, 'node_modules', 'typescript') + const libDir = path.join(pkgDir, 'lib') + await fs.mkdir(libDir, { recursive: true }) + await fs.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: 'typescript', version: opts.version ?? '5.7.0' }), + ) + if (opts.tsserver ?? true) { + await fs.writeFile(path.join(libDir, 'tsserver.js'), '// classic entry\n') + } + if (opts.getExePath) { + await fs.writeFile(path.join(libDir, 'getExePath.js'), '// native marker\n') + } +} + +describe('resolveNativeTypeScriptServer', () => { + test('returns undefined for a classic TypeScript 5 project', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-classic-')) + try { + await writeTypeScriptPackage(dir, { version: '5.7.0', tsserver: true }) + await makeBin(dir, 'tsc') + expect(resolveNativeTypeScriptServer(dir)).toBeUndefined() + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('selects native tsc when typescript is v7+', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-version-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + const result = resolveNativeTypeScriptServer(dir) + expect(result?.command).toBe(tsc) + expect(result?.label).toBe('native tsc') + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('selects native tsc when tsserver.js is missing', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-no-tsserver-')) + try { + await writeTypeScriptPackage(dir, { version: '5.9.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('selects native tsc when getExePath.js marker is present', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-getexepath-')) + try { + await writeTypeScriptPackage(dir, { version: '5.9.0', tsserver: true, getExePath: true }) + const tsc = await makeBin(dir, 'tsc') + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('falls back to tsgo bin (@typescript/native-preview) when present', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-tsgo-')) + try { + // Classic typescript, but native-preview provides a tsgo bin. + await writeTypeScriptPackage(dir, { version: '5.7.0', tsserver: true }) + const tsgo = await makeBin(dir, 'tsgo') + const result = resolveNativeTypeScriptServer(dir) + expect(result?.command).toBe(tsgo) + expect(result?.label).toBe('native tsgo') + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('selects native tsc when the project path contains an @-prefixed directory', async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-at-path-')) + const dir = path.join(base, '@workspace', 'app') + try { + await fs.mkdir(dir, { recursive: true }) + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) + } + finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + test('selects native tsc through a package-manager wrapper shim', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-wrapper-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + if (process.platform !== 'win32') { + await fs.unlink(tsc) + await fs.writeFile(tsc, '#!/bin/sh\nexec node "$(dirname "$0")/../typescript/bin/tsc.js" "$@"\n') + } + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('rejects a shared shim that does not reference the package bin', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-unrelated-shim-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + await fs.unlink(tsc) + await fs.writeFile( + tsc, + process.platform === 'win32' + ? '@node "%~dp0\\..\\unrelated\\tsc.js" %*\n' + : '#!/bin/sh\nexec node /unrelated/tsc.js "$@"\n', + ) + expect(resolveNativeTypeScriptServer(dir)).toBeUndefined() + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('selects native tsc through a pnpm-style symlinked package', async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-pnpm-')) + try { + const packageStore = path.join( + base, + 'node_modules', + '.pnpm', + 'typescript@7.0.0', + 'node_modules', + 'typescript', + ) + await fs.mkdir(path.join(packageStore, 'lib'), { recursive: true }) + await fs.writeFile( + path.join(packageStore, 'package.json'), + JSON.stringify({ name: 'typescript', version: '7.0.0', bin: { tsc: 'bin/tsc.js' } }), + ) + const target = path.join(packageStore, 'bin', 'tsc.js') + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, '#!/usr/bin/env node\n') + + const linkedPackage = path.join(base, 'node_modules', 'typescript') + await fs.symlink(path.relative(path.dirname(linkedPackage), packageStore), linkedPackage) + const binDir = path.join(base, 'node_modules', '.bin') + await fs.mkdir(binDir, { recursive: true }) + const ext = process.platform === 'win32' ? '.cmd' : '' + const shim = path.join(binDir, `tsc${ext}`) + if (process.platform === 'win32') { + await fs.writeFile( + shim, + '@node "%~dp0\\..\\.pnpm\\typescript@7.0.0\\node_modules\\typescript\\bin\\tsc.js" %*\n', + ) + } + else { + await fs.symlink(path.relative(binDir, target), shim) + } + + expect(resolveNativeTypeScriptServer(base)?.command).toBe(shim) + } + finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + test('returns undefined when native package exists but no .bin/tsc', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-no-bin-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + // No .bin/tsc and no tsgo bin. + expect(resolveNativeTypeScriptServer(dir)).toBeUndefined() + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('detects native typescript hoisted in a parent node_modules', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-monorepo-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + + const pkgDir = path.join(dir, 'packages', 'app', 'src') + await fs.mkdir(pkgDir, { recursive: true }) + + expect(resolveNativeTypeScriptServer(pkgDir)?.command).toBe(tsc) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +})