Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ LSP package test layout: `packages/lsp/test/unit/{client,multiplexer,index,serve
- The multiplexer tests use a hand-written `FakeManager` (in multiplexer.test.ts), NOT the real `LSPManager`. So the multiplexer's upstream protocol + relay wiring are covered, but the real `LSPManager.getClients` spawn/dedup logic and `diagnosticsForFile`/`diagnostics` merge across heterogeneous real servers are NOT exercised by these tests.
- Recurring untested branches to check on any LSP PR: `LSPManager` constructor `serverIds` scoping + the "empty array = no restriction" guard (index.ts ~L267); `getClients` filename matching for extensionless files (Dockerfile/Containerfile, index.ts ~L378-389); `closeFile`/`openWithText` on the real manager; `didChange` empty `contentChanges` early-return (multiplexer.ts ~L209-211).
- Concurrent-open fix lives in client.ts (`files[filePath] = 0` set BEFORE the didOpen await). The regression test in client.test.ts genuinely guards it: reverting the order makes the count assertion fail. Treat this as the reference pattern for async-ordering regression tests here.
- Native TypeScript tests cover resolver behavior (classic/no-tsgo, v7 and file markers, native-preview, missing tsc, parent-hoisted TypeScript), but not `TypescriptServer.spawn` command/arguments or the Windows `.cmd` branch.
- The pull-diagnostics fixture advertises `diagnosticProvider` unconditionally and always returns one fixed `full` report. It does not expose request counts or model `unchanged`, errors, or changing/empty reports; the push fixture answers unknown requests, so current client tests cannot prove capability negotiation/gating, didChange refresh, preservation, failure swallowing, or `onDiagnostics` relay side effects.
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ packages/lsp/src/
├── language.ts # File extension → language ID mapping
└── server/ # Server definitions (30+ files)
├── index.ts # LSP_SERVERS registry array
├── typescript.ts # TypeScript/JavaScript server
├── typescript.ts # TypeScript/JavaScript server (auto-selects native TS 7)
├── pyright.ts # Python server
└── ... # One file per language server
```
Expand Down
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 | package-lock.json, bun.lock, etc. |
| TypeScript/JavaScript | typescript-language-server (auto-upgrades to native TypeScript 7 `tsc --lsp` when the project ships it) | package-lock.json, bun.lock, etc. |
| TypeScript/JavaScript | oxlint | .oxlintrc.json, package.json |
| Deno | deno lsp | deno.json, deno.jsonc |
| Python | pyright-langserver | pyproject.toml, setup.py, requirements.txt |
Expand Down
25 changes: 25 additions & 0 deletions apps/docs/content/docs/3.configuration/1.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ formatter: false
lsp: false
```

## Native TypeScript 7 (typescript-go)

The `typescript` server automatically uses the native TypeScript 7 compiler
([typescript-go](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/))
when your project ships one — no configuration needed. At startup it inspects
the project's `node_modules` and, if it finds a native build, spawns
`tsc --lsp --stdio` (or `tsgo --lsp --stdio` from
[`@typescript/native-preview`](https://www.npmjs.com/package/@typescript/native-preview)).
Otherwise it falls back to the classic `typescript-language-server --stdio`.

A project is treated as native TypeScript 7 when any of these hold:

- `node_modules/typescript` is at major version 7 or higher
- the `typescript` package ships `lib/getExePath.js` (native launcher)
- the `typescript` package is missing `lib/tsserver.js` (classic entry point)
- a `node_modules/.bin/tsgo` binary is present (`@typescript/native-preview`)

Because it is one server that swaps its own binary, there is a single set of
diagnostics either way — you never get duplicates. To use it, just install a
native TypeScript 7 build in your project:

```bash
npm install --save-dev @typescript/native-preview
```

## Environment Variables

| Variable | Description | Default |
Expand Down
5 changes: 3 additions & 2 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 | typescript-language-server |
| JavaScript | Biome, Prettier | typescript-language-server |
| TypeScript | Biome, Prettier | native `tsc` / `tsgo` (TypeScript 7, auto-selected) or typescript-language-server |
| JavaScript | Biome, Prettier | native `tsc` / `tsgo` (TypeScript 7, auto-selected) or typescript-language-server |
| Python | ruff, uv | pyright |
| Go | gofmt | gopls |
| Rust | - | rust-analyzer |
Expand All @@ -34,6 +34,7 @@ Code Please supports formatting and LSP diagnostics for many languages.
| Server | Extensions | Root Detection |
|--------|------------|----------------|
| typescript-language-server | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs` | `package.json`, `tsconfig.json` |
| tsc / tsgo (native TypeScript 7, auto-selected when the project ships it) | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts` | `package.json`, `tsconfig.json` |
Comment thread
amondnet marked this conversation as resolved.
| 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
10 changes: 10 additions & 0 deletions packages/lsp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,16 @@ lsp:
- `root: string` - Custom project root path (must be non-empty)
- `command: string[]` - Custom spawn command (must have at least one element)

**Native TypeScript 7 (typescript-go):** The single `typescript` server auto-selects
its binary per project root at spawn time. When the workspace ships a native
TypeScript 7 build — `node_modules/typescript` at major version ≥ 7 (or missing
`lib/tsserver.js` / carrying `lib/getExePath.js`), or a `@typescript/native-preview`
`tsgo` bin — it spawns `tsc --lsp --stdio` (or `tsgo --lsp --stdio`); otherwise it
falls back to the classic `typescript-language-server --stdio`. There is no separate
server id or config toggle, so there are never duplicate diagnostics. The native
servers report diagnostics via the LSP pull model (`textDocument/diagnostic`), which
the client handles transparently alongside push (`publishDiagnostics`).

**Config Utilities:**
```typescript
import { getServerRoot, isServerEnabled, loadLspConfig } from '@pleaseai/code-lsp'
Expand Down
116 changes: 97 additions & 19 deletions packages/lsp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Diagnostic } from 'vscode-languageserver-types'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
CancellationTokenSource,
createMessageConnection,

