dsh-vscode-mode 0.1.36 → 0.1.43

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.
@@ -5,12 +5,12 @@
5
5
  * 供 manager 启动服务器。
6
6
  * 作者 ddj 2026-08-27
7
7
  */
8
- import { existsSync, readdirSync, type Dirent } from 'node:fs'
8
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, type Dirent } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
- import { join, sep } from 'node:path'
10
+ import { dirname, join, sep } from 'node:path'
11
11
  import { isAbsolute } from 'node:path'
12
12
  import { extensionsRoot } from './extmgr.js'
13
- import { dshHome } from '../paths.js'
13
+ import { dshHome, lspSpecCacheFile } from '../paths.js'
14
14
 
15
15
  /** provider 来源标记(与 shared/lsp.ts 的 status.source 一致)。 */
16
16
  export type LspProviderKind = 'extension' | 'discover' | 'manual' | 'none'
@@ -22,6 +22,8 @@ export interface LspProviderSpec {
22
22
  cwd?: string
23
23
  ready: boolean
24
24
  reason?: string
25
+ version?: string
26
+ providerName?: string
25
27
  }
26
28
 
27
29
  /** 语言 → 扩展名(用于判断哪些文件触发该语言服务器)。 */
@@ -88,7 +90,7 @@ export function registerExtensionProvider(languageId: string, argv: string[], cw
88
90
  export function extensionProviderFor(languageId: string): LspProviderSpec | null {
89
91
  const p = extProviders.get(languageId)
90
92
  if (!p || !p.argv.length) return null
91
- return { languageId, kind: 'extension', argv: p.argv, cwd: p.cwd, ready: true }
93
+ return { languageId, kind: 'extension', argv: p.argv, cwd: p.cwd, ready: true, version: p.version, providerName: p.version ? 'EmmyLua' : '语言服务器扩展' }
92
94
  }
93
95
 
94
96
  /** 清空全部扩展源注册(插件卸载/扩展目录重建时)。 */
@@ -123,6 +125,25 @@ export function findDotnet(): string | null {
123
125
  return null
124
126
  }
125
127
 
128
+ /** EmmyLua 自动发现候选(tangzx.emmylua-* / server/emmylua_ls)。 */
129
+ export function candidateEmmyLua(home = dshHome()): { path: string; version?: string } | null {
130
+ const candidates: { path: string; version?: string }[] = []
131
+ for (const dir of vscodeExtensionsDirs(home)) {
132
+ for (const extDir of listMatchingDirs(dir, 'tangzx.emmylua-')) {
133
+ const server = join(extDir, 'server', 'emmylua_ls' + exeSuffix())
134
+ if (!existsSync(server)) continue
135
+ let version: string | undefined
136
+ try {
137
+ const manifest = JSON.parse(readFileSync(join(extDir, 'package.json'), 'utf8')) as { version?: unknown }
138
+ if (typeof manifest.version === 'string') version = manifest.version
139
+ } catch (error) { /* 清单读取失败不影响入口使用 */ }
140
+ candidates.push({ path: server, version })
141
+ }
142
+ }
143
+ candidates.sort((a, b) => (b.version ?? '').localeCompare(a.version ?? '', undefined, { numeric: true }) || a.path.localeCompare(b.path))
144
+ return candidates[0] ?? null
145
+ }
146
+
126
147
  /** LuaLS 自动发现候选(VSCode 扩展目录 + extmgr 目录下 sumneko.lua-* 的 server/bin 可执行)。 */
127
148
  export function candidateLuaServers(home = dshHome()): string[] {
128
149
  const plat = platformServerDir()
@@ -186,13 +207,17 @@ export function resolveLuaProvider(manual?: { command?: string; path?: string },
186
207
  const base = { languageId: 'lua' }
187
208
  const manualResolved = manualSpec(manual)
188
209
  if (manualResolved.ready) return { ...base, kind: 'manual', argv: manualResolved.argv, cwd: manualResolved.cwd, ready: true }
189
- const ext = extensionProviderFor('lua')
190
- if (ext) return ext
191
- const candidates = candidateLuaServers(home)
192
- if (candidates.length) return { ...base, kind: 'discover', argv: [candidates[0]], cwd: undefined, ready: true }
193
- const inPath = findInPath('lua-language-server' + exeSuffix())
194
- if (inPath) return { ...base, kind: 'discover', argv: [inPath], ready: true }
195
- return { ...base, kind: 'none', argv: [], ready: false, reason: manualResolved.reason ?? '未发现 lua-language-server(可安装 LuaLS 或手动指定路径)' }
210
+ return resolveCached('lua', manual, home, (h) => {
211
+ const ext = extensionProviderFor('lua')
212
+ if (ext) return ext
213
+ const emmy = candidateEmmyLua(h)
214
+ if (emmy) return { ...base, kind: 'extension', argv: [emmy.path], ready: true, version: emmy.version, providerName: 'EmmyLua' }
215
+ const candidates = candidateLuaServers(h)
216
+ if (candidates.length) return { ...base, kind: 'discover', argv: [candidates[0]], cwd: undefined, ready: true }
217
+ const inPath = findInPath('lua-language-server' + exeSuffix())
218
+ if (inPath) return { ...base, kind: 'discover', argv: [inPath], ready: true }
219
+ return { ...base, kind: 'none', argv: [], ready: false, reason: manualResolved.reason ?? '未发现 lua-language-server(可安装 LuaLS 或手动指定路径)' }
220
+ })
196
221
  }
197
222
 
198
223
  /**
@@ -206,19 +231,21 @@ export function resolveCSharpProvider(manual?: { command?: string; path?: string
206
231
  const base = { languageId: 'csharp' }
207
232
  const manualResolved = manualSpec(manual)
208
233
  if (manualResolved.ready) return { ...base, kind: 'manual', argv: manualResolved.argv, cwd: manualResolved.cwd, ready: true }
209
- const ext = extensionProviderFor('csharp')
210
- if (ext) return ext
211
- const discovered = candidateCSharpServers(home)
212
- if (discovered.roslynDll && discovered.dotnet) {
213
- return { ...base, kind: 'discover', argv: [discovered.dotnet, discovered.roslynDll, '--stdio'], ready: true }
214
- }
215
- if (discovered.omnisharpExe) {
216
- return { ...base, kind: 'discover', argv: [discovered.omnisharpExe, '--stdio'], ready: true }
217
- }
218
- const reasons: string[] = []
219
- if (!discovered.dotnet) reasons.push('未找到 dotnet')
220
- if (!discovered.roslynDll && !discovered.omnisharpExe) reasons.push('未发现 ms-dotnettools.csharp 扩展')
221
- return { ...base, kind: 'none', argv: [], ready: false, reason: reasons.join(';') + '(可手动指定 Roslyn/OmniSharp 入口)' }
234
+ return resolveCached('csharp', manual, home, (h) => {
235
+ const ext = extensionProviderFor('csharp')
236
+ if (ext) return ext
237
+ const discovered = candidateCSharpServers(h)
238
+ if (discovered.roslynDll && discovered.dotnet) {
239
+ return { ...base, kind: 'discover', argv: [discovered.dotnet, discovered.roslynDll, '--stdio'], ready: true }
240
+ }
241
+ if (discovered.omnisharpExe) {
242
+ return { ...base, kind: 'discover', argv: [discovered.omnisharpExe, '--stdio'], ready: true }
243
+ }
244
+ const reasons: string[] = []
245
+ if (!discovered.dotnet) reasons.push('未找到 dotnet')
246
+ if (!discovered.roslynDll && !discovered.omnisharpExe) reasons.push('未发现 ms-dotnettools.csharp 扩展')
247
+ return { ...base, kind: 'none', argv: [], ready: false, reason: reasons.join(';') + '(可手动指定 Roslyn/OmniSharp 入口)' }
248
+ })
222
249
  }
223
250
 
224
251
  /** 语言 → provider 解析器(可扩展;Phase 2 由 extmgr 注册扩展源 provider)。 */
@@ -233,3 +260,155 @@ export const PROVIDER_RESOLVERS: Record<string, ProviderResolver> = {
233
260
  export function looksAbsolute(value: string): boolean {
234
261
  return isAbsolute(value)
235
262
  }
263
+
264
+ // --region provider 发现结果缓存(进程级 + 磁盘持久化)
265
+ /**
266
+ * 发现结果缓存:只缓存"自动发现/扩展源"结论,不缓存 manual(用户改配置要立即生效)。
267
+ * 进程内 Map 命中优先;未命中读磁盘缓存(跨会话/跨重启共享);仍无才真扫描。
268
+ * 缓存条目按"配置签名 + 扩展目录指纹"校验,任一变化即失效重扫。
269
+ * 作者 ddj 2026年08月28号
270
+ */
271
+
272
+ /** 缓存条目:spec + 生成时的环境指纹。 */
273
+ interface SpecCacheEntry {
274
+ spec: LspProviderSpec
275
+ fingerprint: string
276
+ at: number
277
+ }
278
+
279
+ /** 进程级缓存(home|languageId → 条目),同一 DSH home 的不同会话共享。 */
280
+ const specCache = new Map<string, SpecCacheEntry>()
281
+ /** 磁盘缓存是否已载入本进程(home → loaded),不同 profile 不串缓存。 */
282
+ const diskLoaded = new Set<string>()
283
+ /** 自动发现成功结果的复用时长;过期后才检查目录指纹。 */
284
+ const SPEC_CACHE_TTL_MS = 30 * 60 * 1000
285
+ /** 未发现结果的短缓存时长,避免缺失服务器时反复扫描 PATH。 */
286
+ const SPEC_NEGATIVE_CACHE_MS = 60 * 1000
287
+
288
+ /** 生成带 home 的缓存键,避免不同 profile 的 provider 结果串用。 */
289
+ function cacheEntryKey(home: string, languageId: string): string {
290
+ return home + '|' + languageId
291
+ }
292
+
293
+ /**
294
+ * 构造缓存 key:语言 + 手动配置(manual 结果不缓存,故只参与"是否走缓存"判定)。
295
+ * @author ddj 2026年08月28号
296
+ * @param languageId 语言 id
297
+ * @param manual 手动配置
298
+ * @returns 缓存 key(manual 有效时返回空串表示跳过缓存)
299
+ */
300
+ function cacheKeyOf(languageId: string, manual?: { command?: string; path?: string }): string {
301
+ const pathValue = manual?.path?.trim() ?? ''
302
+ const commandValue = manual?.command?.trim() ?? ''
303
+ if (pathValue || commandValue) return '' // 手动配置:不缓存,立即生效
304
+ return languageId
305
+ }
306
+
307
+ /**
308
+ * 环境指纹:扩展目录清单 + 平台。目录增删/换平台即失效(防缓存陈旧指向已删扩展)。
309
+ * @author ddj 2026年08月28号
310
+ * @param home DSH home
311
+ * @returns 指纹串
312
+ */
313
+ function fingerprintOf(home: string): string {
314
+ const dirs = vscodeExtensionsDirs(home)
315
+ const marks = dirs.map((dir) => {
316
+ try {
317
+ return readdirSync(dir).sort().join(',')
318
+ } catch (error) {
319
+ return '?'
320
+ }
321
+ })
322
+ return process.platform + '|' + marks.join('|')
323
+ }
324
+
325
+ /**
326
+ * 读取磁盘缓存(幂等:每进程只载一次;schema 文件名已隔离版本)。
327
+ * @author ddj 2026年08月28号
328
+ * @param home DSH home
329
+ */
330
+ function loadDiskCache(home: string): void {
331
+ if (diskLoaded.has(home)) return
332
+ diskLoaded.add(home)
333
+ const file = lspSpecCacheFile(home)
334
+ try {
335
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as Record<string, SpecCacheEntry>
336
+ for (const [languageId, entry] of Object.entries(raw)) {
337
+ if (entry && typeof entry === 'object' && entry.spec && typeof entry.fingerprint === 'string') {
338
+ specCache.set(cacheEntryKey(home, languageId), entry)
339
+ }
340
+ }
341
+ } catch (error) { /* 首次运行/损坏:静默降级为真扫描 */ }
342
+ }
343
+
344
+ /**
345
+ * 写回磁盘缓存(best-effort;写失败不影响本次解析结果)。
346
+ * @author ddj 2026年08月28号
347
+ * @param home DSH home
348
+ */
349
+ function saveDiskCache(home: string): void {
350
+ const file = lspSpecCacheFile(home)
351
+ try {
352
+ mkdirSync(dirname(file), { recursive: true })
353
+ const out: Record<string, SpecCacheEntry> = {}
354
+ const prefix = home + '|'
355
+ for (const [key, entry] of specCache) if (key.startsWith(prefix)) out[key.slice(prefix.length)] = entry
356
+ writeFileSync(file, JSON.stringify(out), 'utf8')
357
+ } catch (error) { /* 写入失败静默 */ }
358
+ }
359
+
360
+ /**
361
+ * 带缓存执行 provider 解析(自动发现路径专用)。
362
+ * @author ddj 2026年08月28号
363
+ * @param languageId 语言 id
364
+ * @param manual 手动配置
365
+ * @param home DSH home
366
+ * @param resolve 真解析函数(未命中时调用)
367
+ * @returns provider 规格
368
+ */
369
+ function resolveCached(
370
+ languageId: string,
371
+ manual: { command?: string; path?: string } | undefined,
372
+ home: string,
373
+ resolve: (h: string) => LspProviderSpec,
374
+ ): LspProviderSpec {
375
+ const key = cacheKeyOf(languageId, manual)
376
+ if (!key) return resolve(home) // 手动配置:不走缓存
377
+ loadDiskCache(home)
378
+ const fullKey = cacheEntryKey(home, key)
379
+ const hit = specCache.get(fullKey)
380
+ if (hit && isCacheFresh(hit)) return hit.spec
381
+ const fingerprint = fingerprintOf(home)
382
+ if (hit && hit.fingerprint === fingerprint) {
383
+ hit.at = Date.now()
384
+ return hit.spec
385
+ }
386
+ const spec = resolve(home)
387
+ specCache.set(fullKey, { spec, fingerprint, at: Date.now() })
388
+ saveDiskCache(home)
389
+ return spec
390
+ }
391
+
392
+ /** 判断缓存条目是否仍可直接复用,不触发扩展目录扫描。 */
393
+ function isCacheFresh(entry: SpecCacheEntry): boolean {
394
+ const age = Date.now() - entry.at
395
+ const ttl = entry.spec.kind === 'none' ? SPEC_NEGATIVE_CACHE_MS : SPEC_CACHE_TTL_MS
396
+ if (age < 0 || age >= ttl) return false
397
+ const executable = entry.spec.argv[0]
398
+ return !entry.spec.ready || !executable || existsSync(executable)
399
+ }
400
+
401
+ /**
402
+ * 清空 provider 发现缓存(扩展安装/卸载、配置变更后调用;进程级 + 删磁盘文件)。
403
+ * @author ddj 2026年08月28号
404
+ * @param home DSH home(缺省真实)
405
+ */
406
+ export function clearProviderCache(home = dshHome()): void {
407
+ const prefix = home + '|'
408
+ for (const key of specCache.keys()) if (key.startsWith(prefix)) specCache.delete(key)
409
+ diskLoaded.delete(home)
410
+ try {
411
+ rmSync(lspSpecCacheFile(home), { force: true })
412
+ } catch (error) { /* 删除失败静默 */ }
413
+ }
414
+ // --endregion
package/src/lsp/rpc.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  import type { Ctx } from '../store.js'
7
7
  import { searchRoot } from '../search/ripgrep.js'
8
8
  import { sessionOf, cwdOf } from '../registry.js'
9
- import { resolveProviderSpec, langOfPath, configFromPlugin, configFromSettings, LSP_SETTINGS_NS, type LspConfig } from './config.js'
9
+ import { resolveProviderSpec, langOfPath, configFromPlugin, configFromSettings, LSP_LANGUAGES, LSP_SETTINGS_NS, type LspConfig } from './config.js'
10
+ import { clearProviderCache } from './providers.js'
10
11
  import type { LspManager } from './manager.js'
11
12
  import type { RpcHandlerMap } from '../shared/rpc.js'
12
- import type { LspLocation } from '../shared/lsp.js'
13
+ import type { LspLocation, LspServerStatus } from '../shared/lsp.js'
13
14
  import { installFromMarket, installVsixFile, listInstalled, marketGet, marketSearch, uninstall, updateExtension, type ExtInfo } from './extmgr.js'
14
15
 
15
16
  export interface LspRpcDeps {
@@ -85,6 +86,21 @@ function createDocTracker() {
85
86
  sessionDocs.delete(sessionId)
86
87
  return [...toRelease]
87
88
  },
89
+ /** 清除指定语言的文档归属,让当前客户端重新同步时重新 acquire。 */
90
+ resetLanguage(languageId: string): void {
91
+ for (const [docKey, owners] of openDocs) {
92
+ if (!docKey.includes('|' + languageId + '|')) continue
93
+ for (const sessionId of owners) forgetDoc(sessionId, docKey)
94
+ openDocs.delete(docKey)
95
+ }
96
+ for (const key of [...rootLangRefs.keys()]) if (key.endsWith('|' + languageId)) rootLangRefs.delete(key)
97
+ },
98
+ /** 返回指定会话已打开的文档路径,用于重检测后恢复同步。 */
99
+ pathsFor(sessionId: string, languageId: string): string[] {
100
+ const docs = sessionDocs.get(sessionId)
101
+ if (!docs) return []
102
+ return [...docs].filter((key) => key.includes('|' + languageId + '|')).map((key) => key.slice(key.lastIndexOf('|') + 1))
103
+ },
88
104
  }
89
105
  }
90
106
 
@@ -122,6 +138,41 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
122
138
  const rootLocations = (locations: LspLocation[], root: string): LspLocation[] =>
123
139
  locations.map((location) => ({ ...location, root }))
124
140
 
141
+ /** 当前 provider 检测结论 → 设置页 idle 状态(不启动 server)。 */
142
+ const detectedStatus = (languageId: string, root?: string): LspServerStatus => {
143
+ const spec = resolveProviderSpec(ctx, pluginConfig, languageId)
144
+ return {
145
+ languageId,
146
+ source: spec.kind,
147
+ phase: 'idle',
148
+ reason: spec.ready ? undefined : spec.reason,
149
+ version: spec.version,
150
+ providerName: spec.providerName,
151
+ root,
152
+ }
153
+ }
154
+
155
+ /**
156
+ * 清发现缓存并重置旧 server(保留 tracker 文档记录);
157
+ * 下一次模型同步会按最新配置重新 acquire,已打开文档无需重开。
158
+ * 作者 ddj 2026年09月02号
159
+ * @param languageId 语言 id
160
+ * @param sessionId 会话语境(缺省重置全部工作区该语言)
161
+ * @returns 检测后的 idle 状态列表
162
+ */
163
+ const redetectLanguage = async (languageId: string, sessionId?: string): Promise<LspServerStatus[]> => {
164
+ clearProviderCache()
165
+ if (sessionId) {
166
+ const sc = await rootOf(sessionId)
167
+ if ('err' in sc) return []
168
+ manager.reset(sc.root, languageId)
169
+ return [detectedStatus(languageId, sc.root)]
170
+ }
171
+ const roots = [...new Set(manager.statusAll().filter((s) => s.languageId === languageId).map((s) => s.root))]
172
+ manager.resetLanguage(languageId)
173
+ return roots.length ? roots.map((root) => detectedStatus(languageId, root)) : [detectedStatus(languageId)]
174
+ }
175
+
125
176
  const disposeSession = (sessionId: string): void => {
126
177
  for (const rl of tracker.closeSession(sessionId)) {
127
178
  const sep = rl.lastIndexOf('|')
@@ -160,10 +211,12 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
160
211
  const settings = ctx.get('settings')
161
212
  if (!settings?.update) return { ok: false, error: '设置服务不可用' }
162
213
  await settings.update(LSP_SETTINGS_NS, { [lang]: target })
214
+ // 保存/切换后立即重新检测:清缓存 + 重置旧 server,旧状态不残留
215
+ const servers = await redetectLanguage(lang)
163
216
  const descriptor = settings.describe?.({ redactSecrets: true })?.find((item: { ns?: string }) => item.ns === LSP_SETTINGS_NS)
164
217
  const stored = descriptor?.value as Record<string, unknown> | undefined
165
218
  const merged: LspConfig = { ...configFromPlugin(pluginConfig), ...(stored as LspConfig) }
166
- return { ok: true, config: merged as unknown as Record<string, unknown> }
219
+ return { ok: true, config: merged as unknown as Record<string, unknown>, servers }
167
220
  } catch (error) {
168
221
  return { ok: false, error: '配置保存失败:' + String(error) }
169
222
  }
@@ -274,6 +327,31 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
274
327
  }
275
328
  },
276
329
 
330
+ 'edrv.lsp.semanticTokens': async (args) => {
331
+ const lang = langOfPath(args.path)
332
+ if (!lang) return { ok: true, tokens: undefined }
333
+ const sc = await rootOf(args.sessionId)
334
+ if ('err' in sc) return { ok: false, error: sc.err }
335
+ try {
336
+ const server = serverOf(sc.root, lang)
337
+ if (!server) return { ok: true, tokens: undefined }
338
+ return { ok: true, tokens: (await server.semanticTokens(args.path)) ?? undefined }
339
+ } catch (error) {
340
+ return { ok: false, error: 'LSP 语义分色查询失败:' + String(error) }
341
+ }
342
+ },
343
+
344
+ 'edrv.lsp.redetect': async (args) => {
345
+ if (!(LSP_LANGUAGES as readonly string[]).includes(args.languageId)) {
346
+ return { ok: false, error: '不支持的语言:' + args.languageId }
347
+ }
348
+ try {
349
+ return { ok: true, servers: await redetectLanguage(args.languageId) }
350
+ } catch (error) {
351
+ return { ok: false, error: '重新检测失败:' + String(error) }
352
+ }
353
+ },
354
+
277
355
  // --region 扩展管理(edrv.lsp.ext.*:已装/市场/安装/卸载/更新)
278
356
  'edrv.lsp.ext.list': async () => ({ ok: true, extensions: listInstalled() }),
279
357
 
@@ -292,6 +370,7 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
292
370
  if (args.vsixPath) info = await installVsixFile(args.vsixPath)
293
371
  else if (args.namespace && args.name) info = await installFromMarket(args.namespace, args.name, args.version)
294
372
  else return { ok: false, error: '缺少安装源(vsixPath 或 namespace+name)' }
373
+ clearProviderCache() // 新扩展可能成为 Lua provider,立即丢弃旧发现结果
295
374
  return { ok: true, extension: info }
296
375
  } catch (error) {
297
376
  return { ok: false, error: '安装失败:' + String(error) }
@@ -299,7 +378,9 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
299
378
  },
