feat(lsp): LSP capability seam, generic stdio provider, and lsp tool
Implements the LSP capability seam RFC as three packages: dsh-lsp (the ctx.lsp interface — provider registry by branded id + exclusive extension mapping, per-query order-independent selection, closed request/result vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server provider — Content-Length JSON-RPC framing, per-(provider, workspace) process single-flight, transient didOpen/query/didClose, an abortable per-instance queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool — four operations, one-based UTF-16 cursor conversion, workspace-grouped location rendering, hover capping, a required session workspace, and a timeout budget). Why: an agent had text search and file reads but no way to identify a program symbol — follow an alias, connect an interface to implementations, or read an inferred type — before changing code. Splitting model contract, seam, and local subprocess behavior keeps the four semantic queries stable across future remote or sandbox-native providers without leaking a JSON-RPC escape hatch.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-local/src/protocol.ts'
|
||||
|
||||
const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }
|
||||
|
||||
describe('requestMethod', () => {
|
||||
it('maps each operation to its textDocument request', () => {
|
||||
expect(requestMethod('definition')).toBe('textDocument/definition')
|
||||
expect(requestMethod('references')).toBe('textDocument/references')
|
||||
expect(requestMethod('implementation')).toBe('textDocument/implementation')
|
||||
expect(requestMethod('hover')).toBe('textDocument/hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsOperation', () => {
|
||||
it('reads the provider slot for each operation (boolean and options forms)', () => {
|
||||
const caps: WireServerCapabilities = {
|
||||
definitionProvider: true,
|
||||
referencesProvider: { workDoneProgress: true },
|
||||
implementationProvider: false,
|
||||
}
|
||||
expect(supportsOperation(caps, 'definition')).toBe(true)
|
||||
expect(supportsOperation(caps, 'references')).toBe(true)
|
||||
expect(supportsOperation(caps, 'implementation')).toBe(false)
|
||||
expect(supportsOperation(caps, 'hover')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsTransientOpen', () => {
|
||||
it('accepts legacy Full and Incremental enums, rejects None and absent', () => {
|
||||
expect(supportsTransientOpen(1)).toBe(true)
|
||||
expect(supportsTransientOpen(2)).toBe(true)
|
||||
expect(supportsTransientOpen(0)).toBe(false)
|
||||
expect(supportsTransientOpen(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts options with openClose:true and rejects openClose:false', () => {
|
||||
expect(supportsTransientOpen({ openClose: true })).toBe(true)
|
||||
expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the change enum when openClose is omitted', () => {
|
||||
expect(supportsTransientOpen({ change: 1 })).toBe(true)
|
||||
expect(supportsTransientOpen({ change: 0 })).toBe(false)
|
||||
expect(supportsTransientOpen({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('negotiatePositionEncoding', () => {
|
||||
it('defaults an omitted encoding to utf-16', () => {
|
||||
expect(negotiatePositionEncoding(undefined)).toBe('utf-16')
|
||||
expect(negotiatePositionEncoding('utf-16')).toBe('utf-16')
|
||||
})
|
||||
|
||||
it('rejects any other encoding', () => {
|
||||
expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeLocations', () => {
|
||||
it('returns empty for null and undefined', () => {
|
||||
expect(normalizeLocations(null)).toEqual([])
|
||||
expect(normalizeLocations(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('maps a single Location', () => {
|
||||
expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }])
|
||||
})
|
||||
|
||||
it('maps an array of Locations', () => {
|
||||
const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }])
|
||||
expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b'])
|
||||
})
|
||||
|
||||
it('maps a LocationLink from targetUri + targetSelectionRange', () => {
|
||||
const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE }
|
||||
expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }])
|
||||
})
|
||||
|
||||
it('rejects a non-object entry', () => {
|
||||
expect(() => normalizeLocations([42])).toThrow(/non-object/)
|
||||
})
|
||||
|
||||
it('rejects an entry that is neither a Location nor a LocationLink', () => {
|
||||
expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range is not an object', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range positions are malformed', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHover', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(normalizeHover(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads MarkupContent value and keeps a range', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
|
||||
.toEqual({ contents: '# H', range: RANGE })
|
||||
})
|
||||
|
||||
it('keeps a bare string MarkedString verbatim', () => {
|
||||
expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' })
|
||||
})
|
||||
|
||||
it('renders a language-tagged MarkedString object as a fenced code block', () => {
|
||||
expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } }))
|
||||
.toEqual({ contents: '```ts\nconst x = 1\n```' })
|
||||
})
|
||||
|
||||
it('joins a MarkedString array with one blank line', () => {
|
||||
expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] }))
|
||||
.toEqual({ contents: 'a\n\n```ts\nb\n```' })
|
||||
})
|
||||
|
||||
it('drops an empty-contents hover to null', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a MarkupContent with a non-string value as empty (null)', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a non-object payload', () => {
|
||||
expect(() => normalizeHover(42)).toThrow(/was not an object/)
|
||||
})
|
||||
|
||||
it('rejects malformed contents', () => {
|
||||
expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/)
|
||||
expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
|
||||
})
|
||||
|
||||
it('rejects a hover with no contents field', () => {
|
||||
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
|
||||
})
|
||||
|
||||
it('ignores a malformed range and keeps the contents', () => {
|
||||
expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user