Lifecycle and safety fixes from the external review: - Observe abort while awaiting the initialize handshake, so a server that never replies can't defeat the tool-timeout signal. - On an aborted request the server won't cancel, tear the instance down after a bounded grace instead of releasing the serialized queue with work still live (prevents overlapping document lifecycles). - Re-check provider disposal after the canonicalize/read awaits so a query can't spawn an unowned server after disposeAll(). - Read the source through one open handle (stat + read on the same fd) to close the realpath-vs-read TOCTOU; decode with a fatal UTF-8 decoder so a legitimate U+FFFD is not misclassified as invalid. - Validate and read the source BEFORE spawning a server (pre-start rejection). - Require an explicit openClose for option-form textDocumentSync. - Reject nonpositive teardown budgets and non-executable absolute commands at load; surface unsupported operations as structured LSP_UNSUPPORTED_OPERATION. - Retain the stderr tail (fatal diagnostics land at exit), not the prefix. - Catalog the seam vocabulary in docs/core-data-structures/lsp.md.
154 lines
5.8 KiB
TypeScript
154 lines
5.8 KiB
TypeScript
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('requires an explicit openClose for the options form (no change-enum fallback)', () => {
|
|
expect(supportsTransientOpen({ change: 1 })).toBe(false)
|
|
expect(supportsTransientOpen({ change: 2 })).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' })
|
|
})
|
|
})
|