300
379
 
301
380
  'edrv.lsp.ext.uninstall': async (args) => {
302
- return uninstall(args.id) ? { ok: true } : { ok: false, error: '扩展未安装:' + args.id }
381
+ const ok = uninstall(args.id)
382
+ if (ok) clearProviderCache()
383
+ return ok ? { ok: true } : { ok: false, error: '扩展未安装:' + args.id }
303
384
  },
304
385
 
305
386
  'edrv.lsp.ext.update': async (args) => {
@@ -309,6 +390,7 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
309
390
  const current = listInstalled().find((e) => e.id === args.id)
310
391
  return current ? { ok: true, extension: current, updated: false } : { ok: false, error: '扩展未安装:' + args.id }
311
392
  }
393
+ clearProviderCache()
312
394
  return { ok: true, extension: updated, updated: true }
313
395
  } catch (error) {
314
396
  return { ok: false, error: '更新失败:' + String(error) }
package/src/lsp/server.ts CHANGED
@@ -8,8 +8,10 @@ import { spawnServer, type Transport } from './transport.js'
8
8
  import { createLspClient, type LspClient } from './client.js'
9
9
  import { pathToFileUri } from './uri.js'
10
10
  import type { LspProviderSpec } from './providers.js'
11
+ import { LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES } from '../shared/lsp.js'
11
12
  import type {
12
13
  LspLocation,
14
+ LspSemanticTokens,
13
15
  LspServerCapabilities,
14
16
  LspServerPhase,
15
17
  LspServerStatus,
@@ -31,6 +33,7 @@ export interface LspServer {
31
33
  documentSymbol(path: string): Promise<LspSymbol[]>
32
34
  workspaceSymbol(query: string): Promise<LspSymbol[]>
33
35
  hover(path: string, line: number, character: number): Promise<{ contents: string[] } | null>
36
+ semanticTokens(path: string): Promise<LspSemanticTokens | null>
34
37
  dispose(): Promise<void>
35
38
  }
36
39
 
@@ -59,18 +62,39 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
59
62
  let phase: LspServerPhase = 'idle'
60
63
  let transport: Transport | null = null
61
64
  let client: LspClient | null = null
62
- let capabilities: LspServerCapabilities = { definition: false, references: false, documentSymbol: false, workspaceSymbol: false, hover: false }
65
+ let capabilities: LspServerCapabilities = {
66
+ definition: false,
67
+ references: false,
68
+ documentSymbol: false,
69
+ workspaceSymbol: false,
70
+ hover: false,
71
+ semanticTokens: false,
72
+ semanticTokenTypes: [...LSP_SEMANTIC_TOKEN_TYPES],
73
+ semanticTokenModifiers: [...LSP_SEMANTIC_TOKEN_MODIFIERS],
74
+ }
63
75
  let disposed = false
64
76
  let readyWait: Promise<boolean> = Promise.resolve(false)
65
77
  let resolveReady: ((ready: boolean) => void) | null = null
66
78
  let lastProgress: { value?: number; message?: string } = {}
67
- let lastStatus: LspServerStatus = { languageId, source: spec.kind, phase: 'idle', reason: spec.ready ? undefined : spec.reason, root }
79
+ let lastStatus: LspServerStatus = {
80
+ languageId,
81
+ source: spec.kind,
82
+ phase: 'idle',
83
+ reason: spec.ready ? undefined : spec.reason,
84
+ root,
85
+ version: spec.version,
86
+ providerName: spec.providerName ?? (spec.version ? '语言服务器扩展' : undefined),
87
+ }
68
88
 
69
89
  const log = (line: string): void => logger?.('[' + languageId + '@' + root + '] ' + line)
90
+ const providerInfo = {
91
+ version: spec.version,
92
+ providerName: spec.providerName ?? (spec.version ? 'EmmyLua' : undefined),
93
+ }
70
94
 
71
95
  const setPhase = (next: LspServerPhase, reason?: string): void => {
72
96
  phase = next
73
- lastStatus = { languageId, source: spec.kind, phase: next, reason, root, ...progressFields() }
97
+ lastStatus = { languageId, source: spec.kind, phase: next, reason, root, ...providerInfo, ...progressFields() }
74
98
  server.onStateChange?.(lastStatus)
75
99
  }
76
100
 
@@ -254,6 +278,25 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
254
278
  return { contents: stringifyHoverContents(result.contents) }
255
279
  },
256
280
 
281
+ /** 查询全文 semantic tokens,并将服务器 legend 归一到插件固定 legend。 */
282
+ async semanticTokens(path: string): Promise<LspSemanticTokens | null> {
283
+ const doc = docs.get(path)
284
+ if (!doc || !(await readyWait) || !capabilities.semanticTokens) return null
285
+ const result = await ensureClient().request<{ data?: unknown; resultId?: unknown } | null>(
286
+ 'textDocument/semanticTokens/full',
287
+ { textDocument: { uri: doc.uri } },
288
+ )
289
+ if (!result || !Array.isArray(result.data)) return null
290
+ return {
291
+ data: normalizeSemanticData(
292
+ result.data,
293
+ capabilities.semanticTokenTypes ?? [],
294
+ capabilities.semanticTokenModifiers ?? [],
295
+ ),
296
+ resultId: typeof result.resultId === 'string' ? result.resultId : undefined,
297
+ }
298
+ },
299
+
257
300
  async dispose(): Promise<void> {
258
301
  disposed = true
259
302
  resolveReady?.(false)
@@ -344,3 +387,37 @@ function toInt(value: unknown): number {
344
387
  const n = Number(value)
345
388
  return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0
346
389
  }
390
+
391
+ /** 把服务器 legend 下的 5 元组流转换为插件固定 legend 下的 5 元组流。 */
392
+ function normalizeSemanticData(data: unknown[], types: string[], modifiers: string[]): number[] {
393
+ const out: number[] = []
394
+ let previousLine = 0
395
+ let previousCharacter = 0
396
+ let outputLine = 0
397
+ let outputCharacter = 0
398
+ for (let i = 0; i + 4 < data.length; i += 5) {
399
+ const deltaLine = toInt(data[i])
400
+ const deltaCharacter = toInt(data[i + 1])
401
+ const length = toInt(data[i + 2])
402
+ const sourceType = types[toInt(data[i + 3])] ?? 'variable'
403
+ const sourceModifiers = toInt(data[i + 4])
404
+ const line = previousLine + deltaLine
405
+ const character = deltaLine === 0 ? previousCharacter + deltaCharacter : deltaCharacter
406
+ const typeIndex = Math.max(0, LSP_SEMANTIC_TOKEN_TYPES.indexOf(sourceType as never))
407
+ let modifierBits = 0
408
+ for (let bit = 0; bit < modifiers.length; bit++) {
409
+ if ((sourceModifiers & (1 << bit)) === 0) continue
410
+ const modifier = modifiers[bit]
411
+ const targetBit = LSP_SEMANTIC_TOKEN_MODIFIERS.indexOf(modifier as never)
412
+ if (targetBit >= 0) modifierBits |= 1 << targetBit
413
+ }
414
+ const outputDeltaLine = line - outputLine
415
+ const outputDeltaCharacter = outputDeltaLine === 0 ? character - outputCharacter : character
416
+ out.push(outputDeltaLine, outputDeltaCharacter, length, typeIndex, modifierBits)
417
+ previousLine = line
418
+ previousCharacter = character
419
+ outputLine = line
420
+ outputCharacter = character
421
+ }
422
+ return out
423
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * dsh-vscode-mode host — LSP 配置设置 section 安装(动态依赖,缺失不抛错)。
2
+ * dsh-vscode-mode host — LSP 配置设置 section 安装(版本自适应;缺失/失败不抛错)。
3
3
  * ns:dsh-vscode-mode.languageServers;每语言 { enabled, command, path }。
4
- * 作者 ddj 2026-08-27
4
+ * 安装策略分派见 fileOpenSettings.runSettingsInstall(rc 线 legacy / alpha 线服务方法)。
5
+ * 作者 ddj 2026-08-27 / 2026-09-02
5
6
  */
6
7
  import type { Ctx } from '../store.js'
7
8
  import { LSP_LANGUAGES, LSP_SETTINGS_NS, type LspConfig } from './config.js'
8
- import { loadSettingsDeps, type SettingsDeps } from '../fileOpenSettings.js'
9
+ import { loadSettingsDeps, runSettingsInstall, type SettingsDeps } from '../fileOpenSettings.js'
9
10
 
10
11
  /**
11
12
  * 安装 LSP 设置 section。
@@ -15,7 +16,12 @@ import { loadSettingsDeps, type SettingsDeps } from '../fileOpenSettings.js'
15
16
  * @returns 是否成功安装
16
17
  */
17
18
  export async function installLspSettingsSection(ctx: Ctx, entry: LspConfig): Promise<boolean> {
18
- const deps = await loadSettingsDeps()
19
+ let deps: SettingsDeps | null = null
20
+ try {
21
+ deps = await loadSettingsDeps()
22
+ } catch {
23
+ /* 装载失败按缺失处理 */
24
+ }
19
25
  if (!deps) return false
20
26
  try {
21
27
  // schemastery 无 .optional(),对象字段默认即可选;.default 必给避免 undefined 覆盖
@@ -29,11 +35,11 @@ export async function installLspSettingsSection(ctx: Ctx, entry: LspConfig): Pro
29
35
  }).default({})
30
36
  }
31
37
  const schema = z.object(langShape).default({})
32
- deps.installSettingsSection(ctx, LSP_SETTINGS_NS, schema, entry, {
38
+ const strategy = await runSettingsInstall(ctx, LSP_SETTINGS_NS, schema, entry, {
33
39
  setSource: () => {},
34
40
  onChange: () => {},
35
41
  })
36
- return true
42
+ return strategy === 'legacy' || strategy === 'service'
37
43
  } catch (error) {
38
44
  // 设置 section 安装失败不致命:降级为无设置 UI,LSP 核心功能不受影响
39
45
  console.error('[dsh-vscode-mode] LSP 设置 section 安装失败:' + String(error))
package/src/paths.ts CHANGED
@@ -25,6 +25,8 @@ export const DSH_DIR_NAME = '.dsh'
25
25
  export const DSH_HOME_ENV = 'DSH_HOME'
26
26
  /** 树缓存 schema 版本(文件名携带;版本递增即触发旧版清理)。 */
27
27
  export const TREE_CACHE_SCHEMA = 1
28
+ /** LSP provider 发现缓存 schema 版本(格式变更即递增,旧文件自动失效)。 */
29
+ export const LSP_SPEC_CACHE_SCHEMA = 4
28
30
  /** 树缓存保留期:超期文件视为废弃(工作区搬迁/废弃残留)。 */
29
31
  export const TREE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
30
32
  /** 插件缓存目录总预算:超限按 mtime 从旧到新删文件,防任意增长撑爆磁盘(最后兜底)。 */
@@ -141,6 +143,11 @@ export function userCacheDir(home = dshHome()): string {
141
143
  return join(pluginCacheRoot(home), 'user')
142
144
  }
143
145
 
146
+ /** LSP provider 发现结果缓存文件(用户级:user/lsp-specs.v<schema>.json,跨会话/跨重启共享)。 */
147
+ export function lspSpecCacheFile(home = dshHome(), schema = LSP_SPEC_CACHE_SCHEMA): string {
148
+ return join(userCacheDir(home), 'lsp-specs.v' + schema + '.json')
149
+ }
150
+
144
151
  /** 插件日志根(~/.dsh/dsh-vscode-mode/logs,用户级)。 */
145
152
  export function pluginLogRoot(home = dshHome()): string {
146
153
  return join(home, PLUGIN_ID, 'logs')
package/src/rpc.ts CHANGED
@@ -284,7 +284,10 @@ export function buildHandlers(
284
284
  try {
285
285
  const target = await resolveTarget(ctx, sc.session, args.path)
286
286
  const info = await fs.stat(target)
287
- if (!info || info.type !== 'file') return { ok: false, error: '文件不存在' }
287
+ if (!info || info.type !== 'file') {
288
+ // 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
289
+ return { ok: false, error: '文件不存在', resolvedPath: fs.processPath(target) }
290
+ }
288
291
  if ((info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
289
292
  const content = await fs.readText(target)
290
293
  return { ok: true, content, size: content.length }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * dsh-vscode-mode 兼容层双面契约 — 与其他插件 / DSH 版本的探测与自诊断报告。
3
3
  * Purity rule: no node/react imports (same as types.ts / rpc.ts)。
4
- * 作者 ddj 2026-08-24
4
+ * 作者 ddj 2026-08-24 / 2026-09-02
5
5
  */
6
6
 
7
- /** 单项适配状态:外部插件/服务或护栏项,active 表示健康/生效。 */
7
+ /** 单项适配状态:外部插件/服务、护栏或版本适配项,active 表示健康/生效。 */
8
8
  export interface CompatAdapter {
9
9
  name: string
10
10
  active: boolean
@@ -17,11 +17,17 @@ export interface DevFormInfo {
17
17
  path?: string
18
18
  }
19
19
 
20
- /** 兼容性报告:host 侧构建(RPC edrv.compat 返回),client 侧补充本地适配项后展示。 */
20
+ /**
21
+ * 兼容性报告:host 侧构建(RPC edrv.compat 返回),client 侧补充本地适配项后展示。
22
+ * dshVersion = 运行中 DSH 核心版本(未探测到为空串);adapters = 版本适配机制状态行
23
+ * (旧客户端忽略未知字段,安全前向兼容)。
24
+ */
21
25
  export interface CompatReport {
22
26
  pluginVersion: string
23
27
  external: CompatAdapter[]
24
28
  guards: CompatAdapter[]
25
29
  warnings: string[]
26
30
  devForm?: DevFormInfo
31
+ dshVersion?: string
32
+ adapters?: CompatAdapter[]
27
33
  }