fix(lsp): preserve execution-world URI semantics

This commit is contained in:
Tianyi Cui
2026-07-29 04:19:18 +08:00
parent 3537665806
commit 4997fb219e
24 changed files with 162 additions and 107 deletions

View File

@@ -14,6 +14,7 @@ import {
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
const WS = resolve('/home/u/proj')
const WS_URI = pathToFileURL(WS).href
function loc(uri: string, line: number, character = 0): LspLocation {
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
@@ -48,64 +49,84 @@ describe('parseLspArgs', () => {
describe('renderUri', () => {
it('relativizes a file: URI inside the workspace with forward slashes', () => {
const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href
expect(renderUri(uri, WS)).toBe('src/a.ts')
expect(renderUri(uri, WS_URI)).toBe('src/a.ts')
})
it('returns an absolute path for a file: URI outside the workspace', () => {
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
const uri = pathToFileURL(outside).href
expect(renderUri(uri, WS)).toBe(outside)
expect(renderUri(uri, WS_URI)).toBe(outside)
})
it('renders the workspace root itself as "."', () => {
expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.')
expect(renderUri(WS_URI, WS_URI)).toBe('.')
})
it('keeps an in-workspace path whose first segment starts with dots relative', () => {
// `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external.
const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href
expect(renderUri(uri, WS)).toBe('..generated/a.ts')
expect(renderUri(uri, WS_URI)).toBe('..generated/a.ts')
})
it('relativizes Windows execution-world URIs on a non-Windows host', () => {
expect(renderUri('file:///C:/WORKSPACE/src/a.ts', 'file:///c:/workspace')).toBe('src/a.ts')
expect(renderUri('file:///D:/lib/b.ts', 'file:///C:/workspace')).toBe('D:/lib/b.ts')
})
it('renders remote file authorities without host path conversion', () => {
expect(renderUri('file://server/share/workspace/a.ts', 'file://server/share/workspace')).toBe('a.ts')
expect(renderUri('file://other/share/b.ts', 'file://server/share/workspace')).toBe('//other/share/b.ts')
expect(renderUri('file:///a.ts', 'file://server/')).toBe('/a.ts')
})
it('keeps malformed or mismatched URI coordinates verbatim', () => {
expect(renderUri('file://[', WS_URI)).toBe('file://[')
expect(renderUri('file:///a.ts', 'https://example.com/workspace')).toBe('file:///a.ts')
expect(renderUri('file:///a.ts', 'file:///bad%ZZ')).toBe('file:///a.ts')
expect(renderUri('file:///bad%5Cpath', WS_URI)).toBe('file:///bad%5Cpath')
expect(renderUri('file:///short', 'file:///short/deeper')).toBe('/short')
expect(renderUri('file:///', 'file:///C:/workspace')).toBe('/')
})
it('keeps a non-file URI verbatim', () => {
expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1')
expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class')
expect(renderUri('untitled:Untitled-1', WS_URI)).toBe('untitled:Untitled-1')
expect(renderUri('jdt://contents/Foo.class', WS_URI)).toBe('jdt://contents/Foo.class')
})
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
// An encoded path separator is invalid on every platform and must remain verbatim.
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
expect(renderUri('file:///bad%2Fpath', WS_URI)).toBe('file:///bad%2Fpath')
})
})
describe('formatLocations', () => {
it('renders a no-result line for an empty list', () => {
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
expect(formatLocations([], WS_URI, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
})
it('renders one-based path:line:character grouped by file', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS_URI, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
expect(text).toBe('a.ts:1:1\na.ts:5:3')
})
it('caps at maxLocations and marks the omission', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS)
const text = formatLocations(many, WS_URI, 2, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('a.ts:1:1')
expect(text).toContain('3 more locations omitted (limit 2).')
})
it('uses the singular omission marker for exactly one extra', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS)
const text = formatLocations([loc(a, 0), loc(a, 1)], WS_URI, 1, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('1 more location omitted (limit 1).')
})
it('caps the complete location text even when one URI is enormous', () => {
const maxResultChars = 80
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars)
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS_URI, 1, maxResultChars)
expect(text).toHaveLength(maxResultChars)
expect(text).toContain('locations truncated')
})