StreamMessageReader,
Expand Down Expand Up @@ -62,32 +63,95 @@ export async function createLSPClient(input: {

const diagnostics = new Map<string, Diagnostic[]>()
const files: Record<string, number> = {}
const diagnosticPulls: Record<string, number> = {}
const diagnosticPullTokens: Record<string, CancellationTokenSource> = {}
let nextDiagnosticPullID = 0
let diagnosticsListeners: Array<{
path: string
resolve: () => void
timer?: ReturnType<typeof setTimeout>
}> = []

// Whether the server reports diagnostics via the pull model
// (textDocument/diagnostic) rather than pushing publishDiagnostics.
// Populated from the initialize result below.
let supportsPullDiagnostics = false

const notifyDiagnosticsListeners = (filePath: string): void => {
const listeners = diagnosticsListeners.filter(l => l.path === filePath)
for (const listener of listeners) {
if (listener.timer) { clearTimeout(listener.timer) }
listener.timer = setTimeout(() => {
diagnosticsListeners = diagnosticsListeners.filter(
l => l !== listener,
)
listener.resolve()
}, DIAGNOSTICS_DEBOUNCE_MS)
}
}

// Store diagnostics for a file and notify any waiters. Shared by the push
// (publishDiagnostics) handler and the pull (textDocument/diagnostic) path.
const applyDiagnostics = (filePath: string, diags: Diagnostic[]): void => {
diagnostics.set(filePath, diags)
notifyDiagnosticsListeners(filePath)
input.onDiagnostics?.(filePath, input.serverID)
}

// Pull diagnostics for a single file (LSP textDocument/diagnostic). Used for
// servers that advertise a diagnostic provider instead of pushing them
// (e.g. typescript-go / tsgo). Failures are swallowed — absent diagnostics
// must never break opening a file. `pullID` uniquely identifies this request;
// the response is discarded if a newer open/change/close superseded it, so an
// out-of-order reply can't overwrite fresher diagnostics with stale ones.
const pullDiagnostics = async (filePath: string): Promise<void> => {
if (!supportsPullDiagnostics) { return }
const normalizedPath = normalizePath(filePath)
diagnosticPullTokens[normalizedPath]?.cancel()
diagnosticPullTokens[normalizedPath]?.dispose()
const cancellation = new CancellationTokenSource()
diagnosticPullTokens[normalizedPath] = cancellation
const pullID = ++nextDiagnosticPullID
diagnosticPulls[normalizedPath] = pullID
try {
const report = (await withTimeout(
connection.sendRequest('textDocument/diagnostic', {
Comment thread
amondnet marked this conversation as resolved.
textDocument: { uri: pathToFileURL(normalizedPath).href },
}, cancellation.token),
DIAGNOSTICS_WAIT_TIMEOUT_MS,
)) as { kind?: string, items?: Diagnostic[] } | null
Comment thread
amondnet marked this conversation as resolved.
// Drop a response superseded by a newer pull or close.
if (diagnosticPulls[normalizedPath] !== pullID) { return }
// A "full" report carries items; an "unchanged" report means keep the
// previously reported set, so leave the map as-is.
if (report?.kind === 'full' && Array.isArray(report.items)) {
applyDiagnostics(normalizedPath, report.items)
}
else {
notifyDiagnosticsListeners(normalizedPath)
}
}
catch {
// Settle waiters with the latest known diagnostics when the server is not
// ready or the request fails, unless a newer pull/close superseded it.
if (diagnosticPulls[normalizedPath] === pullID) {
notifyDiagnosticsListeners(normalizedPath)
}
}
finally {
if (diagnosticPulls[normalizedPath] === pullID) {
delete diagnosticPullTokens[normalizedPath]
cancellation.cancel()
cancellation.dispose()
Comment thread
amondnet marked this conversation as resolved.
}
}
Comment thread
amondnet marked this conversation as resolved.
Comment thread
amondnet marked this conversation as resolved.
}
Comment thread
amondnet marked this conversation as resolved.
Comment thread
amondnet marked this conversation as resolved.

// Handle diagnostics notifications
connection.onNotification(
'textDocument/publishDiagnostics',
(params: { uri: string, diagnostics: Diagnostic[] }) => {
const filePath = normalizePath(fileURLToPath(params.uri))
diagnostics.set(filePath, params.diagnostics)

// Notify listeners with debounce
const listener = diagnosticsListeners.find(l => l.path === filePath)
if (listener) {
if (listener.timer) { clearTimeout(listener.timer) }
listener.timer = setTimeout(() => {
diagnosticsListeners = diagnosticsListeners.filter(
l => l !== listener,
)
listener.resolve()
}, DIAGNOSTICS_DEBOUNCE_MS)
}

input.onDiagnostics?.(filePath, input.serverID)
applyDiagnostics(normalizePath(fileURLToPath(params.uri)), params.diagnostics)
},
)

Expand All @@ -108,7 +172,7 @@ export async function createLSPClient(input: {
connection.listen()

// Initialize LSP connection
await withTimeout(
const initializeResult = (await withTimeout(
connection.sendRequest('initialize', {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
Expand All @@ -125,12 +189,19 @@ export async function createLSPClient(input: {
textDocument: {
synchronization: { didOpen: true, didChange: true },
publishDiagnostics: { versionSupport: true },
// Advertise pull-diagnostics support so servers that report via the
// pull model (e.g. typescript-go / tsgo) enable their provider.
diagnostic: { dynamicRegistration: false },
rename: { prepareSupport: true },
},
},
}),
INITIALIZE_TIMEOUT_MS,
)
)) as { capabilities?: { diagnosticProvider?: unknown } } | undefined

// A server that advertises a diagnostic provider reports diagnostics via the
// pull model (textDocument/diagnostic) instead of pushing publishDiagnostics.
supportsPullDiagnostics = Boolean(initializeResult?.capabilities?.diagnosticProvider)

await connection.sendNotification('initialized', {})

Expand Down Expand Up @@ -169,6 +240,7 @@ export async function createLSPClient(input: {
},
contentChanges: [{ text }],
})
void pullDiagnostics(filePath)
return
}

Expand All @@ -185,6 +257,7 @@ export async function createLSPClient(input: {
text,
},
})
void pullDiagnostics(filePath)
},

async close(fileInput: { path: string }) {
Expand All @@ -200,7 +273,12 @@ export async function createLSPClient(input: {
},
})
delete files[filePath]
diagnostics.delete(normalizePath(filePath))
const normalizedPath = normalizePath(filePath)
diagnosticPullTokens[normalizedPath]?.cancel()
diagnosticPullTokens[normalizedPath]?.dispose()
delete diagnosticPullTokens[normalizedPath]
delete diagnosticPulls[normalizedPath]
diagnostics.delete(normalizedPath)
},
},

Expand Down
Loading
Loading