Skip to content
12 changes: 12 additions & 0 deletions .please/docs/review/rejected-findings.jsonl
Original file line number Diff line number Diff line change
@@ -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 '<pkg>/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 <platform pkg>/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"}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
27 changes: 27 additions & 0 deletions apps/docs/content/docs/3.configuration/1.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<platform>`). 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 |
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/content/docs/4.reference/2.supported-languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down
6 changes: 6 additions & 0 deletions packages/lsp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<os>-<cpu>[-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
Expand Down
186 changes: 169 additions & 17 deletions packages/lsp/src/server/typescript.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
/**
* 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 { 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'
Expand Down Expand Up @@ -100,13 +97,132 @@
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') : ''

Check warning on line 131 in packages/lsp/src/server/typescript.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=pleaseai_code-intelligence&issues=AaFeu5Q_Q7ncxF0ND0pz&open=AaFeu5Q_Q7ncxF0ND0pz&pullRequest=124
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])

Check warning on line 181 in packages/lsp/src/server/typescript.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=pleaseai_code-intelligence&issues=AaFc6_Uol4XMASjCx9nN&open=AaFc6_Uol4XMASjCx9nN&pullRequest=124
const unquoted = [...text.matchAll(/([^\s"'`=:;]*node_modules[\\/]tsrs)[\\/]/g)].map(m => m[1])

Check warning on line 182 in packages/lsp/src/server/typescript.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=pleaseai_code-intelligence&issues=AaFc6_Uol4XMASjCx9nO&open=AaFc6_Uol4XMASjCx9nO&pullRequest=124
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)) {
Expand All @@ -115,14 +231,49 @@
}

// `@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 `<prefix>/bin/tsrs` or Windows `<prefix>/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
}
}

Expand All @@ -139,15 +290,16 @@
],
['deno.json', 'deno.jsonc'],
),
async spawn(root) {

Check warning on line 293 in packages/lsp/src/server/typescript.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Async method 'spawn' has no 'await' expression.

See more on https://sonarcloud.io/project/issues?id=pleaseai_code-intelligence&issues=AaFc2w11mqMagQe8Ff_W&open=AaFc2w11mqMagQe8Ff_W&pullRequest=124
// 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'], {
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 }
Expand Down
Loading
Loading