From a21765963f3ba86f5c940dc848f395322e08c0e0 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 14:05:17 +0900 Subject: [PATCH 1/8] feat(lsp): prefer tsrs for the TypeScript LSP when installed --- CLAUDE.md | 2 +- .../docs/3.configuration/1.overview.md | 16 +++++ .../docs/4.reference/2.supported-languages.md | 6 +- packages/lsp/CLAUDE.md | 4 ++ packages/lsp/src/server/typescript.ts | 69 +++++++++++++++---- .../lsp/test/unit/typescript-native.test.ts | 62 +++++++++++++++++ 6 files changed, 141 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 461e607..510b8b6 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 (auto-upgrades to native TypeScript 7 `tsc --lsp` when the project ships it) | package-lock.json, bun.lock, etc. | +| TypeScript/JavaScript | typescript-language-server (auto-upgrades to `tsrs --lsp` or 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 250e0b3..2dd1215 100644 --- a/apps/docs/content/docs/3.configuration/1.overview.md +++ b/apps/docs/content/docs/3.configuration/1.overview.md @@ -105,6 +105,22 @@ native TypeScript 7 build in your project: npm install --save-dev @typescript/native-preview ``` +### tsrs + +[tsrs](https://github.com/oxc-project/tsrs), the Rust port of TypeScript 7, +speaks the same `--lsp --stdio` protocol as `tsgo`. When the project has `tsrs` +installed, it takes priority over `tsc` and `tsgo`: + +```bash +npm install --save-dev tsrs +``` + +tsrs runs as a native binary from a per-platform optional dependency +(`@tsrs/`). If no binary for your platform is installed (for example +on macOS x64, which tsrs does not support) and `TSRS_BINARY` is not set to the +path of an existing tsrs binary, the server skips tsrs and falls back to `tsc`, +`tsgo`, or the classic server. + ## 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 04d9b03..588d6a6 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 | 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 | +| TypeScript | Biome, Prettier | `tsrs` / native `tsc` / `tsgo` (TypeScript 7, auto-selected) or typescript-language-server | +| JavaScript | Biome, Prettier | `tsrs` / native `tsc` / `tsgo` (TypeScript 7, auto-selected) or typescript-language-server | | Python | ruff, uv | pyright | | Go | gofmt | gopls | | Rust | - | rust-analyzer | @@ -34,7 +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` | +| tsrs / 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 4d1866d..b67a406 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -240,6 +240,10 @@ falls back to the classic `typescript-language-server --stdio`. There is no sepa 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`). +A project-local `tsrs` (the Rust port of TypeScript 7, same `--lsp --stdio` protocol) +takes priority over `tsc`/`tsgo`, but only when its native binary is available — +`TSRS_BINARY`, or an installed `@tsrs/*` platform package matching this os/cpu; +otherwise (e.g. darwin-x64, unsupported) resolution falls through. **Config Utilities:** ```typescript diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index 0cd8fda..3602fd0 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -1,11 +1,6 @@ /** * 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 @@ -15,6 +10,7 @@ import type { LSPServerInfo } from './types' import { spawn } from 'node:child_process' import fs from 'node:fs' +import { createRequire } from 'node:module' import path from 'node:path' import process from 'node:process' import { attachLSPProcessHandlers, log, nearestRoot } from './utils' @@ -100,6 +96,54 @@ function isNativeTypeScriptPackage(packageRoot: string): boolean { return !fs.existsSync(path.join(libRoot, 'tsserver.js')) } +function hasTsrsBinary(packageRoot: string): boolean { + const override = process.env.TSRS_BINARY + if (override) { return fs.existsSync(override) } + try { + const realRoot = fs.realpathSync(packageRoot) + const pkg = JSON.parse( + fs.readFileSync(path.join(realRoot, 'package.json'), 'utf8'), + ) as { optionalDependencies?: Record } + const require = createRequire(path.join(realRoot, 'package.json')) + const exeName = process.platform === 'win32' ? 'tsrs.exe' : 'tsrs' + return Object.keys(pkg.optionalDependencies ?? {}).some((name) => { + try { + const platformPkgJson = require.resolve(`${name}/package.json`) + const platformPkg = JSON.parse(fs.readFileSync(platformPkgJson, 'utf8')) as { + os?: string[] + cpu?: string[] + } + return (platformPkg.os?.includes(process.platform) ?? true) + && (platformPkg.cpu?.includes(process.arch) ?? true) + && fs.existsSync(path.join(path.dirname(platformPkgJson), exeName)) + } + catch { + return false + } + }) + } + catch { + return false + } +} + +function findPackageBin( + start: string, + packageName: string, + name: string, + isUsable: (packageRoot: string) => boolean = () => true, +): string | undefined { + let dir = path.resolve(start) + while (true) { + const packageRoot = path.join(dir, 'node_modules', ...packageName.split('/')) + const bin = resolvePackageBin(packageRoot, name) + if (bin && isUsable(packageRoot)) { return bin } + const parent = path.dirname(dir) + if (parent === dir) { return undefined } + dir = parent + } +} + /** * Resolve a project-local native TypeScript 7 LSP command for `root`, if the * workspace provides one. Exported for testing. @@ -107,6 +151,9 @@ function isNativeTypeScriptPackage(packageRoot: string): boolean { export function resolveNativeTypeScriptServer( root: string, ): { command: string, label: string } | undefined { + const tsrs = findPackageBin(root, 'tsrs', 'tsrs', hasTsrsBinary) + if (tsrs) { return { command: tsrs, label: 'tsrs' } } + // Native `typescript` package (TS 7+) → its node_modules/.bin/tsc. const packageRoot = findTypeScriptPackageRoot(root) if (packageRoot && isNativeTypeScriptPackage(packageRoot)) { @@ -115,15 +162,9 @@ export function resolveNativeTypeScriptServer( } // `@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 - } + const tsgo = findPackageBin(root, '@typescript/native-preview', 'tsgo') + if (tsgo) { return { command: tsgo, label: 'native tsgo' } } + return undefined } export const TypescriptServer: LSPServerInfo = { diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index 9ea7b5a..bdf4b08 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -54,6 +54,25 @@ async function writeTypeScriptPackage( } } +async function makeTsrs(dir: string, opts: { platformBinary: boolean }): Promise { + const bin = await makeBin(dir, 'tsrs', 'tsrs') + const platformName = '@tsrs/test-platform' + const pkgJson = path.join(dir, 'node_modules', 'tsrs', 'package.json') + const pkg = JSON.parse(await fs.readFile(pkgJson, 'utf8')) as Record + pkg.optionalDependencies = { [platformName]: '1.0.0' } + await fs.writeFile(pkgJson, JSON.stringify(pkg)) + if (opts.platformBinary) { + const platformDir = path.join(dir, 'node_modules', ...platformName.split('/')) + await fs.mkdir(platformDir, { recursive: true }) + await fs.writeFile( + path.join(platformDir, 'package.json'), + JSON.stringify({ name: platformName, version: '1.0.0', os: [process.platform], cpu: [process.arch] }), + ) + await fs.writeFile(path.join(platformDir, process.platform === 'win32' ? 'tsrs.exe' : 'tsrs'), '') + } + return bin +} + describe('resolveNativeTypeScriptServer', () => { test('returns undefined for a classic TypeScript 5 project', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-classic-')) @@ -120,6 +139,49 @@ describe('resolveNativeTypeScriptServer', () => { } }) + test('prefers tsrs over native tsc and tsgo when installed', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-tsrs-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + await makeBin(dir, 'tsc') + await makeBin(dir, 'tsgo') + const tsrs = await makeTsrs(dir, { platformBinary: true }) + const result = resolveNativeTypeScriptServer(dir) + expect(result?.command).toBe(tsrs) + expect(result?.label).toBe('tsrs') + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('skips tsrs when no platform binary is installed', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-tsrs-unsupported-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + await makeTsrs(dir, { platformBinary: false }) + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('detects tsrs hoisted in a parent node_modules of a classic project', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-tsrs-monorepo-')) + try { + await writeTypeScriptPackage(dir, { version: '5.7.0', tsserver: true }) + const tsrs = await makeTsrs(dir, { platformBinary: true }) + const pkgDir = path.join(dir, 'packages', 'app') + await fs.mkdir(pkgDir, { recursive: true }) + expect(resolveNativeTypeScriptServer(pkgDir)?.command).toBe(tsrs) + } + 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') From 1debac7e3b4467c61f1cd1b807b43d7cb5effc40 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 14:08:42 +0900 Subject: [PATCH 2/8] feat(lsp): fall back to a global tsrs on PATH --- .../docs/3.configuration/1.overview.md | 9 +++ packages/lsp/CLAUDE.md | 4 +- packages/lsp/src/server/typescript.ts | 27 +++++++++ .../lsp/test/unit/typescript-native.test.ts | 58 ++++++++++++++++++- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/3.configuration/1.overview.md b/apps/docs/content/docs/3.configuration/1.overview.md index 2dd1215..1efef5d 100644 --- a/apps/docs/content/docs/3.configuration/1.overview.md +++ b/apps/docs/content/docs/3.configuration/1.overview.md @@ -121,6 +121,15 @@ on macOS x64, which tsrs does not support) and `TSRS_BINARY` is not set to the path of an existing tsrs binary, the server skips tsrs and falls back to `tsc`, `tsgo`, or the classic server. +If the project has none of `tsrs`, a native `tsc`, or `tsgo` installed, the +server uses a `tsrs` on your `PATH` before falling back to the classic server. +A globally installed npm `tsrs` launcher is used only when its platform binary +is installed: + +```bash +npm install --global tsrs +``` + ## Environment Variables | Variable | Description | Default | diff --git a/packages/lsp/CLAUDE.md b/packages/lsp/CLAUDE.md index b67a406..799fa13 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -243,7 +243,9 @@ the client handles transparently alongside push (`publishDiagnostics`). A project-local `tsrs` (the Rust port of TypeScript 7, same `--lsp --stdio` protocol) takes priority over `tsc`/`tsgo`, but only when its native binary is available — `TSRS_BINARY`, or an installed `@tsrs/*` platform package matching this os/cpu; -otherwise (e.g. darwin-x64, unsupported) resolution falls through. +otherwise (e.g. darwin-x64, unsupported) resolution falls through. When no +project-local tsrs/tsc/tsgo exists, a `tsrs` on `PATH` is used before the classic +server (an npm launcher on `PATH` gets the same platform-binary check). **Config Utilities:** ```typescript diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index 3602fd0..dee59a6 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -164,9 +164,36 @@ export function resolveNativeTypeScriptServer( // `@typescript/native-preview` installs a `tsgo` bin (can coexist with TS <= 6). const tsgo = findPackageBin(root, '@typescript/native-preview', 'tsgo') if (tsgo) { return { command: tsgo, label: 'native tsgo' } } + + const globalTsrs = resolveGlobalTsrs() + if (globalTsrs) { return { command: globalTsrs, label: 'global tsrs' } } return undefined } +function resolveGlobalTsrs(): string | undefined { + const bin = Bun.which('tsrs', { PATH: process.env.PATH ?? '' }) + if (!bin) { return undefined } + // An npm-installed launcher (symlinked `/bin/tsrs` or Windows `/tsrs.cmd`) + // only works with its platform binary; anything else on PATH is the binary itself. + const realBin = fs.realpathSync(bin) + const candidates = [ + path.dirname(path.dirname(realBin)), + path.join(path.dirname(bin), 'node_modules', 'tsrs'), + ] + for (const packageRoot of candidates) { + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), + ) as { name?: unknown } + if (pkg.name === 'tsrs') { return hasTsrsBinary(packageRoot) ? bin : undefined } + } + catch { + // Not an npm package root. + } + } + return bin +} + export const TypescriptServer: LSPServerInfo = { id: 'typescript', extensions: TS_EXTENSIONS, diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index bdf4b08..018180f 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { resolveNativeTypeScriptServer } from '../../src/server/typescript' async function makeBin( @@ -74,6 +74,62 @@ async function makeTsrs(dir: string, opts: { platformBinary: boolean }): Promise } describe('resolveNativeTypeScriptServer', () => { + const originalPath = process.env.PATH + let emptyPathDir: string + beforeEach(async () => { + emptyPathDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-empty-path-')) + process.env.PATH = emptyPathDir + }) + afterEach(async () => { + process.env.PATH = originalPath + await fs.rm(emptyPathDir, { recursive: true, force: true }) + }) + + test('falls back to a native tsrs binary on PATH when no project-local server exists', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-tsrs-')) + try { + await writeTypeScriptPackage(dir, { version: '5.7.0', tsserver: true }) + const globalTsrs = path.join(emptyPathDir, process.platform === 'win32' ? 'tsrs.exe' : 'tsrs') + await fs.writeFile(globalTsrs, '') + await fs.chmod(globalTsrs, 0o755) + const result = resolveNativeTypeScriptServer(dir) + expect(result?.command).toBe(globalTsrs) + expect(result?.label).toBe('global tsrs') + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('skips a global npm tsrs launcher without a platform binary', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-tsrs-npm-')) + const prefix = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-prefix-')) + try { + const launcher = await makeTsrs(prefix, { platformBinary: false }) + process.env.PATH = path.dirname(launcher) + expect(resolveNativeTypeScriptServer(dir)).toBeUndefined() + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(prefix, { recursive: true, force: true }) + } + }) + + test('prefers project-local native tsc over a global tsrs', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-local-over-global-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + const globalTsrs = path.join(emptyPathDir, process.platform === 'win32' ? 'tsrs.exe' : 'tsrs') + await fs.writeFile(globalTsrs, '') + await fs.chmod(globalTsrs, 0o755) + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + test('returns undefined for a classic TypeScript 5 project', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-classic-')) try { From 3787df7e72fe42c75ac55552aa139fb4a6c89a81 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 14:16:38 +0900 Subject: [PATCH 3/8] fix(lsp): match the launcher's libc-specific tsrs package and verify wrapper scripts on PATH --- .please/docs/review/rejected-findings.jsonl | 6 ++ packages/lsp/src/server/typescript.ts | 94 +++++++++++++++---- .../lsp/test/unit/typescript-native.test.ts | 49 +++++++++- 3 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 .please/docs/review/rejected-findings.jsonl diff --git a/.please/docs/review/rejected-findings.jsonl b/.please/docs/review/rejected-findings.jsonl new file mode 100644 index 0000000..611f115 --- /dev/null +++ b/.please/docs/review/rejected-findings.jsonl @@ -0,0 +1,6 @@ +{"key":"357f57fbb74bf752","file":"packages/lsp/src/server/typescript.ts","title":"File header lost the description that 'Selection happens...' refers to","reason":"comment-only wording, no observable effect","rejected_at":"2026-10-21T05:13:51Z","source":"code-review"} +{"key":"91eeba00947a708d","file":"packages/lsp/src/server/typescript.ts","title":"require.resolve of '/package.json' fails under restrictive exports","reason":"launcher resolves the same path; @tsrs/* export ./package.json","rejected_at":"2026-10-21T05:13:51Z","source":"code-review"} +{"key":"9bd2de4db29719e3","file":"packages/lsp/src/server/typescript.ts","title":"Platform match ignores npm negated os/cpu entries","reason":"no @tsrs/* package uses negated os/cpu; hypothetical","rejected_at":"2026-10-21T05:13:51Z","source":"code-review"} +{"key":"f8d7cf8167215402","file":"packages/lsp/src/server/typescript.ts","title":"Platform binary assumed at platform package root","reason":"factually wrong: launcher uses /tsrs at the package root","rejected_at":"2026-10-21T05:13:52Z","source":"code-review"} +{"key":"34608b033351bfc3","file":"packages/lsp/src/server/typescript.ts","title":"Global tsrs fallback replaces typescript-language-server for TS 5/6 projects","reason":"by design: global PATH fallback is the stated purpose of this change","rejected_at":"2026-10-21T05:13:52Z","source":"code-review"} +{"key":"ba5d4e1377242670","file":"packages/lsp/src/server/typescript.ts","title":"npm launcher detection assumes the bin is exactly one dir below package root","reason":"hypothetical layout; real package declares bin ./bin/tsrs","rejected_at":"2026-10-21T05:13:52Z","source":"code-review"} diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index dee59a6..6c2790a 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -8,6 +8,7 @@ */ import type { LSPServerInfo } from './types' +import { Buffer } from 'node:buffer' import { spawn } from 'node:child_process' import fs from 'node:fs' import { createRequire } from 'node:module' @@ -96,37 +97,82 @@ function isNativeTypeScriptPackage(packageRoot: string): boolean { return !fs.existsSync(path.join(libRoot, 'tsserver.js')) } +function isMuslLinux(): boolean { + try { + return fs.readFileSync('/usr/bin/ldd', 'utf8').includes('musl') + } + catch { + // Fall through to the process report. + } + try { + const report = process.report?.getReport() as unknown as { + header?: { glibcVersionRuntime?: string } + sharedObjects?: string[] + } | undefined + if (report?.header?.glibcVersionRuntime) { return false } + if (report?.sharedObjects?.some(o => o.includes('libc.musl-') || o.includes('ld-musl-'))) { return true } + } + catch { + // Unknown host: the launcher defaults to GNU. + } + return false +} + +/** The single platform package the tsrs launcher resolves for this host. Exported for testing. */ +export function getTsrsPlatformPackageName(): string { + const suffix = process.platform === 'linux' ? (isMuslLinux() ? '-musl' : '-gnu') : '' + return `@tsrs/${process.platform}-${process.arch}${suffix}` +} + function hasTsrsBinary(packageRoot: string): boolean { const override = process.env.TSRS_BINARY if (override) { return fs.existsSync(override) } try { const realRoot = fs.realpathSync(packageRoot) - const pkg = JSON.parse( - fs.readFileSync(path.join(realRoot, 'package.json'), 'utf8'), - ) as { optionalDependencies?: Record } const require = createRequire(path.join(realRoot, 'package.json')) const exeName = process.platform === 'win32' ? 'tsrs.exe' : 'tsrs' - return Object.keys(pkg.optionalDependencies ?? {}).some((name) => { - try { - const platformPkgJson = require.resolve(`${name}/package.json`) - const platformPkg = JSON.parse(fs.readFileSync(platformPkgJson, 'utf8')) as { - os?: string[] - cpu?: string[] - } - return (platformPkg.os?.includes(process.platform) ?? true) - && (platformPkg.cpu?.includes(process.arch) ?? true) - && fs.existsSync(path.join(path.dirname(platformPkgJson), exeName)) - } - catch { - return false - } - }) + const platformPkgJson = require.resolve(`${getTsrsPlatformPackageName()}/package.json`) + return fs.existsSync(path.join(path.dirname(platformPkgJson), exeName)) } catch { return false } } +function isNativeExecutable(file: string): boolean { + const header = Buffer.alloc(4) + const fd = fs.openSync(file, 'r') + try { + if (fs.readSync(fd, header, 0, 4, 0) < 4) { return false } + } + finally { + fs.closeSync(fd) + } + const hex = header.toString('hex') + return hex === '7f454c46' // ELF + || ['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe'].includes(hex) // Mach-O + || hex.startsWith('4d5a') // PE +} + +/** Locate the tsrs package a wrapper script launches, from `node_modules/tsrs` paths in its text. */ +function findWrapperPackageRoot(wrapper: string, bin: string): string | undefined { + const dir = path.dirname(bin) + const text = wrapper.replace(/\$\{?basedir\}?|%~dp0|%dp0%/g, dir) + for (const match of text.matchAll(/([^\s"'`=:;]*node_modules[\\/]tsrs)[\\/]/g)) { + const packageRoot = path.resolve(dir, match[1]!) + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), + ) as { name?: unknown } + if (pkg.name === 'tsrs') { return packageRoot } + } + catch { + // Not a package root. + } + } + return undefined +} + function findPackageBin( start: string, packageName: string, @@ -174,7 +220,7 @@ function resolveGlobalTsrs(): string | undefined { const bin = Bun.which('tsrs', { PATH: process.env.PATH ?? '' }) if (!bin) { return undefined } // An npm-installed launcher (symlinked `/bin/tsrs` or Windows `/tsrs.cmd`) - // only works with its platform binary; anything else on PATH is the binary itself. + // only works with its platform binary; a native executable is used as-is and a wrapper script is verified. const realBin = fs.realpathSync(bin) const candidates = [ path.dirname(path.dirname(realBin)), @@ -191,7 +237,15 @@ function resolveGlobalTsrs(): string | undefined { // Not an npm package root. } } - return bin + try { + if (isNativeExecutable(realBin)) { return bin } + // A wrapper script (e.g. pnpm) is only usable when the tsrs package it launches has its binary. + const packageRoot = findWrapperPackageRoot(fs.readFileSync(realBin, 'utf8'), bin) + return packageRoot && hasTsrsBinary(packageRoot) ? bin : undefined + } + catch { + return undefined + } } export const TypescriptServer: LSPServerInfo = { diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index 018180f..fa8cf66 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -1,8 +1,9 @@ +import { Buffer } from 'node:buffer' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { resolveNativeTypeScriptServer } from '../../src/server/typescript' +import { getTsrsPlatformPackageName, resolveNativeTypeScriptServer } from '../../src/server/typescript' async function makeBin( dir: string, @@ -54,12 +55,14 @@ async function writeTypeScriptPackage( } } -async function makeTsrs(dir: string, opts: { platformBinary: boolean }): Promise { +async function makeTsrs( + dir: string, + opts: { platformBinary: boolean, platformName?: string }, +): Promise { const bin = await makeBin(dir, 'tsrs', 'tsrs') - const platformName = '@tsrs/test-platform' + const platformName = opts.platformName ?? getTsrsPlatformPackageName() const pkgJson = path.join(dir, 'node_modules', 'tsrs', 'package.json') const pkg = JSON.parse(await fs.readFile(pkgJson, 'utf8')) as Record - pkg.optionalDependencies = { [platformName]: '1.0.0' } await fs.writeFile(pkgJson, JSON.stringify(pkg)) if (opts.platformBinary) { const platformDir = path.join(dir, 'node_modules', ...platformName.split('/')) @@ -90,7 +93,7 @@ describe('resolveNativeTypeScriptServer', () => { try { await writeTypeScriptPackage(dir, { version: '5.7.0', tsserver: true }) const globalTsrs = path.join(emptyPathDir, process.platform === 'win32' ? 'tsrs.exe' : 'tsrs') - await fs.writeFile(globalTsrs, '') + await fs.writeFile(globalTsrs, Buffer.from('7f454c46', 'hex')) await fs.chmod(globalTsrs, 0o755) const result = resolveNativeTypeScriptServer(dir) expect(result?.command).toBe(globalTsrs) @@ -115,6 +118,29 @@ describe('resolveNativeTypeScriptServer', () => { } }) + test('skips a global wrapper-script tsrs launcher without a platform binary', async () => { + if (process.platform === 'win32') { return } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-tsrs-wrapper-')) + const prefix = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-wrapper-prefix-')) + try { + await makeTsrs(prefix, { platformBinary: false }) + const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-wrapper-bin-')) + const wrapper = path.join(wrapperDir, 'tsrs') + await fs.writeFile( + wrapper, + `#!/bin/sh\nexec node "${path.join(prefix, 'node_modules', 'tsrs', 'bin', 'tsrs.js')}" "$@"\n`, + ) + await fs.chmod(wrapper, 0o755) + process.env.PATH = wrapperDir + expect(resolveNativeTypeScriptServer(dir)).toBeUndefined() + await fs.rm(wrapperDir, { recursive: true, force: true }) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(prefix, { recursive: true, force: true }) + } + }) + test('prefers project-local native tsc over a global tsrs', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-local-over-global-')) try { @@ -224,6 +250,19 @@ describe('resolveNativeTypeScriptServer', () => { } }) + test('skips tsrs when only a different platform package is installed', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-tsrs-wrong-platform-')) + try { + await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) + const tsc = await makeBin(dir, 'tsc') + await makeTsrs(dir, { platformBinary: true, platformName: '@tsrs/other-platform' }) + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + test('detects tsrs hoisted in a parent node_modules of a classic project', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts7-tsrs-monorepo-')) try { From ef6e7fedd4c78d5a5e138cc26fd6e3b2888a5cba Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 14:24:28 +0900 Subject: [PATCH 4/8] fix(lsp): resolve relative TSRS_BINARY from the project root and parse quoted wrapper paths --- .please/docs/review/rejected-findings.jsonl | 3 ++ packages/lsp/src/server/typescript.ts | 28 +++++++------ .../lsp/test/unit/typescript-native.test.ts | 39 +++++++++++++++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/.please/docs/review/rejected-findings.jsonl b/.please/docs/review/rejected-findings.jsonl index 611f115..f3288ca 100644 --- a/.please/docs/review/rejected-findings.jsonl +++ b/.please/docs/review/rejected-findings.jsonl @@ -4,3 +4,6 @@ {"key":"f8d7cf8167215402","file":"packages/lsp/src/server/typescript.ts","title":"Platform binary assumed at platform package root","reason":"factually wrong: launcher uses /tsrs at the package root","rejected_at":"2026-10-21T05:13:52Z","source":"code-review"} {"key":"34608b033351bfc3","file":"packages/lsp/src/server/typescript.ts","title":"Global tsrs fallback replaces typescript-language-server for TS 5/6 projects","reason":"by design: global PATH fallback is the stated purpose of this change","rejected_at":"2026-10-21T05:13:52Z","source":"code-review"} {"key":"ba5d4e1377242670","file":"packages/lsp/src/server/typescript.ts","title":"npm launcher detection assumes the bin is exactly one dir below package root","reason":"hypothetical layout; real package declares bin ./bin/tsrs","rejected_at":"2026-10-21T05:13:52Z","source":"code-review"} +{"key":"df867bf51b4eb150","file":"packages/lsp/src/server/typescript.ts","title":"tsrs binary discovery is added to the TypeScript server definition file","reason":"file-organization preference, no observable effect","rejected_at":"2026-10-21T05:23:13Z","source":"code-review"} +{"key":"d5926b6868704560","file":"packages/lsp/src/server/typescript.ts","title":"Same package.json name check is written twice","reason":"refactoring suggestion, no observable effect","rejected_at":"2026-10-21T05:23:13Z","source":"code-review"} +{"key":"bc30ad4d8fa4c35e","file":"packages/lsp/src/server/typescript.ts","title":"Version-manager shell shims are rejected even when tsrs works","reason":"by design: unverifiable wrappers fail closed to the classic server","rejected_at":"2026-10-21T05:23:13Z","source":"code-review"} diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index 6c2790a..d80bcb5 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -124,9 +124,10 @@ export function getTsrsPlatformPackageName(): string { return `@tsrs/${process.platform}-${process.arch}${suffix}` } -function hasTsrsBinary(packageRoot: string): boolean { +function hasTsrsBinary(packageRoot: string, root: string): boolean { const override = process.env.TSRS_BINARY - if (override) { return fs.existsSync(override) } + // The launcher runs with cwd = root, so a relative override resolves against it. + if (override) { return fs.existsSync(path.resolve(root, override)) } try { const realRoot = fs.realpathSync(packageRoot) const require = createRequire(path.join(realRoot, 'package.json')) @@ -158,8 +159,13 @@ function isNativeExecutable(file: string): boolean { function findWrapperPackageRoot(wrapper: string, bin: string): string | undefined { const dir = path.dirname(bin) const text = wrapper.replace(/\$\{?basedir\}?|%~dp0|%dp0%/g, dir) - for (const match of text.matchAll(/([^\s"'`=:;]*node_modules[\\/]tsrs)[\\/]/g)) { - const packageRoot = path.resolve(dir, match[1]!) + // Quoted strings may contain spaces (e.g. an expanded $basedir); unquoted arguments cannot. + const quoted = [...text.matchAll(/"([^"]*)"|'([^']*)'/g)] + .map(m => /(.*?node_modules[\\/]tsrs)[\\/]/.exec(m[1] ?? m[2] ?? '')?.[1]) + const unquoted = [...text.matchAll(/([^\s"'`=:;]*node_modules[\\/]tsrs)[\\/]/g)].map(m => m[1]) + for (const candidate of [...quoted, ...unquoted]) { + if (!candidate) { continue } + const packageRoot = path.resolve(dir, candidate) try { const pkg = JSON.parse( fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), @@ -191,13 +197,13 @@ function findPackageBin( } /** - * Resolve a project-local native TypeScript 7 LSP command for `root`, if the - * workspace provides one. Exported for testing. + * Resolve a native TypeScript 7 LSP command for `root`: a project-local one if + * the workspace provides it, else a global `tsrs` on PATH. Exported for testing. */ export function resolveNativeTypeScriptServer( root: string, ): { command: string, label: string } | undefined { - const tsrs = findPackageBin(root, 'tsrs', 'tsrs', hasTsrsBinary) + const tsrs = findPackageBin(root, 'tsrs', 'tsrs', packageRoot => hasTsrsBinary(packageRoot, root)) if (tsrs) { return { command: tsrs, label: 'tsrs' } } // Native `typescript` package (TS 7+) → its node_modules/.bin/tsc. @@ -211,12 +217,12 @@ export function resolveNativeTypeScriptServer( const tsgo = findPackageBin(root, '@typescript/native-preview', 'tsgo') if (tsgo) { return { command: tsgo, label: 'native tsgo' } } - const globalTsrs = resolveGlobalTsrs() + const globalTsrs = resolveGlobalTsrs(root) if (globalTsrs) { return { command: globalTsrs, label: 'global tsrs' } } return undefined } -function resolveGlobalTsrs(): string | undefined { +function resolveGlobalTsrs(root: string): string | undefined { const bin = Bun.which('tsrs', { PATH: process.env.PATH ?? '' }) if (!bin) { return undefined } // An npm-installed launcher (symlinked `/bin/tsrs` or Windows `/tsrs.cmd`) @@ -231,7 +237,7 @@ function resolveGlobalTsrs(): string | undefined { const pkg = JSON.parse( fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'), ) as { name?: unknown } - if (pkg.name === 'tsrs') { return hasTsrsBinary(packageRoot) ? bin : undefined } + if (pkg.name === 'tsrs') { return hasTsrsBinary(packageRoot, root) ? bin : undefined } } catch { // Not an npm package root. @@ -241,7 +247,7 @@ function resolveGlobalTsrs(): string | undefined { if (isNativeExecutable(realBin)) { return bin } // A wrapper script (e.g. pnpm) is only usable when the tsrs package it launches has its binary. const packageRoot = findWrapperPackageRoot(fs.readFileSync(realBin, 'utf8'), bin) - return packageRoot && hasTsrsBinary(packageRoot) ? bin : undefined + return packageRoot && hasTsrsBinary(packageRoot, root) ? bin : undefined } catch { return undefined diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index fa8cf66..084f312 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -141,6 +141,45 @@ describe('resolveNativeTypeScriptServer', () => { } }) + test('resolves a relative TSRS_BINARY against the project root', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-rel-override-')) + const original = process.env.TSRS_BINARY + try { + const launcher = await makeTsrs(dir, { platformBinary: false }) + await fs.writeFile(path.join(dir, 'custom-tsrs'), '') + process.env.TSRS_BINARY = 'custom-tsrs' + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(launcher) + } + finally { + if (original === undefined) { delete process.env.TSRS_BINARY } + else { process.env.TSRS_BINARY = original } + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + test('resolves a wrapper script in a PATH directory containing spaces', async () => { + if (process.platform === 'win32') { return } + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-space-project-')) + const prefix = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-space-prefix-')) + const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-space-parent-')) + try { + await makeTsrs(prefix, { platformBinary: true }) + const wrapperDir = path.join(parent, 'Jane Doe', 'pnpm') + await fs.mkdir(wrapperDir, { recursive: true }) + const wrapper = path.join(wrapperDir, 'tsrs') + const rel = path.relative(wrapperDir, path.join(prefix, 'node_modules', 'tsrs', 'bin', 'tsrs.js')) + await fs.writeFile(wrapper, `#!/bin/sh\nbasedir=$(dirname "$0")\nexec node "$basedir/${rel}" "$@"\n`) + await fs.chmod(wrapper, 0o755) + process.env.PATH = wrapperDir + expect(resolveNativeTypeScriptServer(dir)?.command).toBe(wrapper) + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(prefix, { recursive: true, force: true }) + await fs.rm(parent, { recursive: true, force: true }) + } + }) + test('prefers project-local native tsc over a global tsrs', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-local-over-global-')) try { From 2e25ec2b416493acd7d91ce114965d8b9a244d79 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 14:30:19 +0900 Subject: [PATCH 5/8] fix(lsp): quote the Windows spawn command and make the precedence test exercise ordering --- .please/docs/review/rejected-findings.jsonl | 3 +++ packages/lsp/src/server/typescript.ts | 2 +- packages/lsp/test/unit/typescript-native.test.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.please/docs/review/rejected-findings.jsonl b/.please/docs/review/rejected-findings.jsonl index f3288ca..1999df7 100644 --- a/.please/docs/review/rejected-findings.jsonl +++ b/.please/docs/review/rejected-findings.jsonl @@ -7,3 +7,6 @@ {"key":"df867bf51b4eb150","file":"packages/lsp/src/server/typescript.ts","title":"tsrs binary discovery is added to the TypeScript server definition file","reason":"file-organization preference, no observable effect","rejected_at":"2026-10-21T05:23:13Z","source":"code-review"} {"key":"d5926b6868704560","file":"packages/lsp/src/server/typescript.ts","title":"Same package.json name check is written twice","reason":"refactoring suggestion, no observable effect","rejected_at":"2026-10-21T05:23:13Z","source":"code-review"} {"key":"bc30ad4d8fa4c35e","file":"packages/lsp/src/server/typescript.ts","title":"Version-manager shell shims are rejected even when tsrs works","reason":"by design: unverifiable wrappers fail closed to the classic server","rejected_at":"2026-10-21T05:23:13Z","source":"code-review"} +{"key":"92a9cbea7b1a2019","file":"packages/lsp/src/server/typescript.ts","title":"Cache platform package name instead of recomputing per candidate","reason":"performance nit, no observable effect","rejected_at":"2026-10-21T05:29:22Z","source":"code-review"} +{"key":"3ff0e688c39b8ca0","file":"packages/lsp/src/server/typescript.ts","title":"TSRS_BINARY override short-circuits the platform-binary check","reason":"by design: launcher exits when TSRS_BINARY is set but missing, so falling through is correct","rejected_at":"2026-10-21T05:29:22Z","source":"code-review"} +{"key":"7c1de032e7ea5661","file":".please/docs/review/rejected-findings.jsonl","title":"Remove committed review-tool ledger from the branch","reason":"by design: tracked ledger is folded into fix commits by review-fix-loop","rejected_at":"2026-10-21T05:29:22Z","source":"code-review"} diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index d80bcb5..19ef631 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -272,7 +272,7 @@ export const TypescriptServer: LSPServerInfo = { 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'], { + const proc = spawn(process.platform === 'win32' ? `"${native.command}"` : native.command, ['--lsp', '--stdio'], { cwd: root, env: { ...process.env, BUN_BE_BUN: '1' }, shell: process.platform === 'win32', diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index 084f312..99709fe 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -186,7 +186,7 @@ describe('resolveNativeTypeScriptServer', () => { await writeTypeScriptPackage(dir, { version: '7.0.0', tsserver: false }) const tsc = await makeBin(dir, 'tsc') const globalTsrs = path.join(emptyPathDir, process.platform === 'win32' ? 'tsrs.exe' : 'tsrs') - await fs.writeFile(globalTsrs, '') + await fs.writeFile(globalTsrs, Buffer.from('7f454c46', 'hex')) await fs.chmod(globalTsrs, 0o755) expect(resolveNativeTypeScriptServer(dir)?.command).toBe(tsc) } From f6b1ada8c72af34e6111a46f023a0554333b5f78 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 16:06:12 +0900 Subject: [PATCH 6/8] fix(lsp): guard the global tsrs realpath and isolate tests from an ambient TSRS_BINARY A PATH entry that disappears between Bun.which and realpathSync now falls back instead of throwing out of spawn. The typescript-native tests clear TSRS_BINARY so a developer's exported override cannot change their results, and packages/lsp/CLAUDE.md now describes the single launcher-resolved platform package. --- packages/lsp/CLAUDE.md | 4 ++-- packages/lsp/src/server/typescript.ts | 8 +++++++- packages/lsp/test/unit/typescript-native.test.ts | 7 ++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/lsp/CLAUDE.md b/packages/lsp/CLAUDE.md index 799fa13..9b09c79 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -242,8 +242,8 @@ servers report diagnostics via the LSP pull model (`textDocument/diagnostic`), w the client handles transparently alongside push (`publishDiagnostics`). A project-local `tsrs` (the Rust port of TypeScript 7, same `--lsp --stdio` protocol) takes priority over `tsc`/`tsgo`, but only when its native binary is available — -`TSRS_BINARY`, or an installed `@tsrs/*` platform package matching this os/cpu; -otherwise (e.g. darwin-x64, unsupported) resolution falls through. When no +`TSRS_BINARY` (resolved against the project root), or the one `@tsrs/-[-gnu|-musl]` +platform package the tsrs launcher itself would load, with its binary present; otherwise (e.g. darwin-x64, unsupported) resolution falls through. When no project-local tsrs/tsc/tsgo exists, a `tsrs` on `PATH` is used before the classic server (an npm launcher on `PATH` gets the same platform-binary check). diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index 19ef631..7da966d 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -227,7 +227,13 @@ function resolveGlobalTsrs(root: string): string | undefined { if (!bin) { return undefined } // An npm-installed launcher (symlinked `/bin/tsrs` or Windows `/tsrs.cmd`) // only works with its platform binary; a native executable is used as-is and a wrapper script is verified. - const realBin = fs.realpathSync(bin) + let realBin: string + try { + realBin = fs.realpathSync(bin) + } + catch { + return undefined + } const candidates = [ path.dirname(path.dirname(realBin)), path.join(path.dirname(bin), 'node_modules', 'tsrs'), diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index 99709fe..3676fe9 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -78,13 +78,17 @@ async function makeTsrs( describe('resolveNativeTypeScriptServer', () => { const originalPath = process.env.PATH + const originalTsrsBinary = process.env.TSRS_BINARY let emptyPathDir: string beforeEach(async () => { emptyPathDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-empty-path-')) process.env.PATH = emptyPathDir + delete process.env.TSRS_BINARY }) afterEach(async () => { process.env.PATH = originalPath + if (originalTsrsBinary === undefined) { delete process.env.TSRS_BINARY } + else { process.env.TSRS_BINARY = originalTsrsBinary } await fs.rm(emptyPathDir, { recursive: true, force: true }) }) @@ -143,7 +147,6 @@ describe('resolveNativeTypeScriptServer', () => { test('resolves a relative TSRS_BINARY against the project root', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-rel-override-')) - const original = process.env.TSRS_BINARY try { const launcher = await makeTsrs(dir, { platformBinary: false }) await fs.writeFile(path.join(dir, 'custom-tsrs'), '') @@ -151,8 +154,6 @@ describe('resolveNativeTypeScriptServer', () => { expect(resolveNativeTypeScriptServer(dir)?.command).toBe(launcher) } finally { - if (original === undefined) { delete process.env.TSRS_BINARY } - else { process.env.TSRS_BINARY = original } await fs.rm(dir, { recursive: true, force: true }) } }) From 7f5d49e1d2db8e827dc37ec13604a10bf436c7c5 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 22:50:49 +0900 Subject: [PATCH 7/8] chore(lsp): apply AI code review suggestions Make the libc and platform-package helpers injectable and test them with independent expected names, extract the Windows-quoted spawn arguments, exercise the global npm launcher with an executable target, and align the tsrs docs with the resolver's fallback order and root markers. --- .../docs/3.configuration/1.overview.md | 16 ++-- .../docs/4.reference/2.supported-languages.md | 4 +- packages/lsp/src/server/typescript.ts | 34 +++++++-- .../lsp/test/unit/typescript-native.test.ts | 74 ++++++++++++++++++- 4 files changed, 110 insertions(+), 18 deletions(-) diff --git a/apps/docs/content/docs/3.configuration/1.overview.md b/apps/docs/content/docs/3.configuration/1.overview.md index 1efef5d..92733b2 100644 --- a/apps/docs/content/docs/3.configuration/1.overview.md +++ b/apps/docs/content/docs/3.configuration/1.overview.md @@ -118,13 +118,15 @@ npm install --save-dev tsrs tsrs runs as a native binary from a per-platform optional dependency (`@tsrs/`). If no binary for your platform is installed (for example on macOS x64, which tsrs does not support) and `TSRS_BINARY` is not set to the -path of an existing tsrs binary, the server skips tsrs and falls back to `tsc`, -`tsgo`, or the classic server. - -If the project has none of `tsrs`, a native `tsc`, or `tsgo` installed, the -server uses a `tsrs` on your `PATH` before falling back to the classic server. -A globally installed npm `tsrs` launcher is used only when its platform binary -is installed: +path of an existing tsrs binary (a relative path resolves against the project +root), the server skips the project's tsrs and tries +`tsc`, `tsgo`, a `tsrs` on your `PATH`, and then the classic server, in that order. + +If the project has no usable `tsrs` and no native `tsc` or `tsgo`, the server +looks for `tsrs` on your `PATH` before falling back to the classic server. A +native tsrs binary is used as is. An npm launcher, or a wrapper script that runs +one (for example from pnpm), is used only when its platform binary is +installed. The server skips any other `tsrs` on `PATH`: ```bash npm install --global tsrs 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 588d6a6..36ee7f1 100644 --- a/apps/docs/content/docs/4.reference/2.supported-languages.md +++ b/apps/docs/content/docs/4.reference/2.supported-languages.md @@ -33,8 +33,8 @@ 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` | -| tsrs / tsc / tsgo (native TypeScript 7, auto-selected when the project ships it) | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts` | `package.json`, `tsconfig.json` | +| typescript-language-server | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs` | `package-lock.json`, `bun.lockb`, `bun.lock`, `pnpm-lock.yaml`, `yarn.lock` (not under `deno.json`, `deno.jsonc`) | +| tsrs / tsc / tsgo (native TypeScript 7, auto-selected when the project ships it; a `tsrs` on `PATH` is the last native fallback) | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts` | `package-lock.json`, `bun.lockb`, `bun.lock`, `pnpm-lock.yaml`, `yarn.lock` (not under `deno.json`, `deno.jsonc`) | | 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/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index 7da966d..e759b54 100644 --- a/packages/lsp/src/server/typescript.ts +++ b/packages/lsp/src/server/typescript.ts @@ -97,15 +97,19 @@ function isNativeTypeScriptPackage(packageRoot: string): boolean { return !fs.existsSync(path.join(libRoot, 'tsserver.js')) } -function isMuslLinux(): boolean { +/** Detect musl the way the tsrs launcher does. The readers are injectable for testing. */ +export function isMuslLinux( + readLdd: () => string = () => fs.readFileSync('/usr/bin/ldd', 'utf8'), + getReport: () => unknown = () => process.report?.getReport(), +): boolean { try { - return fs.readFileSync('/usr/bin/ldd', 'utf8').includes('musl') + return readLdd().includes('musl') } catch { // Fall through to the process report. } try { - const report = process.report?.getReport() as unknown as { + const report = getReport() as { header?: { glibcVersionRuntime?: string } sharedObjects?: string[] } | undefined @@ -119,9 +123,22 @@ function isMuslLinux(): boolean { } /** The single platform package the tsrs launcher resolves for this host. Exported for testing. */ -export function getTsrsPlatformPackageName(): string { - const suffix = process.platform === 'linux' ? (isMuslLinux() ? '-musl' : '-gnu') : '' - return `@tsrs/${process.platform}-${process.arch}${suffix}` +export function getTsrsPlatformPackageName( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + musl: () => boolean = isMuslLinux, +): string { + const suffix = platform === 'linux' ? (musl() ? '-musl' : '-gnu') : '' + return `@tsrs/${platform}-${arch}${suffix}` +} + +/** On Windows a native server runs through the shell, so its path is quoted. Exported for testing. */ +export function nativeServerSpawnArgs( + command: string, + platform: NodeJS.Platform = process.platform, +): { command: string, args: string[], shell: boolean } { + const win32 = platform === 'win32' + return { command: win32 ? `"${command}"` : command, args: ['--lsp', '--stdio'], shell: win32 } } function hasTsrsBinary(packageRoot: string, root: string): boolean { @@ -278,10 +295,11 @@ export const TypescriptServer: LSPServerInfo = { const native = resolveNativeTypeScriptServer(root) if (native) { log.info({ command: native.command }, `Using ${native.label} language server (--lsp --stdio)`) - const proc = spawn(process.platform === 'win32' ? `"${native.command}"` : native.command, ['--lsp', '--stdio'], { + const launch = nativeServerSpawnArgs(native.command) + const proc = spawn(launch.command, launch.args, { cwd: root, env: { ...process.env, BUN_BE_BUN: '1' }, - shell: process.platform === 'win32', + shell: launch.shell, }) attachLSPProcessHandlers(proc, 'typescript') return { process: proc } diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index 3676fe9..89e6c59 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -3,7 +3,12 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { getTsrsPlatformPackageName, resolveNativeTypeScriptServer } from '../../src/server/typescript' +import { + getTsrsPlatformPackageName, + isMuslLinux, + nativeServerSpawnArgs, + resolveNativeTypeScriptServer, +} from '../../src/server/typescript' async function makeBin( dir: string, @@ -60,6 +65,7 @@ async function makeTsrs( opts: { platformBinary: boolean, platformName?: string }, ): Promise { const bin = await makeBin(dir, 'tsrs', 'tsrs') + await fs.chmod(path.join(dir, 'node_modules', 'tsrs', 'bin', 'tsrs.js'), 0o755) const platformName = opts.platformName ?? getTsrsPlatformPackageName() const pkgJson = path.join(dir, 'node_modules', 'tsrs', 'package.json') const pkg = JSON.parse(await fs.readFile(pkgJson, 'utf8')) as Record @@ -122,6 +128,22 @@ describe('resolveNativeTypeScriptServer', () => { } }) + test('uses a global npm tsrs launcher with its platform binary', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-tsrs-npm-ok-')) + const prefix = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-prefix-ok-')) + try { + const launcher = await makeTsrs(prefix, { platformBinary: true }) + process.env.PATH = path.dirname(launcher) + const result = resolveNativeTypeScriptServer(dir) + expect(result?.command).toBe(launcher) + expect(result?.label).toBe('global tsrs') + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(prefix, { recursive: true, force: true }) + } + }) + test('skips a global wrapper-script tsrs launcher without a platform binary', async () => { if (process.platform === 'win32') { return } const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-tsrs-wrapper-')) @@ -437,3 +459,53 @@ describe('resolveNativeTypeScriptServer', () => { } }) }) + +describe('getTsrsPlatformPackageName', () => { + test('names the package for each supported host', () => { + expect(getTsrsPlatformPackageName('darwin', 'arm64')).toBe('@tsrs/darwin-arm64') + expect(getTsrsPlatformPackageName('linux', 'x64', () => false)).toBe('@tsrs/linux-x64-gnu') + expect(getTsrsPlatformPackageName('linux', 'arm64', () => true)).toBe('@tsrs/linux-arm64-musl') + expect(getTsrsPlatformPackageName('win32', 'x64')).toBe('@tsrs/win32-x64') + }) +}) + +describe('isMuslLinux', () => { + const noLdd = (): string => { + throw new Error('ENOENT') + } + + test('reads musl or glibc from ldd', () => { + expect(isMuslLinux(() => '#!/bin/sh\nexec /lib/ld-musl-x86_64.so.1 --list "$@"\n')).toBe(true) + expect(isMuslLinux(() => '#!/bin/bash\n# GNU C Library ldd\n')).toBe(false) + }) + + test('falls back to the process report without ldd', () => { + expect(isMuslLinux(noLdd, () => ({ header: { glibcVersionRuntime: '2.39' } }))).toBe(false) + expect(isMuslLinux(noLdd, () => ({ header: {}, sharedObjects: ['/lib/ld-musl-aarch64.so.1'] }))).toBe(true) + }) + + test('defaults to GNU when nothing identifies the libc', () => { + expect(isMuslLinux(noLdd, () => undefined)).toBe(false) + expect(isMuslLinux(noLdd, () => { + throw new Error('report unavailable') + })).toBe(false) + }) +}) + +describe('nativeServerSpawnArgs', () => { + test('quotes the path and uses the shell on Windows', () => { + expect(nativeServerSpawnArgs('C:\\Program Files\\app\\node_modules\\.bin\\tsrs.cmd', 'win32')).toEqual({ + command: '"C:\\Program Files\\app\\node_modules\\.bin\\tsrs.cmd"', + args: ['--lsp', '--stdio'], + shell: true, + }) + }) + + test('spawns the path directly elsewhere', () => { + expect(nativeServerSpawnArgs('/home/jane doe/app/node_modules/.bin/tsrs', 'linux')).toEqual({ + command: '/home/jane doe/app/node_modules/.bin/tsrs', + args: ['--lsp', '--stdio'], + shell: false, + }) + }) +}) From fada9d5a80274f6370fbc8085a855f4817a9e6f2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Wed, 21 Oct 2026 22:58:25 +0900 Subject: [PATCH 8/8] test(lsp): mirror the Windows global npm layout for the tsrs launcher tests npm on Windows writes a global \tsrs.cmd beside node_modules, but the fixture put the shim in node_modules\.bin, which resolveGlobalTsrs does not recognize, so the positive global-launcher test failed there. --- .../lsp/test/unit/typescript-native.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/lsp/test/unit/typescript-native.test.ts b/packages/lsp/test/unit/typescript-native.test.ts index 89e6c59..fa08193 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -60,6 +60,22 @@ async function writeTypeScriptPackage( } } +/** + * Put `prefix`'s tsrs launcher on PATH and return it. On POSIX that is `binLauncher`, the `.bin` symlink, + * which resolves through its realpath like a global `/bin/tsrs` link. On Windows `binLauncher` is + * unused: npm writes a global `\tsrs.cmd` beside `node_modules`, so the helper writes that shim. + */ +async function useGlobalLauncher(prefix: string, binLauncher: string): Promise { + if (process.platform !== 'win32') { + process.env.PATH = path.dirname(binLauncher) + return binLauncher + } + const cmd = path.join(prefix, 'tsrs.cmd') + await fs.writeFile(cmd, '@node "%~dp0\\node_modules\\tsrs\\bin\\tsrs.js" %*\n') + process.env.PATH = prefix + return cmd +} + async function makeTsrs( dir: string, opts: { platformBinary: boolean, platformName?: string }, @@ -118,8 +134,7 @@ describe('resolveNativeTypeScriptServer', () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-tsrs-npm-')) const prefix = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-prefix-')) try { - const launcher = await makeTsrs(prefix, { platformBinary: false }) - process.env.PATH = path.dirname(launcher) + await useGlobalLauncher(prefix, await makeTsrs(prefix, { platformBinary: false })) expect(resolveNativeTypeScriptServer(dir)).toBeUndefined() } finally { @@ -132,8 +147,7 @@ describe('resolveNativeTypeScriptServer', () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-tsrs-npm-ok-')) const prefix = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-global-prefix-ok-')) try { - const launcher = await makeTsrs(prefix, { platformBinary: true }) - process.env.PATH = path.dirname(launcher) + const launcher = await useGlobalLauncher(prefix, await makeTsrs(prefix, { platformBinary: true })) const result = resolveNativeTypeScriptServer(dir) expect(result?.command).toBe(launcher) expect(result?.label).toBe('global tsrs')