Merge latest master into invariant service seam
This commit is contained in:
42
packages/lsp/lsp/README.md
Normal file
42
packages/lsp/lsp/README.md
Normal 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.
|
||||
41
packages/lsp/lsp/package.json
Normal file
41
packages/lsp/lsp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
packages/lsp/lsp/src/brand.ts
Normal file
21
packages/lsp/lsp/src/brand.ts
Normal 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
|
||||
}
|
||||
158
packages/lsp/lsp/src/index.ts
Normal file
158
packages/lsp/lsp/src/index.ts
Normal 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
|
||||
30
packages/lsp/lsp/src/invariant.ts
Normal file
30
packages/lsp/lsp/src/invariant.ts
Normal 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 */
|
||||
130
packages/lsp/lsp/src/types.ts
Normal file
130
packages/lsp/lsp/src/types.ts
Normal 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>
|
||||
}
|
||||
187
packages/lsp/lsp/tests/lsp.spec.ts
Normal file
187
packages/lsp/lsp/tests/lsp.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
27
packages/lsp/lsp/tsconfig.json
Normal file
27
packages/lsp/lsp/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user