dsh-vscode-mode 0.15.6 → 0.15.8

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/lib/index.js CHANGED
@@ -2423,6 +2423,15 @@ function imageMimeOf(path) {
2423
2423
  /** PDF 统一 MIME(host base64 响应与 client PDF 面板同源,避免两处漂移)。 */
2424
2424
  const PDF_MIME = "application/pdf";
2425
2425
  /**
2426
+ * 会话级前置错误的规范文案:host requireSession 系与 client 错误识别共用同一常量,
2427
+ * 避免文案漂移导致 client 端分类失效。跨端场景(桌面端/Web 端各自运行独立 DSH 实例、
2428
+ * 会话列表互通但会话仅在一端激活)下,本端 host 取不到会话即返回「会话不存在」。
2429
+ * @author ddj 2026年10月16号
2430
+ */
2431
+ const SESSION_MISSING_ERROR = "会话不存在";
2432
+ /** 会话存在但无工作区 cwd 时的规范文案(与 SESSION_MISSING_ERROR 同源共用)。 */
2433
+ const SESSION_NO_WORKSPACE_ERROR = "会话无工作区";
2434
+ /**
2426
2435
  * 路径 → 二进制预览 MIME:PDF 特判 {@link PDF_MIME},其余走图片表回退 octet-stream。
2427
2436
  * @author ddj 2026年09月22号
2428
2437
  * @param path 文件路径(`/` 或 `\` 分隔均可)
@@ -11198,9 +11207,9 @@ function recView(record, ws = null) {
11198
11207
  /** 会话/工作区公共前置:返回 {session,cwd} 或错误文案。 */
11199
11208
  async function requireSession(ctx, sessionId) {
11200
11209
  const session = sessionOf(ctx, sessionId);
11201
- if (!session) return { err: "会话不存在" };
11210
+ if (!session) return { err: SESSION_MISSING_ERROR };
11202
11211
  const cwd = cwdOf(session);
11203
- if (!cwd) return { err: "会话无工作区" };
11212
+ if (!cwd) return { err: SESSION_NO_WORKSPACE_ERROR };
11204
11213
  return {
11205
11214
  session,
11206
11215
  cwd
@@ -13961,6 +13970,19 @@ function pathToFileUri(absPath) {
13961
13970
  return pathToFileURL(normalized).toString();
13962
13971
  }
13963
13972
  /**
13973
+ * file:// URI → 绝对路径(Windows file:///C:/x → C:\x)。
13974
+ * @author ddj 2026年08月27号
13975
+ * @param uri file:// URI
13976
+ * @returns 绝对路径
13977
+ */
13978
+ function fileUriToPath(fileUri) {
13979
+ try {
13980
+ return fileURLToPath(fileUri);
13981
+ } catch (error) {
13982
+ return String(error);
13983
+ }
13984
+ }
13985
+ /**
13964
13986
  * 是否绝对路径(**平台无关**判定)。
13965
13987
  *
13966
13988
  * ⚠️ 不能用 `node:path` 的 `isAbsolute`:它按**运行平台**解释路径——`isAbsolute('D:/ws/x')`
@@ -14069,6 +14091,130 @@ function deriveDefinitionFromLocations(queryPos, locations, docText, docAbsPath)
14069
14091
  const first = candidates[0];
14070
14092
  return sameRange(first.range, containing.range) ? containing : first;
14071
14093
  }
14094
+ /** 提取文本开头的标识符链(`EventId.ISTaskSystem = {` → ['EventId','ISTaskSystem'])。 */
14095
+ function chainOf(text) {
14096
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*)/.exec(text);
14097
+ if (!match) return [];
14098
+ return match[1].replace(/\s+/g, "").split(".");
14099
+ }
14100
+ /**
14101
+ * 判定切片文本是否为查询链的声明形候选:链后紧跟单个 '='(name = ... 赋值,排除 '==' 比较),
14102
+ * 且候选链是查询链的段级前缀(外层表声明 `EventId.ISTaskSystem = {`)或末段一致(叶字段声明
14103
+ * `TaskStateChange = 50000,`)。
14104
+ */
14105
+ function isDeclarationMatch(word, queryChain) {
14106
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*(=)(?!=)/.exec(word);
14107
+ if (!match) return false;
14108
+ const chain = match[1].replace(/\s+/g, "").split(".");
14109
+ if (!chain.length || chain.length > queryChain.length) return false;
14110
+ const prefix = queryChain.slice(0, chain.length);
14111
+ if (chain.every((seg, i) => seg === prefix[i])) return true;
14112
+ if (chain[chain.length - 1] === queryChain[queryChain.length - 1]) return true;
14113
+ return false;
14114
+ }
14115
+ /**
14116
+ * 跨文件声明推导(definition/declaration 均空、同文件推导只给出"点击处自身"时的兜底)。
14117
+ * 背景:EmmyLua 0.9.41 对成员链字段(EventId.ISTaskSystem.TaskStateChange)的 definition 恒返回空,
14118
+ * 但 references(includeDeclaration) 在词中/尾位置包含字段声明位置(声明文件内的 `XXX = {...}` /
14119
+ * `KEY = value,` 赋值行)。同文件推导把跨文件声明过滤掉 → 这里放宽到跨文件,
14120
+ * 用"段级相等/前缀/后缀 + 赋值形(单个 '=')"识别声明,并读取目标文件文本做词级校验。
14121
+ * 文件读取有上限(maxFiles),防止大工作区把跳转拖慢。
14122
+ * @author ddj 2026年10月15号
14123
+ * @param queryPos 查询位置(调用方已 nudge 到词尾)
14124
+ * @param locations references(includeDeclaration) 结果
14125
+ * @param docText 当前文档全文(取点击链)
14126
+ * @param docAbsPath 当前文档绝对路径(file:// URI)
14127
+ * @param lookup 文件文本读取器与读取上限
14128
+ * @returns 跨文件声明位置;未识别到返回 null
14129
+ */
14130
+ async function deriveCrossFileDeclaration(queryPos, locations, docText, docAbsPath, lookup) {
14131
+ if (!locations.length) return null;
14132
+ const docAbs = normAbs(docAbsPath);
14133
+ const containing = locations.filter((loc) => posInLspRange(queryPos, loc.range)).sort((a, b) => rangeSize(a.range) - rangeSize(b.range))[0];
14134
+ if (!containing) return null;
14135
+ const queryChain = chainOf(sliceRange(docText, containing.range));
14136
+ if (!queryChain.length) return null;
14137
+ const maxFiles = lookup.maxFiles ?? 8;
14138
+ const textByKey = /* @__PURE__ */ new Map();
14139
+ let best = null;
14140
+ let bestChainLen = Number.POSITIVE_INFINITY;
14141
+ const ordered = [...locations].sort((a, b) => {
14142
+ const ca = a.range.start.character - b.range.start.character;
14143
+ return ca !== 0 ? ca : a.range.start.line - b.range.start.line;
14144
+ });
14145
+ for (const loc of ordered) {
14146
+ const key = normAbs(loc.uri);
14147
+ if (key === docAbs) continue;
14148
+ let text = textByKey.get(key);
14149
+ if (text === void 0) {
14150
+ if (textByKey.size >= maxFiles) break;
14151
+ text = await lookup.readText(loc.uri);
14152
+ textByKey.set(key, text ?? null);
14153
+ }
14154
+ if (!text) continue;
14155
+ const word = sliceToLineEnd(text, loc.range);
14156
+ const chain = chainOf(word);
14157
+ if (chain.length && chain.length <= bestChainLen && isDeclarationMatch(word, queryChain)) {
14158
+ best = loc;
14159
+ bestChainLen = chain.length;
14160
+ if (bestChainLen === 1) break;
14161
+ }
14162
+ }
14163
+ return best;
14164
+ }
14165
+ /** 从 range.start 到所在行末的切片(声明形判定需覆盖标识符后的 `= value`)。 */
14166
+ function sliceToLineEnd(text, range) {
14167
+ return (text.split(/\r\n|\r|\n/)[Math.max(0, range.start.line)] ?? "").slice(Math.max(0, range.start.character));
14168
+ }
14169
+ /**
14170
+ * 判定"直接 definition 结果是否为同文件误解析(把使用点当定义)"。
14171
+ * 背景:EmmyLua 0.9.41 对跨文件全局(EventId = {} 定义在别的文件)的 textDocument/definition
14172
+ * 常错误返回当前文件最后一个使用点(如 SubHandler:94 的 `{ EventId.NewActivity...`),而非声明文件。
14173
+ * 特征:直接结果全部位于查询文档同文件,且首个结果的切片文本不是点击链的声明形
14174
+ * (local/function 声明或 `链 = ...` 赋值)。命中即应降级走跨文件声明推导。
14175
+ * @author ddj 2026年10月15号
14176
+ * @param docAbsPath 当前文档绝对路径(file:// URI 或绝对路径)
14177
+ * @param locations 直接 definition 结果
14178
+ * @param docText 当前文档全文(取点击链)
14179
+ * @param position 查询位置(调用方已 nudge 到词尾)
14180
+ */
14181
+ function isMisresolvedSameFileUsage(docAbsPath, locations, docText, position) {
14182
+ if (!locations.length) return false;
14183
+ const docAbs = normAbs(docAbsPath);
14184
+ if (locations.some((loc) => normAbs(loc.uri) !== docAbs)) return false;
14185
+ const chainRange = chainRangeAt(docText, position);
14186
+ if (!chainRange) return false;
14187
+ const queryChain = chainOf(sliceRange(docText, chainRange));
14188
+ if (!queryChain.length) return false;
14189
+ return !isDeclarationForm(sliceToLineEnd(docText, locations[0].range), queryChain);
14190
+ }
14191
+ /** 取 position 处的标识符/成员链 range(0-based,取所在行内的链)。 */
14192
+ function chainRangeAt(text, pos) {
14193
+ const line = text.split(/\r\n|\r|\n/)[pos.line] ?? "";
14194
+ const re = /[A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*/g;
14195
+ let m;
14196
+ while (m = re.exec(line)) {
14197
+ const start = m.index;
14198
+ const end = start + m[0].length;
14199
+ if (pos.character >= start && pos.character < end) return {
14200
+ start: {
14201
+ line: pos.line,
14202
+ character: start
14203
+ },
14204
+ end: {
14205
+ line: pos.line,
14206
+ character: end
14207
+ }
14208
+ };
14209
+ }
14210
+ return null;
14211
+ }
14212
+ /** 切片文本是否为查询链的"声明形"(local/function 声明或 `链 = ...` 赋值)。 */
14213
+ function isDeclarationForm(word, queryChain) {
14214
+ const t = word.replace(/^\s+/, "");
14215
+ if (/^(local\b|function\b)/.test(t)) return true;
14216
+ return isDeclarationMatch(word, queryChain);
14217
+ }
14072
14218
  //#endregion
14073
14219
  //#region src/lsp/server.ts
14074
14220
  /**
@@ -14090,6 +14236,72 @@ function lspDocVersion(incoming, previous) {
14090
14236
  return Number.isInteger(incoming) && incoming >= next && incoming <= MAX_DOC_VERSION ? incoming : next;
14091
14237
  }
14092
14238
  /**
14239
+ * 把查询位置挪到光标所在 Lua 标识符词的末尾字符。
14240
+ *
14241
+ * 背景:EmmyLua 0.9.41 存在词边界怪癖——查询位置落在标识符 token 前半时,definition/hover/references
14242
+ * 一律返回空 / "unknown" / [],落在 token 后半/末尾才正常解析(已对 EventId/ISTaskSystem/
14243
+ * TaskStateChange 逐字符实测确认)。把位置挪到词尾即可让基标识符(如 `EventId`)正常解析。
14244
+ * 仅当位置严格落在标识符词内时才挪;点号/空白/字符串/注释位置原样返回,不影响正常符号。
14245
+ * @author ddj 2026年10月15号
14246
+ * @param text 文档全文
14247
+ * @param pos 查询位置(0-based)
14248
+ * @returns 词末字符位置;不在词内则原样返回
14249
+ */
14250
+ function nudgeToWordEnd(text, pos) {
14251
+ const line = text.split(/\r\n|\r|\n/)[pos.line];
14252
+ if (line === void 0) return pos;
14253
+ const identifier = /[A-Za-z_][A-Za-z0-9_]*/g;
14254
+ let match;
14255
+ while ((match = identifier.exec(line)) !== null) {
14256
+ const start = match.index;
14257
+ const end = start + match[0].length;
14258
+ if (pos.character >= start && pos.character < end) return {
14259
+ line: pos.line,
14260
+ character: end - 1
14261
+ };
14262
+ if (pos.character < start) break;
14263
+ }
14264
+ return pos;
14265
+ }
14266
+ /** hover 内容是否"空/unknown"(EmmyLua 对未解析符号、成员链字段 hover 的原生输出)。 */
14267
+ function isEmptyHover(contents) {
14268
+ return contents.every((c) => {
14269
+ const t = c.trim();
14270
+ return !t || t === "unknown";
14271
+ });
14272
+ }
14273
+ /**
14274
+ * 取光标所在成员链的基标识符范围(`EventId.ISTaskSystem.TaskStateChange` → `EventId`)。
14275
+ * 链中无成员访问(无 '.')时返回 null——基标识符回查无意义。
14276
+ */
14277
+ function chainBaseRange(line, character) {
14278
+ const chain = /[A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*/g;
14279
+ let match;
14280
+ while ((match = chain.exec(line)) !== null) {
14281
+ const start = match.index;
14282
+ const end = start + match[0].length;
14283
+ if (character >= start && character < end) {
14284
+ const dot = match[0].indexOf(".");
14285
+ if (dot === -1) return null;
14286
+ return {
14287
+ start,
14288
+ end: start + dot
14289
+ };
14290
+ }
14291
+ if (character < start) break;
14292
+ }
14293
+ return null;
14294
+ }
14295
+ /** LSP position 是否落在 range 内(start 包含、end 排除)。 */
14296
+ function rangeContains(pos, range) {
14297
+ const start = range.start;
14298
+ const end = range.end ?? start;
14299
+ if (pos.line < start.line || pos.line > end.line) return false;
14300
+ if (pos.line === start.line && pos.character < start.character) return false;
14301
+ if (pos.line === end.line && pos.character >= end.character) return false;
14302
+ return true;
14303
+ }
14304
+ /**
14093
14305
  * 创建语言服务器会话。
14094
14306
  * @author ddj 2026年08月27号
14095
14307
  * @param spec provider 规格(argv/cwd)
@@ -14115,6 +14327,20 @@ function createLspServer(spec, root, languageId, logger) {
14115
14327
  const rel = toWorkspacePath(root, path);
14116
14328
  return isAbsolutePath(rel) ? pathToFileUri(rel) : pathToFileUri(root + "/" + rel.split("/").join("/"));
14117
14329
  };
14330
+ /**
14331
+ * 按 file:// URI 读取文件文本:已打开文档走内存(与语言服务器索引一致),其余读盘。
14332
+ * @author ddj 2026年10月15号
14333
+ * @param uri 目标文件 file:// URI
14334
+ * @returns 文件文本;读取失败(文件不存在/非 UTF-8 等)返回 null
14335
+ */
14336
+ const readTextByUri = async (uri) => {
14337
+ for (const doc of docs.values()) if (doc.uri === uri) return doc.text;
14338
+ try {
14339
+ return await readFile(fileUriToPath(uri), "utf8");
14340
+ } catch {
14341
+ return null;
14342
+ }
14343
+ };
14118
14344
  let phase = "idle";
14119
14345
  let transport = null;
14120
14346
  let client = null;
@@ -14312,15 +14538,37 @@ function createLspServer(spec, root, languageId, logger) {
14312
14538
  const doc = docs.get(path);
14313
14539
  if (!doc || !await readyWait) return [];
14314
14540
  const client = ensureClient();
14315
- const position = {
14541
+ const position = nudgeToWordEnd(doc.text, {
14316
14542
  line,
14317
14543
  character
14544
+ });
14545
+ const crossFromRefs = async () => {
14546
+ try {
14547
+ const refs = normalizeLocations(await client.request("textDocument/references", {
14548
+ textDocument: { uri: doc.uri },
14549
+ position,
14550
+ context: { includeDeclaration: true }
14551
+ }));
14552
+ return await deriveCrossFileDeclaration(position, refs, doc.text, doc.uri, { readText: readTextByUri });
14553
+ } catch (error) {
14554
+ log("references fallback failed: " + String(error));
14555
+ return null;
14556
+ }
14318
14557
  };
14319
14558
  const direct = normalizeLocations(await client.request("textDocument/definition", {
14320
14559
  textDocument: { uri: doc.uri },
14321
14560
  position
14322
14561
  }));
14323
- if (direct.length) return direct;
14562
+ if (direct.length) {
14563
+ if (isMisresolvedSameFileUsage(doc.uri, direct, doc.text, position)) {
14564
+ const cross = await crossFromRefs();
14565
+ if (cross) {
14566
+ log("definition direct was same-file misresolution → cross-file declaration (" + path + ":" + line + ":" + character + ")");
14567
+ return [cross];
14568
+ }
14569
+ }
14570
+ return direct;
14571
+ }
14324
14572
  if (capabilities.declaration) try {
14325
14573
  const decl = normalizeLocations(await client.request("textDocument/declaration", {
14326
14574
  textDocument: { uri: doc.uri },
@@ -14334,15 +14582,24 @@ function createLspServer(spec, root, languageId, logger) {
14334
14582
  log("declaration request failed: " + String(error));
14335
14583
  }
14336
14584
  try {
14337
- const derived = deriveDefinitionFromLocations(position, normalizeLocations(await client.request("textDocument/references", {
14585
+ const refs = normalizeLocations(await client.request("textDocument/references", {
14338
14586
  textDocument: { uri: doc.uri },
14339
14587
  position,
14340
14588
  context: { includeDeclaration: true }
14341
- })), doc.text, doc.uri);
14342
- if (derived) {
14589
+ }));
14590
+ const derived = deriveDefinitionFromLocations(position, refs, doc.text, doc.uri);
14591
+ const selfDerived = derived ? rangeContains(position, derived.range) : false;
14592
+ const misDerived = derived ? isMisresolvedSameFileUsage(doc.uri, [derived], doc.text, position) : false;
14593
+ if (derived && !selfDerived && !misDerived) {
14343
14594
  log("definition fallback → references-derived (" + path + ":" + line + ":" + character + ")");
14344
14595
  return [derived];
14345
14596
  }
14597
+ const cross = await deriveCrossFileDeclaration(position, refs, doc.text, doc.uri, { readText: readTextByUri });
14598
+ if (cross) {
14599
+ log("definition fallback → cross-file declaration (" + path + ":" + line + ":" + character + ")");
14600
+ return [cross];
14601
+ }
14602
+ if (derived) return [derived];
14346
14603
  } catch (error) {
14347
14604
  log("references fallback failed: " + String(error));
14348
14605
  }
@@ -14351,12 +14608,13 @@ function createLspServer(spec, root, languageId, logger) {
14351
14608
  async references(path, line, character, includeDeclaration) {
14352
14609
  const doc = docs.get(path);
14353
14610
  if (!doc || !await readyWait) return [];
14611
+ const position = nudgeToWordEnd(doc.text, {
14612
+ line,
14613
+ character
14614
+ });
14354
14615
  return normalizeLocations(await ensureClient().request("textDocument/references", {
14355
14616
  textDocument: { uri: doc.uri },
14356
- position: {
14357
- line,
14358
- character
14359
- },
14617
+ position,
14360
14618
  context: { includeDeclaration }
14361
14619
  }));
14362
14620
  },
@@ -14376,15 +14634,31 @@ function createLspServer(spec, root, languageId, logger) {
14376
14634
  async hover(path, line, character) {
14377
14635
  const doc = docs.get(path);
14378
14636
  if (!doc || !await readyWait) return null;
14379
- const result = await ensureClient().request("textDocument/hover", {
14637
+ const client = ensureClient();
14638
+ const position = nudgeToWordEnd(doc.text, {
14639
+ line,
14640
+ character
14641
+ });
14642
+ const result = await client.request("textDocument/hover", {
14380
14643
  textDocument: { uri: doc.uri },
14381
- position: {
14382
- line,
14383
- character
14384
- }
14644
+ position
14385
14645
  });
14386
- if (!result || !result.contents) return null;
14387
- return { contents: stringifyHoverContents(result.contents) };
14646
+ const contents = result?.contents ? stringifyHoverContents(result.contents) : [];
14647
+ if (isEmptyHover(contents)) {
14648
+ const base = chainBaseRange(doc.text.split(/\r\n|\r|\n/)[position.line] ?? "", position.character);
14649
+ if (base) {
14650
+ const baseResult = await client.request("textDocument/hover", {
14651
+ textDocument: { uri: doc.uri },
14652
+ position: {
14653
+ line: position.line,
14654
+ character: base.end - 1
14655
+ }
14656
+ });
14657
+ const baseContents = baseResult?.contents ? stringifyHoverContents(baseResult.contents) : [];
14658
+ if (!isEmptyHover(baseContents)) return { contents: baseContents };
14659
+ }
14660
+ }
14661
+ return contents.length ? { contents } : null;
14388
14662
  },
14389
14663
  /**
14390
14664
  * 查询补全列表(textDocument/completion)。
@@ -16441,8 +16715,8 @@ function createLspRpc(deps) {
16441
16715
  /** 解析会话与工作区根(失败 → null + error)。 */
16442
16716
  const rootOf = async (sessionId) => {
16443
16717
  const session = sessionOf(ctx, sessionId);
16444
- if (!session) return { err: "会话不存在" };
16445
- if (!cwdOf(session)) return { err: "会话无工作区" };
16718
+ if (!session) return { err: SESSION_MISSING_ERROR };
16719
+ if (!cwdOf(session)) return { err: SESSION_NO_WORKSPACE_ERROR };
16446
16720
  try {
16447
16721
  return {
16448
16722
  root: await searchRoot(ctx, session),
@@ -19695,9 +19969,9 @@ function foregroundHelperArgv(processName) {
19695
19969
  /** host 会话/工作区前置(对齐 rpc.ts requireSession 的错误文案)。 */
19696
19970
  async function requireSvnSession(ctx, sessionId) {
19697
19971
  const session = sessionOf(ctx, sessionId);
19698
- if (!session) return { err: "会话不存在" };
19972
+ if (!session) return { err: SESSION_MISSING_ERROR };
19699
19973
  const cwd = cwdOf(session);
19700
- if (!cwd) return { err: "会话无工作区" };
19974
+ if (!cwd) return { err: SESSION_NO_WORKSPACE_ERROR };
19701
19975
  return { cwd };
19702
19976
  }
19703
19977
  /** 工作区相对路径归一('' 合法 = 工作区根;非法含越界/绝对路径返回 null)。 */