dsh-vscode-mode 0.1.32 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lsp/rpc.ts ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * dsh-vscode-mode host — edrv.lsp.* RPC handlers。
3
+ * 文档跟踪(openDocs 集合 + root|lang 引用计数)→ 懒启动服务器、用完释放。
4
+ * 作者 ddj 2026-08-27
5
+ */
6
+ import type { Ctx } from '../store.js'
7
+ import { searchRoot } from '../search/ripgrep.js'
8
+ import { sessionOf, cwdOf } from '../registry.js'
9
+ import { resolveProviderSpec, langOfPath, configFromPlugin, configFromSettings, LSP_SETTINGS_NS, type LspConfig } from './config.js'
10
+ import type { LspManager } from './manager.js'
11
+ import type { RpcHandlerMap } from '../shared/rpc.js'
12
+ import type { LspLocation } from '../shared/lsp.js'
13
+ import { installFromMarket, installVsixFile, listInstalled, marketGet, marketSearch, uninstall, updateExtension, type ExtInfo } from './extmgr.js'
14
+
15
+ export interface LspRpcDeps {
16
+ ctx: Ctx
17
+ pluginConfig: unknown
18
+ manager: LspManager
19
+ }
20
+
21
+ /**
22
+ * 打开文档跟踪:openDocs(docKey→sessionId) + rootLangRefs 计数 + sessionAcquired 归属。
23
+ * 保证 manager 的 acquire/release 一一配对(多会话共享同一 root|lang 也平衡)。
24
+ */
25
+ function createDocTracker() {
26
+ const openDocs = new Map<string, Set<string>>()
27
+ const rootLangRefs = new Map<string, number>()
28
+ const sessionDocs = new Map<string, Set<string>>()
29
+ const keyOf = (root: string, lang: string, path: string): string => root + '|' + lang + '|' + path
30
+ const rootLangOf = (docKey: string): string => docKey.slice(0, docKey.lastIndexOf('|'))
31
+ const rememberDoc = (sessionId: string, docKey: string): void => {
32
+ const docs = sessionDocs.get(sessionId) ?? new Set<string>()
33
+ docs.add(docKey)
34
+ sessionDocs.set(sessionId, docs)
35
+ }
36
+ const forgetDoc = (sessionId: string, docKey: string): void => {
37
+ const docs = sessionDocs.get(sessionId)
38
+ docs?.delete(docKey)
39
+ if (docs?.size === 0) sessionDocs.delete(sessionId)
40
+ }
41
+ const releaseRef = (rl: string): boolean => {
42
+ const next = (rootLangRefs.get(rl) ?? 1) - 1
43
+ if (next <= 0) {
44
+ rootLangRefs.delete(rl)
45
+ return true
46
+ }
47
+ rootLangRefs.set(rl, next)
48
+ return false
49
+ }
50
+ return {
51
+ /** 记录文档打开;同一文档可由多个会话打开,但每个会话只计一次。 */
52
+ open(root: string, lang: string, path: string, sessionId: string): boolean {
53
+ const docKey = keyOf(root, lang, path)
54
+ const owners = openDocs.get(docKey) ?? new Set<string>()
55
+ if (owners.has(sessionId)) return false
56
+ owners.add(sessionId)
57
+ openDocs.set(docKey, owners)
58
+ rememberDoc(sessionId, docKey)
59
+ const rl = root + '|' + lang
60
+ const count = rootLangRefs.get(rl) ?? 0
61
+ rootLangRefs.set(rl, count + 1)
62
+ return count === 0
63
+ },
64
+ /** 记录文档关闭;返回 true 表示 root|lang 已无文档(应 release)。 */
65
+ close(root: string, lang: string, path: string, sessionId: string): boolean {
66
+ const docKey = keyOf(root, lang, path)
67
+ const owners = openDocs.get(docKey)
68
+ if (!owners?.has(sessionId)) return false
69
+ owners.delete(sessionId)
70
+ if (owners.size === 0) openDocs.delete(docKey)
71
+ forgetDoc(sessionId, docKey)
72
+ return releaseRef(root + '|' + lang)
73
+ },
74
+ /** 关闭某会话全部文档;返回全局引用归零、需要 release 的 root|lang。 */
75
+ closeSession(sessionId: string): string[] {
76
+ const docs = sessionDocs.get(sessionId)
77
+ if (!docs) return []
78
+ const toRelease = new Set<string>()
79
+ for (const docKey of docs) {
80
+ const owners = openDocs.get(docKey)
81
+ owners?.delete(sessionId)
82
+ if (owners?.size === 0) openDocs.delete(docKey)
83
+ if (releaseRef(rootLangOf(docKey))) toRelease.add(rootLangOf(docKey))
84
+ }
85
+ sessionDocs.delete(sessionId)
86
+ return [...toRelease]
87
+ },
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 构造 edrv.lsp.* handlers(一次性创建,tracker 状态跨请求保留)。
93
+ * @author ddj 2026年08月27号
94
+ * @param deps 依赖(ctx/pluginConfig/manager)
95
+ * @returns { handlers, disposeSession } handlers 为局部 map,disposeSession 供 session/disposed 清理
96
+ */
97
+ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMap>; disposeSession: (sessionId: string) => void } {
98
+ const { ctx, pluginConfig, manager } = deps
99
+ const tracker = createDocTracker()
100
+
101
+ /** 解析会话与工作区根(失败 → null + error)。 */
102
+ const rootOf = async (sessionId: string | undefined): Promise<{ root: string; sessionId: string } | { err: string }> => {
103
+ const session = sessionOf(ctx, sessionId)
104
+ if (!session) return { err: '会话不存在' }
105
+ const cwd = cwdOf(session)
106
+ if (!cwd) return { err: '会话无工作区' }
107
+ try {
108
+ const root = await searchRoot(ctx, session)
109
+ const sid = typeof session.id === 'string' && session.id ? session.id : sessionId ?? ''
110
+ return { root, sessionId: sid }
111
+ } catch (error) {
112
+ return { err: String(error) }
113
+ }
114
+ }
115
+
116
+ const acquireServer = (root: string, lang: string) =>
117
+ manager.acquire(root, lang, (languageId: string) => resolveProviderSpec(ctx, pluginConfig, languageId))
118
+
119
+ const serverOf = (root: string, lang: string) =>
120
+ manager.peek(root, lang)
121
+
122
+ const rootLocations = (locations: LspLocation[], root: string): LspLocation[] =>
123
+ locations.map((location) => ({ ...location, root }))
124
+
125
+ const disposeSession = (sessionId: string): void => {
126
+ for (const rl of tracker.closeSession(sessionId)) {
127
+ const sep = rl.lastIndexOf('|')
128
+ manager.release(rl.slice(0, sep), rl.slice(sep + 1))
129
+ }
130
+ }
131
+
132
+ const handlers: Partial<RpcHandlerMap> = {
133
+ 'edrv.lsp.status': async (args) => {
134
+ if (!args.sessionId) return { ok: true, servers: manager.statusAll() }
135
+ const sc = await rootOf(args.sessionId)
136
+ if ('err' in sc) return { ok: false, error: sc.err }
137
+ return { ok: true, servers: manager.statusAll().filter((status) => status.root === sc.root) }
138
+ },
139
+
140
+ 'edrv.lsp.configGet': async () => {
141
+ const settings = configFromSettings(ctx)
142
+ const plugin = configFromPlugin(pluginConfig)
143
+ const merged: LspConfig = { ...plugin }
144
+ for (const lang of Object.keys(settings)) merged[lang] = { ...plugin[lang], ...settings[lang] }
145
+ return { ok: true, config: merged as unknown as Record<string, unknown> }
146
+ },
147
+
148
+ 'edrv.lsp.configUpdate': async (args) => {
149
+ const lang = args.languageId
150
+ if (!lang || !resolveProviderSpec(ctx, pluginConfig, lang).languageId) {
151
+ return { ok: false, error: '不支持的语言:' + lang }
152
+ }
153
+ const current = configFromSettings(ctx)
154
+ const next: LspConfig = { ...current, [lang]: { ...(current[lang] ?? {}) } }
155
+ const target = next[lang]!
156
+ if (args.enabled !== undefined) target.enabled = args.enabled
157
+ if (args.command !== undefined) target.command = args.command || undefined
158
+ if (args.path !== undefined) target.path = args.path || undefined
159
+ try {
160
+ const settings = ctx.get('settings')
161
+ if (!settings?.update) return { ok: false, error: '设置服务不可用' }
162
+ await settings.update(LSP_SETTINGS_NS, { [lang]: target })
163
+ const descriptor = settings.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === LSP_SETTINGS_NS)
164
+ const stored = descriptor?.value as Record<string, unknown> | undefined
165
+ const merged: LspConfig = { ...configFromPlugin(pluginConfig), ...(stored as LspConfig) }
166
+ return { ok: true, config: merged as unknown as Record<string, unknown> }
167
+ } catch (error) {
168
+ return { ok: false, error: '配置保存失败:' + String(error) }
169
+ }
170
+ },
171
+
172
+ 'edrv.lsp.sync': async (args) => {
173
+ const lang = langOfPath(args.path)
174
+ if (!lang) return { ok: true }
175
+ const sc = await rootOf(args.sessionId)
176
+ if ('err' in sc) return { ok: false, error: sc.err }
177
+ const first = tracker.open(sc.root, lang, args.path, sc.sessionId)
178
+ const server = first ? acquireServer(sc.root, lang) : serverOf(sc.root, lang)
179
+ if (!server) return { ok: false, error: '语言服务器未注册' }
180
+ server.sync(args.path, args.text, args.version)
181
+ return { ok: true }
182
+ },
183
+
184
+ 'edrv.lsp.close': async (args) => {
185
+ const lang = langOfPath(args.path)
186
+ if (!lang) return { ok: true }
187
+ const sc = await rootOf(args.sessionId)
188
+ if ('err' in sc) return { ok: true }
189
+ if (tracker.close(sc.root, lang, args.path, sc.sessionId)) {
190
+ manager.release(sc.root, lang)
191
+ }
192
+ return { ok: true }
193
+ },
194
+
195
+ 'edrv.lsp.definition': async (args) => {
196
+ const lang = langOfPath(args.path)
197
+ if (!lang) return { ok: true, locations: [] }
198
+ const sc = await rootOf(args.sessionId)
199
+ if ('err' in sc) return { ok: false, error: sc.err }
200
+ try {
201
+ const server = serverOf(sc.root, lang)
202
+ if (!server) return { ok: true, locations: [] }
203
+ const locations = await server.definition(args.path, args.position.line, args.position.character)
204
+ const rooted = rootLocations(locations, sc.root)
205
+ const truncated = rooted.length > 500
206
+ return { ok: true, locations: truncated ? rooted.slice(0, 500) : rooted, truncated: truncated || undefined }
207
+ } catch (error) {
208
+ return { ok: false, error: 'LSP 定义查询失败:' + String(error) }
209
+ }
210
+ },
211
+
212
+ 'edrv.lsp.references': async (args) => {
213
+ const lang = langOfPath(args.path)
214
+ if (!lang) return { ok: true, locations: [] }
215
+ const sc = await rootOf(args.sessionId)
216
+ if ('err' in sc) return { ok: false, error: sc.err }
217
+ try {
218
+ const server = serverOf(sc.root, lang)
219
+ if (!server) return { ok: true, locations: [] }
220
+ const locations = await server.references(args.path, args.position.line, args.position.character, args.includeDeclaration !== false)
221
+ const rooted = rootLocations(locations, sc.root)
222
+ const truncated = rooted.length > 500
223
+ return { ok: true, locations: truncated ? rooted.slice(0, 500) : rooted, truncated: truncated || undefined }
224
+ } catch (error) {
225
+ return { ok: false, error: 'LSP 引用查询失败:' + String(error) }
226
+ }
227
+ },
228
+
229
+ 'edrv.lsp.documentSymbol': async (args) => {
230
+ const lang = langOfPath(args.path)
231
+ if (!lang) return { ok: true, symbols: [] }
232
+ const sc = await rootOf(args.sessionId)
233
+ if ('err' in sc) return { ok: false, error: sc.err }
234
+ try {
235
+ const server = serverOf(sc.root, lang)
236
+ if (!server) return { ok: true, symbols: [] }
237
+ const symbols = await server.documentSymbol(args.path)
238
+ return { ok: true, symbols }
239
+ } catch (error) {
240
+ return { ok: false, error: 'LSP 大纲查询失败:' + String(error) }
241
+ }
242
+ },
243
+
244
+ 'edrv.lsp.workspaceSymbol': async (args) => {
245
+ const sc = await rootOf(args.sessionId)
246
+ if ('err' in sc) return { ok: false, error: sc.err }
247
+ const out: { symbols: never[]; truncated?: boolean } = { symbols: [] }
248
+ for (const lang of ['lua', 'csharp']) {
249
+ const existing = serverOf(sc.root, lang)
250
+ if (!existing) continue
251
+ try {
252
+ const symbols = await existing.workspaceSymbol(args.query)
253
+ if (symbols.length) out.symbols.push(...(symbols as never[]))
254
+ } catch (error) {
255
+ /* 单语言失败忽略 */
256
+ }
257
+ }
258
+ const truncated = out.symbols.length > 500
259
+ return { ok: true, symbols: out.symbols.slice(0, 500), truncated: truncated || undefined }
260
+ },
261
+
262
+ 'edrv.lsp.hover': async (args) => {
263
+ const lang = langOfPath(args.path)
264
+ if (!lang) return { ok: true, hover: undefined }
265
+ const sc = await rootOf(args.sessionId)
266
+ if ('err' in sc) return { ok: false, error: sc.err }
267
+ try {
268
+ const server = serverOf(sc.root, lang)
269
+ if (!server) return { ok: true, hover: undefined }
270
+ const hover = await server.hover(args.path, args.position.line, args.position.character)
271
+ return { ok: true, hover: hover ?? undefined }
272
+ } catch (error) {
273
+ return { ok: false, error: 'LSP hover 查询失败:' + String(error) }
274
+ }
275
+ },
276
+
277
+ // --region 扩展管理(edrv.lsp.ext.*:已装/市场/安装/卸载/更新)
278
+ 'edrv.lsp.ext.list': async () => ({ ok: true, extensions: listInstalled() }),
279
+
280
+ 'edrv.lsp.ext.market': async (args) => {
281
+ try {
282
+ const items = await marketSearch(args.query, args.size ?? 12)
283
+ return { ok: true, extensions: items }
284
+ } catch (error) {
285
+ return { ok: false, error: '市场搜索失败:' + String(error) }
286
+ }
287
+ },
288
+
289
+ 'edrv.lsp.ext.install': async (args) => {
290
+ try {
291
+ let info: ExtInfo
292
+ if (args.vsixPath) info = await installVsixFile(args.vsixPath)
293
+ else if (args.namespace && args.name) info = await installFromMarket(args.namespace, args.name, args.version)
294
+ else return { ok: false, error: '缺少安装源(vsixPath 或 namespace+name)' }
295
+ return { ok: true, extension: info }
296
+ } catch (error) {
297
+ return { ok: false, error: '安装失败:' + String(error) }
298
+ }
299
+ },
300
+
301
+ 'edrv.lsp.ext.uninstall': async (args) => {
302
+ return uninstall(args.id) ? { ok: true } : { ok: false, error: '扩展未安装:' + args.id }
303
+ },
304
+
305
+ 'edrv.lsp.ext.update': async (args) => {
306
+ try {
307
+ const updated = await updateExtension(args.id)
308
+ if (!updated) {
309
+ const current = listInstalled().find((e) => e.id === args.id)
310
+ return current ? { ok: true, extension: current, updated: false } : { ok: false, error: '扩展未安装:' + args.id }
311
+ }
312
+ return { ok: true, extension: updated, updated: true }
313
+ } catch (error) {
314
+ return { ok: false, error: '更新失败:' + String(error) }
315
+ }
316
+ },
317
+
318
+ 'edrv.lsp.ext.updates': async () => {
319
+ try {
320
+ const updates = []
321
+ for (const ext of listInstalled()) {
322
+ const meta = await marketGet(ext.namespace, ext.name)
323
+ const latest = String(meta?.version ?? '')
324
+ if (latest && latest !== ext.version) updates.push({ id: ext.id, current: ext.version, latest, displayName: ext.displayName })
325
+ }
326
+ return { ok: true, updates }
327
+ } catch (error) {
328
+ return { ok: false, error: '检查更新失败:' + String(error) }
329
+ }
330
+ },
331
+ // --endregion
332
+ }
333
+ return { handlers, disposeSession }
334
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * dsh-vscode-mode host — 单语言服务器会话(绑定 工作区根 + 语言)。
3
+ * 负责:启动传输 + LSP 客户端握手、文档同步(didOpen/didChange/didSave/didClose)、
4
+ * 请求封装(definition/references/documentSymbol/workspaceSymbol/hover)、状态上报。
5
+ * 作者 ddj 2026-08-27
6
+ */
7
+ import { spawnServer, type Transport } from './transport.js'
8
+ import { createLspClient, type LspClient } from './client.js'
9
+ import { pathToFileUri } from './uri.js'
10
+ import type { LspProviderSpec } from './providers.js'
11
+ import type {
12
+ LspLocation,
13
+ LspServerCapabilities,
14
+ LspServerPhase,
15
+ LspServerStatus,
16
+ LspSymbol,
17
+ } from '../shared/lsp.js'
18
+
19
+ export interface LspServer {
20
+ readonly languageId: string
21
+ readonly root: string
22
+ readonly phase: LspServerPhase
23
+ readonly capabilities: LspServerCapabilities
24
+ onStateChange: ((status: LspServerStatus) => void) | null
25
+ status(): LspServerStatus
26
+ start(): Promise<boolean>
27
+ sync(path: string, text: string, version: number): void
28
+ close(path: string): void
29
+ definition(path: string, line: number, character: number): Promise<LspLocation[]>
30
+ references(path: string, line: number, character: number, includeDeclaration: boolean): Promise<LspLocation[]>
31
+ documentSymbol(path: string): Promise<LspSymbol[]>
32
+ workspaceSymbol(query: string): Promise<LspSymbol[]>
33
+ hover(path: string, line: number, character: number): Promise<{ contents: string[] } | null>
34
+ dispose(): Promise<void>
35
+ }
36
+
37
+ /** 文档注册表:path(工作区相对) → { version, text, uri }。 */
38
+ interface OpenDoc {
39
+ path: string
40
+ uri: string
41
+ version: number
42
+ text: string
43
+ }
44
+
45
+ const RESTARTABLE = true
46
+
47
+ /**
48
+ * 创建语言服务器会话。
49
+ * @author ddj 2026年08月27号
50
+ * @param spec provider 规格(argv/cwd)
51
+ * @param root 工作区根(绝对路径)
52
+ * @param languageId 语言 id
53
+ * @param logger 诊断日志
54
+ * @returns 会话句柄
55
+ */
56
+ export function createLspServer(spec: LspProviderSpec, root: string, languageId: string, logger?: (line: string) => void): LspServer {
57
+ const rootUri = pathToFileUri(root)
58
+ const docs = new Map<string, OpenDoc>()
59
+ let phase: LspServerPhase = 'idle'
60
+ let transport: Transport | null = null
61
+ let client: LspClient | null = null
62
+ let capabilities: LspServerCapabilities = { definition: false, references: false, documentSymbol: false, workspaceSymbol: false, hover: false }
63
+ let disposed = false
64
+ let readyWait: Promise<boolean> = Promise.resolve(false)
65
+ let resolveReady: ((ready: boolean) => void) | null = null
66
+ let lastProgress: { value?: number; message?: string } = {}
67
+ let lastStatus: LspServerStatus = { languageId, source: spec.kind, phase: 'idle', reason: spec.ready ? undefined : spec.reason, root }
68
+
69
+ const log = (line: string): void => logger?.('[' + languageId + '@' + root + '] ' + line)
70
+
71
+ const setPhase = (next: LspServerPhase, reason?: string): void => {
72
+ phase = next
73
+ lastStatus = { languageId, source: spec.kind, phase: next, reason, root, ...progressFields() }
74
+ server.onStateChange?.(lastStatus)
75
+ }
76
+
77
+ /** 返回当前进度字段,避免空进度污染状态载荷。 */
78
+ const progressFields = (): Pick<LspServerStatus, 'progress' | 'progressMessage'> => ({
79
+ progress: lastProgress.value,
80
+ progressMessage: lastProgress.message,
81
+ })
82
+
83
+ /** 处理 LSP $/progress 通知并映射为编辑器状态。 */
84
+ const applyProgress = (params: unknown): void => {
85
+ const value = params as { value?: unknown }
86
+ const report = value?.value as { kind?: unknown; percentage?: unknown; message?: unknown } | undefined
87
+ if (report?.kind === 'begin') {
88
+ lastProgress = { value: 0, message: typeof report.message === 'string' ? report.message : '正在解析工作区' }
89
+ if (phase === 'ready') phase = 'indexing'
90
+ } else if (report?.kind === 'end') {
91
+ lastProgress = { value: 100, message: typeof report.message === 'string' ? report.message : '解析完成' }
92
+ if (phase === 'indexing') phase = 'ready'
93
+ } else if (typeof report?.percentage === 'number') {
94
+ lastProgress = { value: Math.max(0, Math.min(100, report.percentage)), message: typeof report.message === 'string' ? report.message : lastProgress.message }
95
+ if (phase === 'ready') phase = 'indexing'
96
+ } else if (typeof report?.message === 'string') {
97
+ lastProgress = { ...lastProgress, message: report.message }
98
+ }
99
+ lastStatus = { ...lastStatus, phase, ...progressFields() }
100
+ server.onStateChange?.(lastStatus)
101
+ }
102
+
103
+ const ensureClient = (): LspClient => {
104
+ if (!client) throw new Error('LSP 服务器未启动')
105
+ return client
106
+ }
107
+
108
+ const server: LspServer = {
109
+ languageId,
110
+ root,
111
+ get phase() {
112
+ return phase
113
+ },
114
+ get capabilities() {
115
+ return capabilities
116
+ },
117
+ status(): LspServerStatus {
118
+ return lastStatus
119
+ },
120
+ onStateChange: null,
121
+
122
+ async start(): Promise<boolean> {
123
+ if (disposed) return false
124
+ if (phase === 'starting') return readyWait
125
+ if (phase === 'ready' || phase === 'indexing') return true
126
+ readyWait = new Promise((resolve) => { resolveReady = resolve })
127
+ lastProgress = {}
128
+ setPhase('starting')
129
+ try {
130
+ transport = spawnServer(
131
+ { argv: spec.argv, cwd: spec.cwd ?? root },
132
+ (message) => { /* 由 client 接管 */ },
133
+ (line) => log('stderr: ' + line),
134
+ )
135
+ client = createLspClient(transport, {
136
+ onReady: (caps) => {
137
+ capabilities = caps
138
+ // 重放已打开文档(崩溃重启后恢复服务器侧状态)
139
+ for (const doc of docs.values()) {
140
+ client?.notify('textDocument/didOpen', {
141
+ textDocument: { uri: doc.uri, languageId, version: doc.version, text: doc.text },
142
+ })
143
+ }
144
+ setPhase('ready')
145
+ resolveReady?.(true)
146
+ resolveReady = null
147
+ log('ready: definition=' + caps.definition + ' references=' + caps.references + ' symbols=' + caps.documentSymbol)
148
+ },
149
+ onProgress: (params) => applyProgress(params),
150
+ onExit: (code, signal) => {
151
+ if (disposed) return
152
+ resolveReady?.(false)
153
+ resolveReady = null
154
+ log('server exited (' + String(code) + '/' + String(signal) + ')')
155
+ client = null
156
+ transport = null
157
+ if (RESTARTABLE) setPhase('idle', '服务器已退出(' + String(code) + '/' + String(signal) + '),等待重启')
158
+ else setPhase('stopped', '服务器已退出')
159
+ },
160
+ onLog: (line) => log('lsp: ' + line),
161
+ })
162
+ // 让 client 接管 stdout 消息
163
+ const caps = await client.initialize(rootUri, [rootUri])
164
+ capabilities = caps
165
+ return true
166
+ } catch (error) {
167
+ log('start failed: ' + String(error))
168
+ setPhase('unavailable', '启动失败:' + String(error))
169
+ resolveReady?.(false)
170
+ resolveReady = null
171
+ try { await server.dispose() } catch { /* 忽略 */ }
172
+ return false
173
+ }
174
+ },
175
+
176
+ sync(path: string, text: string, version: number): void {
177
+ const existing = docs.get(path)
178
+ const uri = existing?.uri ?? pathToFileUri(root + '/' + path.split('/').join('/'))
179
+ if (existing) {
180
+ existing.version = version
181
+ existing.text = text
182
+ if (phase === 'ready' || phase === 'indexing') {
183
+ client?.notify('textDocument/didChange', {
184
+ textDocument: { uri, version },
185
+ contentChanges: [{ text }],
186
+ })
187
+ }
188
+ return
189
+ }
190
+ docs.set(path, { path, uri, version, text })
191
+ if (phase === 'ready' || phase === 'indexing') {
192
+ client?.notify('textDocument/didOpen', {
193
+ textDocument: { uri, languageId, version, text },
194
+ })
195
+ }
196
+ },
197
+
198
+ close(path: string): void {
199
+ const doc = docs.get(path)
200
+ if (!doc) return
201
+ docs.delete(path)
202
+ if (phase === 'ready' || phase === 'indexing') {
203
+ client?.notify('textDocument/didClose', { textDocument: { uri: doc.uri } })
204
+ }
205
+ },
206
+
207
+ async definition(path: string, line: number, character: number): Promise<LspLocation[]> {
208
+ const doc = docs.get(path)
209
+ if (!doc || !(await readyWait)) return []
210
+ const result = await ensureClient().request<unknown>(
211
+ 'textDocument/definition',
212
+ { textDocument: { uri: doc.uri }, position: { line, character } },
213
+ )
214
+ return normalizeLocations(result)
215
+ },
216
+
217
+ async references(path: string, line: number, character: number, includeDeclaration: boolean): Promise<LspLocation[]> {
218
+ const doc = docs.get(path)
219
+ if (!doc || !(await readyWait)) return []
220
+ const result = await ensureClient().request<unknown>(
221
+ 'textDocument/references',
222
+ {
223
+ textDocument: { uri: doc.uri },
224
+ position: { line, character },
225
+ context: { includeDeclaration },
226
+ },
227
+ )
228
+ return normalizeLocations(result)
229
+ },
230
+
231
+ async documentSymbol(path: string): Promise<LspSymbol[]> {
232
+ const doc = docs.get(path)
233
+ if (!doc || !(await readyWait)) return []
234
+ const result = await ensureClient().request<unknown>('textDocument/documentSymbol', { textDocument: { uri: doc.uri } })
235
+ if (!Array.isArray(result)) return []
236
+ return (result as unknown[]).map(normalizeSymbol).filter((s): s is LspSymbol => s !== null)
237
+ },
238
+
239
+ async workspaceSymbol(query: string): Promise<LspSymbol[]> {
240
+ if (!(await readyWait)) return []
241
+ const result = await ensureClient().request<unknown>('workspace/symbol', { query })
242
+ if (!Array.isArray(result)) return []
243
+ return (result as unknown[]).map(normalizeSymbol).filter((s): s is LspSymbol => s !== null)
244
+ },
245
+
246
+ async hover(path: string, line: number, character: number): Promise<{ contents: string[] } | null> {
247
+ const doc = docs.get(path)
248
+ if (!doc || !(await readyWait)) return null
249
+ const result = await ensureClient().request<{ contents?: unknown; range?: unknown } | null>(
250
+ 'textDocument/hover',
251
+ { textDocument: { uri: doc.uri }, position: { line, character } },
252
+ )
253
+ if (!result || !result.contents) return null
254
+ return { contents: stringifyHoverContents(result.contents) }
255
+ },
256
+
257
+ async dispose(): Promise<void> {
258
+ disposed = true
259
+ resolveReady?.(false)
260
+ resolveReady = null
261
+ if (client) {
262
+ await client.shutdown().catch(() => {})
263
+ client = null
264
+ }
265
+ if (transport) {
266
+ transport.dispose()
267
+ transport = null
268
+ }
269
+ setPhase('stopped')
270
+ },
271
+ }
272
+ return server
273
+ }
274
+
275
+ /** 归一化 definition/references 结果(Location | Location[] | LocationLink[])。 */
276
+ function normalizeLocations(result: unknown): LspLocation[] {
277
+ if (!Array.isArray(result)) return []
278
+ const out: LspLocation[] = []
279
+ for (const item of result) {
280
+ if (!item || typeof item !== 'object') continue
281
+ const obj = item as { uri?: unknown; range?: unknown; targetUri?: unknown; targetRange?: unknown; targetSelectionRange?: unknown }
282
+ const uri = typeof obj.targetUri === 'string' ? obj.targetUri : typeof obj.uri === 'string' ? obj.uri : ''
283
+ const range = (obj.targetRange ?? obj.range) as { start?: { line?: unknown; character?: unknown }; end?: { line?: unknown; character?: unknown } } | undefined
284
+ if (!uri || !range || !range.start || !range.end) continue
285
+ out.push({
286
+ uri,
287
+ range: {
288
+ start: { line: toInt(range.start.line), character: toInt(range.start.character) },
289
+ end: { line: toInt(range.end.line), character: toInt(range.end.character) },
290
+ },
291
+ })
292
+ }
293
+ return out
294
+ }
295
+
296
+ /** 归一化 documentSymbol/workspaceSymbol 结果(DocumentSymbol | SymbolInformation)。 */
297
+ function normalizeSymbol(item: unknown): LspSymbol | null {
298
+ if (!item || typeof item !== 'object') return null
299
+ const obj = item as {
300
+ name?: unknown
301
+ kind?: unknown
302
+ detail?: unknown
303
+ containerName?: unknown
304
+ range?: { start?: unknown; end?: unknown }
305
+ selectionRange?: { start?: unknown; end?: unknown }
306
+ children?: unknown
307
+ location?: { uri?: unknown; range?: unknown }
308
+ }
309
+ if (typeof obj.name !== 'string') return null
310
+ const selection = obj.selectionRange ?? (obj.location && (obj.location as { range?: unknown }).range)
311
+ const range = obj.range ?? selection
312
+ if (!range || typeof range !== 'object') return null
313
+ const r = range as { start?: { line?: unknown; character?: unknown }; end?: { line?: unknown; character?: unknown } }
314
+ if (!r.start || !r.end) return null
315
+ const symbol: LspSymbol = {
316
+ name: obj.name,
317
+ kind: toInt(obj.kind),
318
+ range: { start: { line: toInt((r.start as { line?: unknown }).line), character: toInt((r.start as { character?: unknown }).character) }, end: { line: toInt((r.end as { line?: unknown }).line), character: toInt((r.end as { character?: unknown }).character) } },
319
+ selectionRange: selection
320
+ ? { start: { line: toInt(((selection as { start?: { line?: unknown } }).start as { line?: unknown } | undefined)?.line ?? toInt((r.start as { line?: unknown }).line)), character: toInt(((selection as { start?: { character?: unknown } }).start as { character?: unknown } | undefined)?.character ?? toInt((r.start as { character?: unknown }).character)) }, end: { line: toInt(((selection as { end?: { line?: unknown } }).end as { line?: unknown } | undefined)?.line ?? toInt((r.end as { line?: unknown }).line)), character: toInt(((selection as { end?: { character?: unknown } }).end as { character?: unknown } | undefined)?.character ?? toInt((r.end as { character?: unknown }).character)) } }
321
+ : { start: { line: toInt((r.start as { line?: unknown }).line), character: toInt((r.start as { character?: unknown }).character) }, end: { line: toInt((r.end as { line?: unknown }).line), character: toInt((r.end as { character?: unknown }).character) } },
322
+ }
323
+ if (typeof obj.detail === 'string' && obj.detail) symbol.detail = obj.detail
324
+ if (typeof obj.containerName === 'string' && obj.containerName) symbol.containerName = obj.containerName
325
+ if (Array.isArray(obj.children)) {
326
+ const kids = obj.children.map(normalizeSymbol).filter((s): s is LspSymbol => s !== null)
327
+ if (kids.length) symbol.children = kids
328
+ }
329
+ return symbol
330
+ }
331
+
332
+ /** hover contents → 文本行(MarkupContent | MarkedString | MarkedString[])。 */
333
+ function stringifyHoverContents(contents: unknown): string[] {
334
+ if (typeof contents === 'string') return [contents]
335
+ if (Array.isArray(contents)) return contents.map(stringifyHoverContents).flat().filter(Boolean)
336
+ if (contents && typeof contents === 'object') {
337
+ const value = (contents as { value?: unknown }).value
338
+ if (typeof value === 'string') return [value]
339
+ }
340
+ return []
341
+ }
342
+
343
+ function toInt(value: unknown): number {
344
+ const n = Number(value)
345
+ return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0
346
+ }