refactor(lsp): configure local servers together
This commit is contained in:
@@ -46,11 +46,14 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
providerId: 'fake',
|
||||
command: ${JSON.stringify(process.execPath)},
|
||||
args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}],
|
||||
env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
servers: {
|
||||
fake: {
|
||||
command: ${JSON.stringify(process.execPath)},
|
||||
args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}],
|
||||
env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
|
||||
console.log(JSON.stringify(result))
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { Config } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
@@ -28,17 +28,23 @@ afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */
|
||||
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
providerId: 'fake',
|
||||
/** One fake stdio server entry with optional behavior and host-bound overrides. */
|
||||
function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServer],
|
||||
env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the real seam + lsp-local plugin driving one fake server. */
|
||||
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: { fake: fakeServer(fakeEnv, overrides) },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
@@ -53,6 +59,24 @@ function locationJson(line: number): unknown {
|
||||
}
|
||||
|
||||
describe('lsp-local end to end over a fake server', () => {
|
||||
it('routes different extensions to independent configured servers', async () => {
|
||||
await writeFile(join(ws, 'a.py'), 'x = 1\n')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
|
||||
python: fakeServer(
|
||||
{ LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) },
|
||||
{ extensionToLanguage: { '.py': 'python' } },
|
||||
),
|
||||
},
|
||||
})
|
||||
expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } })
|
||||
expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resolves definition to normalized locations', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const result = await ctx.lsp.query(query('definition'))
|
||||
@@ -245,10 +269,13 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'missing',
|
||||
command: 'definitely-not-a-real-lsp-binary-xyz',
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
servers: {
|
||||
missing: {
|
||||
command: 'definitely-not-a-real-lsp-binary-xyz',
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
},
|
||||
},
|
||||
})).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
@@ -24,6 +25,11 @@ function query(): LspQueryRequest {
|
||||
return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
/** Wrap one server entry in the plugin's named server table. */
|
||||
function config(providerId: string, server: LspLocalServerConfig): Config {
|
||||
return { servers: { [providerId]: server } }
|
||||
}
|
||||
|
||||
describe('lsp-local provider resolution', () => {
|
||||
it('resolves a bare command on the child PATH and registers the provider', async () => {
|
||||
// A tiny executable script placed on a custom PATH dir: the load-time resolver must find it.
|
||||
@@ -35,26 +41,24 @@ describe('lsp-local provider resolution', () => {
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'onpath',
|
||||
await expect(ctx.plugin(LspLocal, config('onpath', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: bin },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).resolves.toBeDefined()
|
||||
}))).resolves.toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips empty PATH segments and fails when the command is absent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'nope',
|
||||
await expect(ctx.plugin(LspLocal, config('nope', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: `::${join(root, 'empty')}` },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).rejects.toThrow(/was not found on PATH/)
|
||||
}))).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -64,12 +68,11 @@ describe('lsp-local provider resolution', () => {
|
||||
await ctx.plugin(Lsp)
|
||||
// Grab the provider instance by registering, then dispose the whole plugin fiber.
|
||||
const lsp = ctx.lsp
|
||||
const fiber = await ctx.plugin(LspLocal, {
|
||||
providerId: 'disp',
|
||||
const fiber = await ctx.plugin(LspLocal, config('disp', {
|
||||
command: process.execPath,
|
||||
args: ['-e', 'setInterval(()=>{},1000)'],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})
|
||||
}))
|
||||
await fiber.dispose()
|
||||
// After disposal the provider unregistered from the seam, so selection fails as unavailable.
|
||||
await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
@@ -79,13 +82,12 @@ describe('lsp-local provider resolution', () => {
|
||||
it('rejects a nonpositive teardown budget at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'bad-budget',
|
||||
await expect(ctx.plugin(LspLocal, config('bad-budget', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
killGraceMs: 0,
|
||||
})).rejects.toThrow(/killGraceMs must be a positive integer/)
|
||||
}))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -94,24 +96,65 @@ describe('lsp-local provider resolution', () => {
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'abs-bad',
|
||||
await expect(ctx.plugin(LspLocal, config('abs-bad', {
|
||||
command: notExe,
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).rejects.toThrow(/is not an executable file/)
|
||||
}))).rejects.toThrow(/is not an executable file/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an executable directory as a command at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'abs-directory',
|
||||
await expect(ctx.plugin(LspLocal, config('abs-directory', {
|
||||
command: ws,
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).rejects.toThrow(/is not an executable file/)
|
||||
}))).rejects.toThrow(/is not an executable file/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an empty server table at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an empty server id at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('', {
|
||||
command: process.execPath,
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/server ids must be non-empty strings/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resolves every executable before publishing any provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
|
||||
missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } },
|
||||
},
|
||||
})).rejects.toThrow(/was not found on PATH/)
|
||||
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rolls back earlier registrations when a later server conflicts', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
|
||||
second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
|
||||
},
|
||||
})).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,10 +53,13 @@ beforeAll(async () => {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
providerId: 'typescript',
|
||||
command: serverBin,
|
||||
args: ['--stdio'],
|
||||
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
|
||||
servers: {
|
||||
typescript: {
|
||||
command: serverBin,
|
||||
args: ['--stdio'],
|
||||
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user