diff --git a/.please/docs/review/rejected-findings.jsonl b/.please/docs/review/rejected-findings.jsonl new file mode 100644 index 0000000..1999df7 --- /dev/null +++ b/.please/docs/review/rejected-findings.jsonl @@ -0,0 +1,12 @@ +{"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"} +{"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/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..92733b2 100644 --- a/apps/docs/content/docs/3.configuration/1.overview.md +++ b/apps/docs/content/docs/3.configuration/1.overview.md @@ -105,6 +105,33 @@ 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 (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 +``` + ## 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..36ee7f1 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 | @@ -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` | -| 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/CLAUDE.md b/packages/lsp/CLAUDE.md index 4d1866d..9b09c79 100644 --- a/packages/lsp/CLAUDE.md +++ b/packages/lsp/CLAUDE.md @@ -240,6 +240,12 @@ 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` (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). **Config Utilities:** ```typescript diff --git a/packages/lsp/src/server/typescript.ts b/packages/lsp/src/server/typescript.ts index 0cd8fda..e759b54 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 @@ -13,8 +8,10 @@ */ 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' import path from 'node:path' import process from 'node:process' import { attachLSPProcessHandlers, log, nearestRoot } from './utils' @@ -100,13 +97,132 @@ function isNativeTypeScriptPackage(packageRoot: string): boolean { return !fs.existsSync(path.join(libRoot, 'tsserver.js')) } +/** 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 readLdd().includes('musl') + } + catch { + // Fall through to the process report. + } + try { + const report = getReport() 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( + 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 { + const override = process.env.TSRS_BINARY + // 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')) + const exeName = process.platform === 'win32' ? 'tsrs.exe' : 'tsrs' + 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) + // 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'), + ) as { name?: unknown } + if (pkg.name === 'tsrs') { return packageRoot } + } + catch { + // Not a package root. + } + } + return undefined +} + +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. + * 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', packageRoot => hasTsrsBinary(packageRoot, root)) + 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,14 +231,49 @@ 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' } } + + const globalTsrs = resolveGlobalTsrs(root) + if (globalTsrs) { return { command: globalTsrs, label: 'global tsrs' } } + return 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`) + // only works with its platform binary; a native executable is used as-is and a wrapper script is verified. + 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'), + ] + 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, root) ? bin : undefined } + } + catch { + // Not an npm package root. + } + } + 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, root) ? bin : undefined + } + catch { + return undefined } } @@ -144,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(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 9ea7b5a..fa08193 100644 --- a/packages/lsp/test/unit/typescript-native.test.ts +++ b/packages/lsp/test/unit/typescript-native.test.ts @@ -1,8 +1,14 @@ +import { Buffer } from 'node:buffer' 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' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + getTsrsPlatformPackageName, + isMuslLinux, + nativeServerSpawnArgs, + resolveNativeTypeScriptServer, +} from '../../src/server/typescript' async function makeBin( dir: string, @@ -54,7 +60,178 @@ 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 }, +): 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 + 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', () => { + 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 }) + }) + + 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, Buffer.from('7f454c46', 'hex')) + 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 { + await useGlobalLauncher(prefix, await makeTsrs(prefix, { platformBinary: false })) + expect(resolveNativeTypeScriptServer(dir)).toBeUndefined() + } + finally { + await fs.rm(dir, { recursive: true, force: true }) + await fs.rm(prefix, { recursive: true, force: true }) + } + }) + + 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 useGlobalLauncher(prefix, await makeTsrs(prefix, { platformBinary: true })) + 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-')) + 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('resolves a relative TSRS_BINARY against the project root', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-rel-override-')) + 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 { + 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 { + 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, Buffer.from('7f454c46', 'hex')) + 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 { @@ -120,6 +297,62 @@ 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('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 { + 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') @@ -240,3 +473,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, + }) + }) +})