Merge latest master into invariant service seam

This commit is contained in:
Tianyi Cui
2026-07-21 18:27:46 +08:00
89 changed files with 7354 additions and 32 deletions

View File

@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-lsp
The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses.
This package is the interface third of the LSP capability:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy |
| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers |
| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` |
The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
## Service API (`ctx.lsp`)
| Member | Semantics |
|---|---|
| `registerProvider(provider)` | Register a backend, atomically reserving its branded `id` and every normalized file extension. Any invalid input or conflict publishes nothing and throws `LspError` (`LSP_INVALID_PROVIDER` / `LSP_CONFLICT`). Returns a disposer releasing all reservations. Disposed with the calling fiber. |
| `query(request, signal?)` | Select the provider by the file's final extension, derive the `languageId` from that provider's mapping, and run one query. No match throws `LspError` `LSP_UNAVAILABLE`. |
Selection is per query and order-independent: a provider owns a set of extensions exclusively, so registration and HMR order never change routing. Extension keys normalize to lowercase, leading-dot form; the `languageId` only synchronizes the transient document, never participates in selection. The first version has no glob, language-id, or explicit route selector.
Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner of the model-facing name, description, prompt guidance, schema, and presentation.
## Vocabulary
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
## Model Experience
Indirectly, through `dsh-tool-lsp`, which owns the model-facing `lsp` schema, prompt, and rendered results while this registry contributes no prompt or schema itself.
#### KV Cache effect
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## Known Limitations and Deferred Work
- **Exclusive extension ownership within one runtime** — two providers cannot both claim `.ts`, even with different language ids; overlaps fail registration. The intended extension is a deployment-configured selector above registrations, which can relax exclusive reservation without adding provider choice to model input ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
- **Four operations only** — symbols and call hierarchy are deferred (they need different schemas); diagnostics need separate freshness/accumulation rules; mutations (rename, code actions, formatting) require separate tools with preview, permission, and write-policy integration.
- **No observation surface** — availability is observed only by running `query()` and routing the thrown `LspError` codes; there is no provider-change event or capability-status query.

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-lsp",
"description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,21 @@
/**
* dsh-lsp's owned branded id: {@link LspProviderId}, the opaque identity a provider reserves on
* `ctx.lsp`. The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; keeping the type and its
* factory together here lets `index.ts` re-export both under one name.
* @module @deepseek-ai/dsh-lsp/brand
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Opaque provider identity, reserved atomically with its extension mappings at registration. */
export type LspProviderId = Branded<'LspProviderId'>
/**
* Brand a string as an {@link LspProviderId}. No validation — the registry rejects an empty id at
* registration.
* @param id - the provider's stable identifier.
* @returns the same string, branded.
*/
export function LspProviderId(id: string): LspProviderId {
return id as LspProviderId
}

View File

@@ -0,0 +1,158 @@
/**
* The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query,
* order-independent selection over normalized goToDefinition/findReferences/goToImplementation/
* hover queries.
*
* A provider reserves a branded id and an exclusive set of file extensions atomically:
* {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an
* invalid or conflicting registration publishes nothing, and its disposer releases every
* reservation together. Selection routes a query by the file's final extension; it never depends on
* registration order. The seam exposes exactly the four operations and no JSON-RPC escape hatch.
* @module @deepseek-ai/dsh-lsp
*/
import { Context, Service } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { LspProviderId } from './brand.ts'
import type {
LspProvider,
LspQueryRequest,
LspQueryResult,
LspService,
} from './types.ts'
export { LspProviderId } from './brand.ts'
export type {
LspHover,
LspLocation,
LspOperation,
LspPosition,
LspProvider,
LspProviderQuery,
LspQueryRequest,
LspQueryResult,
LspRange,
LspService,
} from './types.ts'
declare module 'cordis' {
interface Context {
lsp: LspService
}
}
/**
* Structured LSP failure. Extends {@link HarnessError} with a stable `code`
* (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`,
* `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of
* parsing `message`.
*/
export class LspError extends HarnessError {}
/**
* Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` →
* `.ts`, `foo.d.ts` → `.ts`). Returns `''` for a name with no extension or a leading-dot dotfile
* (`.bashrc`), which no route ever matches. Splits on both `/` and `\` so a caller's path separator
* does not change the result.
* @param filePath - the source path to inspect.
* @returns the normalized extension, or `''` when there is none.
*/
export function finalExtension(filePath: string): string {
const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath
const dot = base.lastIndexOf('.')
// dot <= 0 covers both "no dot" (-1) and a leading-dot dotfile (0): neither has an extension.
if (dot <= 0) return ''
return base.slice(dot).toLowerCase()
}
/** A well-formed normalized extension: a dot followed by one or more non-dot, non-separator chars. */
const EXTENSION_PATTERN = /^\.[^./\\]+$/
/** One selection route: the provider to run plus the language id to synchronize the document with. */
interface Route {
readonly provider: LspProvider
readonly languageId: string
}
/**
* `ctx.lsp`. Holds the id reservations and the extension→route table; both are populated and cleared
* together per provider so a route always has a live provider.
*/
export class Lsp extends Service implements LspService {
private readonly providerIds = new Set<LspProviderId>()
private readonly routes = new Map<string, Route>()
constructor(ctx: Context) {
super(ctx, 'lsp')
}
registerProvider(provider: LspProvider): () => void {
// Validate and conflict-check everything BEFORE any mutation: an invalid or conflicting
// registration must publish nothing (fail-loud, all-or-nothing).
const id = provider.id
if (id.trim() === '') {
throw new LspError('an LSP provider id must be a non-empty string', 'LSP_INVALID_PROVIDER')
}
if (this.providerIds.has(id)) {
throw new LspError(`an LSP provider with id "${id}" is already registered`, 'LSP_CONFLICT')
}
const entries = Object.entries(provider.extensionToLanguage)
if (entries.length === 0) {
throw new LspError(`LSP provider "${id}" registers no file extensions`, 'LSP_INVALID_PROVIDER')
}
// Normalize into this provider's route set, catching intra-provider duplicates (e.g. `.TS` and
// `.ts`) before checking cross-provider conflicts.
const pending = new Map<string, Route>()
for (const [rawExt, languageId] of entries) {
const ext = normalizeExtension(rawExt)
if (!EXTENSION_PATTERN.test(ext)) {
throw new LspError(`LSP provider "${id}" maps an invalid extension "${rawExt}"`, 'LSP_INVALID_PROVIDER')
}
if (languageId.trim() === '') {
throw new LspError(`LSP provider "${id}" maps extension "${ext}" to an empty language id`, 'LSP_INVALID_PROVIDER')
}
if (pending.has(ext)) {
throw new LspError(`LSP provider "${id}" maps extension "${ext}" more than once`, 'LSP_INVALID_PROVIDER')
}
pending.set(ext, { provider, languageId })
}
for (const ext of pending.keys()) {
if (this.routes.has(ext)) {
throw new LspError(`extension "${ext}" is already handled by another LSP provider`, 'LSP_CONFLICT')
}
}
// All checks passed: reserve id and every extension in one lifecycle controller so disposal
// releases them together.
const dispose = this.ctx.effect(function* (this: Lsp) {
this.providerIds.add(id)
for (const [ext, route] of pending) this.routes.set(ext, route)
yield () => {
this.providerIds.delete(id)
for (const ext of pending.keys()) this.routes.delete(ext)
}
}.bind(this), 'lsp.registerProvider()')
// ctx.effect's disposer returns Promise<void>; our disposer API is synchronous
// fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
async query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> {
const route = this.routes.get(finalExtension(request.filePath))
if (route === undefined) {
throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE')
}
return route.provider.query({ ...request, languageId: route.languageId }, signal)
}
}
/** Lowercase an extension and ensure it carries a leading dot; `EXTENSION_PATTERN` rejects the rest. */
function normalizeExtension(ext: string): string {
const lower = ext.toLowerCase()
return lower.startsWith('.') ? lower : `.${lower}`
}
export default Lsp

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-lsp`.
* @module @deepseek-ai/dsh-lsp/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-lsp'
/** Cordis companion plugin name. */
export const name = 'lsp-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: provider ids and extension routes are private, atomically updated state;
* the seam exposes neither an enumerable snapshot nor lifecycle events to compare independently.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,130 @@
/**
* LSP seam vocabulary: the normalized request, provider, and result contracts. Types only — the
* {@link LspError} taxonomy and the {@link LspProviderId} brand factory are runtime and live in
* `index.ts`. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing
* tool owns the one-based cursor convention. The seam exposes no protocol types, process or document
* controls, or generic JSON-RPC escape hatch — only the four semantic operations.
* @module @deepseek-ai/dsh-lsp/types
*/
import type { LspProviderId } from './brand.ts'
/**
* The four semantic queries the seam and model expose. A closed union: adding an operation is a
* compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are
* deliberately deferred (they need different schemas).
*/
export type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */
export interface LspPosition {
/** Zero-based line. */
readonly line: number
/** Zero-based UTF-16 code-unit offset within the line. */
readonly character: number
}
/** A zero-based UTF-16 half-open range `[start, end)`. */
export interface LspRange {
readonly start: LspPosition
readonly end: LspPosition
}
/**
* A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied,
* `languageId` comes from the provider registration (not here), and consumers own timeouts and
* result limits — so no field needs implementation defaulting and there is no `resolve()` step.
*/
export interface LspQueryRequest {
/** Which semantic query to run. */
readonly operation: LspOperation
/** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */
readonly filePath: string
/** The zero-based UTF-16 cursor position to query at. */
readonly position: LspPosition
/** The workspace root the provider resolves against and indexes; required, never defaulted. */
readonly workspaceRoot: string
}
/**
* A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId`
* the seam derived from the provider's extension mapping. The language id only synchronizes the
* transient document; it does not participate in selection.
*/
export interface LspProviderQuery extends LspQueryRequest {
/** The LSP language id for `filePath`, from this provider's extension mapping. */
readonly languageId: string
}
/** One resolved location: a document URI and the range within it. */
export interface LspLocation {
/** The target document URI (`file:` or otherwise), verbatim from the server. */
readonly uri: string
/** The range within the target document. */
readonly range: LspRange
}
/** Normalized hover content, or `null` for no hover at the position. */
export interface LspHover {
/** The normalized hover text (markdown or plaintext, provider-joined). */
readonly contents: string
/** The range the hover applies to, when the server supplied one. */
readonly range?: LspRange
}
/**
* The closed result union. Navigation operations (`goToDefinition`, `findReferences`,
* `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`.
* Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled.
*
* The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
* request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
* relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`;
* otherwise a symlinked workspace misclassifies in-workspace results as external.
*/
export type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'hover'; readonly hover: LspHover | null }
/**
* A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link
* LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys).
* `findReferences` always includes declarations — the provider enforces this internally; callers
* get no flag.
*/
export interface LspProvider {
/** Stable provider identity, reserved atomically with the extension mappings. */
readonly id: LspProviderId
/** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
readonly extensionToLanguage: Readonly<Record<string, string>>
/**
* Run one query. The seam has already selected this provider and derived `languageId`.
* @param request - the resolved provider query (caller request + derived language id).
* @param signal - optional cancellation; the provider stops its own work when it aborts.
* @returns the normalized, closed-union result.
*/
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult>
}
/**
* The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query
* execution; exposes exactly the four operations and no protocol escape hatch.
*/
export interface LspService {
/**
* Register a provider, atomically reserving its id and every normalized extension. Any conflict
* or invalid input publishes nothing and throws `LspError`; the returned disposer releases all
* reservations. Disposed with the calling fiber.
* @param provider - the backend to register.
* @returns a synchronous disposer releasing the id and all extension reservations.
*/
registerProvider(provider: LspProvider): () => void
/**
* Select a provider by the file's extension and run one query. Selection is per-query and
* order-independent; no match throws `LspError` `LSP_UNAVAILABLE`.
* @param request - the normalized query.
* @param signal - optional cancellation forwarded to the selected provider.
* @returns the normalized, closed-union result.
*/
query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult>
}

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Lsp, {
finalExtension,
LspError,
LspProviderId,
type LspProvider,
type LspProviderQuery,
type LspQueryResult,
} from '@deepseek-ai/dsh-lsp'
/** A scripted provider that records the queries it receives. */
function makeProvider(
id: string,
extensionToLanguage: Record<string, string>,
result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' },
): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
const seen: LspProviderQuery[] = []
const seenSignals: (AbortSignal | undefined)[] = []
return {
id: LspProviderId(id),
extensionToLanguage,
seen,
seenSignals,
query(request, signal) {
seen.push(request)
seenSignals.push(signal)
return Promise.resolve(result)
},
}
}
/** Mount an Lsp service on a fresh root context. */
async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> {
const ctx = new Context()
await ctx.plugin(Lsp)
return { ctx, lsp: ctx.lsp as Lsp }
}
const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } }
function query(filePath: string, operation: LspProviderQuery['operation'] = 'goToDefinition'): Parameters<Lsp['query']>[0] {
return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' }
}
describe('finalExtension', () => {
it('lowercases and keeps only the final extension', () => {
expect(finalExtension('src/Foo.TS')).toBe('.ts')
expect(finalExtension('a/b/foo.d.ts')).toBe('.ts')
expect(finalExtension('C:\\proj\\Main.CS')).toBe('.cs')
})
it('returns empty for no extension or a leading-dot dotfile', () => {
expect(finalExtension('Makefile')).toBe('')
expect(finalExtension('.bashrc')).toBe('')
expect(finalExtension('dir.d/file')).toBe('')
})
})
describe('Lsp registration', () => {
it('registers a provider and routes a query to it, then releases on dispose', async () => {
const { lsp } = await mountLsp()
const provider = makeProvider('ts', { '.ts': 'typescript' })
const dispose = lsp.registerProvider(provider)
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' })
dispose()
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
})
it('normalizes extension keys to lowercase leading-dot and derives the language id', async () => {
const { lsp } = await mountLsp()
const provider = makeProvider('ts', { TS: 'typescript' })
lsp.registerProvider(provider)
await lsp.query(query('a.ts'))
expect(provider.seen[0]?.languageId).toBe('typescript')
})
it('rejects an empty provider id (LSP_INVALID_PROVIDER)', async () => {
const { lsp } = await mountLsp()
expect(() => lsp.registerProvider(makeProvider(' ', { '.ts': 'typescript' })))
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
})
it('rejects a provider with no extensions (LSP_INVALID_PROVIDER)', async () => {
const { lsp } = await mountLsp()
expect(() => lsp.registerProvider(makeProvider('ts', {})))
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
})
it('rejects an invalid extension mapping (LSP_INVALID_PROVIDER)', async () => {
const { lsp } = await mountLsp()
expect(() => lsp.registerProvider(makeProvider('ts', { '.tar.gz': 'archive' })))
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
})
it('rejects an empty language id (LSP_INVALID_PROVIDER)', async () => {
const { lsp } = await mountLsp()
expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': ' ' })))
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
})
it('rejects an extension mapped twice within one provider (LSP_INVALID_PROVIDER)', async () => {
const { lsp } = await mountLsp()
expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript', TS: 'ts2' })))
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
})
it('rejects a duplicate provider id (LSP_CONFLICT)', async () => {
const { lsp } = await mountLsp()
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
expect(() => lsp.registerProvider(makeProvider('ts', { '.tsx': 'typescriptreact' })))
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
})
it('rejects an extension already owned by another provider (LSP_CONFLICT)', async () => {
const { lsp } = await mountLsp()
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
expect(() => lsp.registerProvider(makeProvider('other', { '.ts': 'other-lang' })))
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
})
it('publishes nothing when a later extension conflicts (atomic reservation)', async () => {
const { lsp } = await mountLsp()
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
// This provider's `.py` is free but `.ts` conflicts: the whole registration must roll back.
expect(() => lsp.registerProvider(makeProvider('py-ts', { '.py': 'python', '.ts': 'x' })))
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
// `.py` must NOT have been reserved.
await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
})
it('releases every extension and the id together on dispose', async () => {
const { lsp } = await mountLsp()
const dispose = lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript', '.tsx': 'typescriptreact' }))
dispose()
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
await expect(lsp.query(query('a.tsx'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
// The id is free again after release.
expect(() => lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript' }))).not.toThrow()
})
it('selection is order-independent across two providers', async () => {
const { lsp } = await mountLsp()
const ts = makeProvider('ts', { '.ts': 'typescript' }, hover)
const py = makeProvider('py', { '.py': 'python' })
lsp.registerProvider(ts)
lsp.registerProvider(py)
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover)
})
it('forwards the abort signal verbatim to the provider', async () => {
const { lsp } = await mountLsp()
const provider = makeProvider('ts', { '.ts': 'typescript' })
lsp.registerProvider(provider)
const controller = new AbortController()
await lsp.query(query('a.ts'), controller.signal)
expect(provider.seenSignals[0]).toBe(controller.signal)
})
it('fails LSP_UNAVAILABLE when no provider handles the extension', async () => {
const { lsp } = await mountLsp()
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
})
it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
const { ctx, lsp } = await mountLsp()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
}, { inject: ['lsp'] }))
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await fiber.dispose()
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
})
it('LspError carries its structured code', () => {
expect(new LspError('m', 'LSP_UNAVAILABLE').code).toBe('LSP_UNAVAILABLE')
})
it('brands a provider id without altering the string', () => {
expect(LspProviderId('ts')).toBe('ts')
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
}
]
}