dsh-vscode-mode 0.15.6 → 0.15.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.15.6",
3
+ "version": "0.15.7",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -3,7 +3,7 @@
3
3
  * dsh-vscode-mode client — Monaco LSP provider 注册 + 跳转/引用命令。
4
4
  * 数据层:Definition/Reference/DocumentSymbol/Hover provider(同一文件内跳转 Monaco 原生可用)。
5
5
  * 交互层:edrv.goToDefinition(F12)/ edrv.findReferences(Shift+F12 / 右键)均走 Monaco 原生命令;
6
- * Ctrl+点击同样交给原生(多结果弹 Peek 让用户选、无定义时按 alternativeDefinitionCommand 降级),
6
+ * Ctrl+点击使用处交给原生;Lua 表枚举字段声明处改为打开引用 Peek,
7
7
  * 数据经 host 的 LSP RPC 提供(registerEditorOpener 接管 edrv:// 点击跳转)。
8
8
  * 作者 ddj 2026-08-27 / 2026-09-11
9
9
  */
@@ -324,6 +324,73 @@ function peekSupported(ed) {
324
324
  }
325
325
  }
326
326
 
327
+ /**
328
+ * 判定 Ctrl+点击是否落在 Lua 表的字段声明键上(`TaskStateChange = 50000`)。
329
+ * 仅拦截表字面量内的键,不改动使用处的原生跳转行为。
330
+ * @author ddj 2026年10月16号
331
+ * @param model 当前 Monaco 文档
332
+ * @param position 鼠标命中位置
333
+ * @returns 是否为表字段声明键
334
+ */
335
+ export function isLuaTableKey(model, position) {
336
+ if (model?.getLanguageId?.() !== 'lua' || !position) return false
337
+ const line = model.getLineContent(position.lineNumber)
338
+ const key = model.getWordAtPosition(position)
339
+ if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key.word)) return false
340
+ if (!/^\s*=\s*(?!=)/.test(line.slice(key.endColumn - 1))) return false
341
+ if (!/^\s*$/.test(line.slice(0, key.startColumn - 1))) return false
342
+
343
+ // 仅识别 `Name = {` 后连续的键值行;遇到其它语句或闭合括号即停止回溯。
344
+ for (let lineNumber = position.lineNumber - 1; lineNumber >= Math.max(1, position.lineNumber - 100); lineNumber--) {
345
+ const previous = model.getLineContent(lineNumber).trim()
346
+ if (!previous || previous.startsWith('--')) continue
347
+ if (/^(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)*[A-Za-z_][A-Za-z0-9_]*\s*=\s*\{\s*(?:--.*)?$/.test(previous)) return true
348
+ if (!/^[A-Za-z_][A-Za-z0-9_]*\s*=\s*[^{}]*,?\s*(?:--.*)?$/.test(previous)) return false
349
+ }
350
+ return false
351
+ }
352
+
353
+ /**
354
+ * 仅在 Lua 表字段声明处把 Ctrl+点击交给引用 Peek;其他点击留给 Monaco 原生导航。
355
+ * 使用 DOM capture 阶段阻止原生 gotoDefinitionAtPosition 同时触发,避免跳转和 Peek 竞态。
356
+ * @author ddj 2026年10月16号
357
+ * @param ed Monaco 编辑器
358
+ * @param monaco Monaco API
359
+ * @returns 解绑回调
360
+ */
361
+ export function bindLuaKeyRefs(ed, monaco) {
362
+ const node = ed?.getDomNode?.()
363
+ if (!node) return () => {}
364
+ const keyAt = (event) => {
365
+ if (event.button !== 0 || (!event.ctrlKey && !event.metaKey) || event.altKey || event.shiftKey) return null
366
+ const target = ed.getTargetAtClientPoint?.(event.clientX, event.clientY)
367
+ const content = monaco.editor?.MouseTargetType?.CONTENT_TEXT ?? 6
368
+ return target?.type === content && isLuaTableKey(ed.getModel(), target.position) ? target.position : null
369
+ }
370
+ let suppressUp = false
371
+ const onDown = (event) => {
372
+ const position = keyAt(event)
373
+ if (!position) return
374
+ suppressUp = true
375
+ event.preventDefault()
376
+ event.stopPropagation()
377
+ ed.setPosition(position)
378
+ void triggerReferencePeek(ed)
379
+ }
380
+ const onUp = (event) => {
381
+ if (!suppressUp) return
382
+ suppressUp = false
383
+ event.preventDefault()
384
+ event.stopPropagation()
385
+ }
386
+ node.addEventListener('mousedown', onDown, true)
387
+ node.addEventListener('mouseup', onUp, true)
388
+ return () => {
389
+ node.removeEventListener('mousedown', onDown, true)
390
+ node.removeEventListener('mouseup', onUp, true)
391
+ }
392
+ }
393
+
327
394
  /** Shift+F12 / 右键「查找所有引用」:走 Monaco 原生 References Peek(两栏引用视图)。 */
328
395
  export async function runFindReferences(ed) {
329
396
  const position = ed.getPosition()
@@ -75,7 +75,7 @@ import { navStateLoad, navStateSave } from '../state/navStateCache.js'
75
75
  import { statusOfAdd } from '../addToConversation.js'
76
76
  import { setSearchScope, setSearchSeed } from '../searchSeed.js'
77
77
  import { CACHE_KEY } from '../paths.js'
78
- import { runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
78
+ import { runGoToDefinition, runFindReferences, hideReferencesOverlay, bindLuaKeyRefs } from '../monaco/lsp/providers.js'
79
79
  import { isLuaTypeSlot } from '../monaco/lsp/keywords.js'
80
80
  import { bindLspUnderline } from '../monaco/lsp/underline.js'
81
81
  import { onLspProgress, refreshStatus, setSession as setLspSession } from '../monaco/lsp/index.js'
@@ -2393,6 +2393,8 @@ export function EditorView(props) {
2393
2393
  // SVN 组按创建时的最新状态注册(此后由 [monaco, svnStatus] effect 随状态增减)
2394
2394
  syncSvnMenuActs(ed)
2395
2395
  bindLspUnderline(ed, m)
2396
+ const unbindLuaKeyRefs = bindLuaKeyRefs(ed, m)
2397
+ ed.onDidDispose(unbindLuaKeyRefs)
2396
2398
  // AI 补全:编辑器实例登记(差异静默判定用)+ Alt+\ 手动触发 ghost text
2397
2399
  trackAiEditor(ed)
2398
2400
  ed.addCommand(m.KeyMod.Alt | m.KeyCode.Backslash, () => {
package/src/lsp/derive.ts CHANGED
@@ -94,3 +94,160 @@ export function deriveDefinitionFromLocations(
94
94
  const first = candidates[0]
95
95
  return sameRange(first.range, containing.range) ? containing : first
96
96
  }
97
+
98
+ /** 文本读取回调(服务器提供:已打开文档走内存,其余读盘)。 */
99
+ export interface CrossFileLookup {
100
+ readText: (uri: string) => Promise<string | null>
101
+ maxFiles?: number
102
+ }
103
+
104
+ /** 提取文本开头的标识符链(`EventId.ISTaskSystem = {` → ['EventId','ISTaskSystem'])。 */
105
+ function chainOf(text: string): string[] {
106
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*)/.exec(text)
107
+ if (!match) return []
108
+ return match[1].replace(/\s+/g, '').split('.')
109
+ }
110
+
111
+ /**
112
+ * 判定切片文本是否为查询链的声明形候选:链后紧跟单个 '='(name = ... 赋值,排除 '==' 比较),
113
+ * 且候选链是查询链的段级前缀(外层表声明 `EventId.ISTaskSystem = {`)或末段一致(叶字段声明
114
+ * `TaskStateChange = 50000,`)。
115
+ */
116
+ function isDeclarationMatch(word: string, queryChain: string[]): boolean {
117
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*(=)(?!=)/.exec(word)
118
+ if (!match) return false
119
+ const chain = match[1].replace(/\s+/g, '').split('.')
120
+ if (!chain.length || chain.length > queryChain.length) return false
121
+ // 候选是查询链的外层声明(EventId.ISTaskSystem = {)
122
+ const prefix = queryChain.slice(0, chain.length)
123
+ if (chain.every((seg, i) => seg === prefix[i])) return true
124
+ // 候选末段与查询链末段一致(TaskStateChange = 50000 是链叶字段声明)
125
+ if (chain[chain.length - 1] === queryChain[queryChain.length - 1]) return true
126
+ return false
127
+ }
128
+
129
+ /**
130
+ * 跨文件声明推导(definition/declaration 均空、同文件推导只给出"点击处自身"时的兜底)。
131
+ * 背景:EmmyLua 0.9.41 对成员链字段(EventId.ISTaskSystem.TaskStateChange)的 definition 恒返回空,
132
+ * 但 references(includeDeclaration) 在词中/尾位置包含字段声明位置(声明文件内的 `XXX = {...}` /
133
+ * `KEY = value,` 赋值行)。同文件推导把跨文件声明过滤掉 → 这里放宽到跨文件,
134
+ * 用"段级相等/前缀/后缀 + 赋值形(单个 '=')"识别声明,并读取目标文件文本做词级校验。
135
+ * 文件读取有上限(maxFiles),防止大工作区把跳转拖慢。
136
+ * @author ddj 2026年10月15号
137
+ * @param queryPos 查询位置(调用方已 nudge 到词尾)
138
+ * @param locations references(includeDeclaration) 结果
139
+ * @param docText 当前文档全文(取点击链)
140
+ * @param docAbsPath 当前文档绝对路径(file:// URI)
141
+ * @param lookup 文件文本读取器与读取上限
142
+ * @returns 跨文件声明位置;未识别到返回 null
143
+ */
144
+ export async function deriveCrossFileDeclaration(
145
+ queryPos: LspPosition,
146
+ locations: LspLocation[],
147
+ docText: string,
148
+ docAbsPath: string,
149
+ lookup: CrossFileLookup,
150
+ ): Promise<LspLocation | null> {
151
+ if (!locations.length) return null
152
+ const docAbs = normAbs(docAbsPath)
153
+
154
+ // 点击位置落在的条目(多个时取最小包裹),取其词文本的标识符链作为查询链
155
+ const containing = locations
156
+ .filter((loc) => posInLspRange(queryPos, loc.range))
157
+ .sort((a, b) => rangeSize(a.range) - rangeSize(b.range))[0]
158
+ if (!containing) return null
159
+ const queryChain = chainOf(sliceRange(docText, containing.range))
160
+ if (!queryChain.length) return null
161
+
162
+ // 声明候选集中在低列(EventId = {} 在 col0、表字段 `TaskStateChange = 50000,` 在 col4),
163
+ // 使用点通常在 col10+。按列升序优先读取"声明形"文件,避免 3884 条引用按原序只读到使用文件
164
+ // 就耗尽 maxFiles。每个文件只读一次,但文件内每个位置都要判定。
165
+ const maxFiles = lookup.maxFiles ?? 8
166
+ const textByKey = new Map<string, string | null>()
167
+ let best: LspLocation | null = null
168
+ let bestChainLen = Number.POSITIVE_INFINITY
169
+ const ordered = [...locations].sort((a, b) => {
170
+ const ca = a.range.start.character - b.range.start.character
171
+ return ca !== 0 ? ca : a.range.start.line - b.range.start.line
172
+ })
173
+ for (const loc of ordered) {
174
+ const key = normAbs(loc.uri)
175
+ if (key === docAbs) continue
176
+ let text = textByKey.get(key)
177
+ if (text === undefined) {
178
+ if (textByKey.size >= maxFiles) break
179
+ text = await lookup.readText(loc.uri)
180
+ textByKey.set(key, text ?? null)
181
+ }
182
+ if (!text) continue
183
+ // 声明检测需看到标识符后的 `= value`,而引用条目 range 常只覆盖标识符本身
184
+ // (如 EventId = {} 的 range 是 0:0-0:7,'=' 在 8)。取从 range.start 到行末的文本。
185
+ const word = sliceToLineEnd(text, loc.range)
186
+ const chain = chainOf(word)
187
+ if (chain.length && chain.length <= bestChainLen && isDeclarationMatch(word, queryChain)) {
188
+ best = loc
189
+ bestChainLen = chain.length
190
+ if (bestChainLen === 1) break
191
+ }
192
+ }
193
+ return best
194
+ }
195
+
196
+ /** 从 range.start 到所在行末的切片(声明形判定需覆盖标识符后的 `= value`)。 */
197
+ function sliceToLineEnd(text: string, range: LspRange): string {
198
+ const lines = text.split(/\r\n|\r|\n/)
199
+ const line = lines[Math.max(0, range.start.line)] ?? ''
200
+ return line.slice(Math.max(0, range.start.character))
201
+ }
202
+
203
+ /**
204
+ * 判定"直接 definition 结果是否为同文件误解析(把使用点当定义)"。
205
+ * 背景:EmmyLua 0.9.41 对跨文件全局(EventId = {} 定义在别的文件)的 textDocument/definition
206
+ * 常错误返回当前文件最后一个使用点(如 SubHandler:94 的 `{ EventId.NewActivity...`),而非声明文件。
207
+ * 特征:直接结果全部位于查询文档同文件,且首个结果的切片文本不是点击链的声明形
208
+ * (local/function 声明或 `链 = ...` 赋值)。命中即应降级走跨文件声明推导。
209
+ * @author ddj 2026年10月15号
210
+ * @param docAbsPath 当前文档绝对路径(file:// URI 或绝对路径)
211
+ * @param locations 直接 definition 结果
212
+ * @param docText 当前文档全文(取点击链)
213
+ * @param position 查询位置(调用方已 nudge 到词尾)
214
+ */
215
+ export function isMisresolvedSameFileUsage(
216
+ docAbsPath: string,
217
+ locations: LspLocation[],
218
+ docText: string,
219
+ position: LspPosition,
220
+ ): boolean {
221
+ if (!locations.length) return false
222
+ const docAbs = normAbs(docAbsPath)
223
+ // 直接结果含跨文件条目 → 正常定义,不疑误
224
+ if (locations.some((loc) => normAbs(loc.uri) !== docAbs)) return false
225
+ const chainRange = chainRangeAt(docText, position)
226
+ if (!chainRange) return false
227
+ const queryChain = chainOf(sliceRange(docText, chainRange))
228
+ if (!queryChain.length) return false
229
+ // 取行末切片,覆盖 local/function 前缀与 `= value`(range 常只覆盖标识符本身)
230
+ return !isDeclarationForm(sliceToLineEnd(docText, locations[0].range), queryChain)
231
+ }
232
+
233
+ /** 取 position 处的标识符/成员链 range(0-based,取所在行内的链)。 */
234
+ function chainRangeAt(text: string, pos: LspPosition): LspRange | null {
235
+ const line = text.split(/\r\n|\r|\n/)[pos.line] ?? ''
236
+ const re = /[A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*/g
237
+ let m: RegExpExecArray | null
238
+ while ((m = re.exec(line))) {
239
+ const start = m.index
240
+ const end = start + m[0].length
241
+ if (pos.character >= start && pos.character < end) {
242
+ return { start: { line: pos.line, character: start }, end: { line: pos.line, character: end } }
243
+ }
244
+ }
245
+ return null
246
+ }
247
+
248
+ /** 切片文本是否为查询链的"声明形"(local/function 声明或 `链 = ...` 赋值)。 */
249
+ function isDeclarationForm(word: string, queryChain: string[]): boolean {
250
+ const t = word.replace(/^\s+/, '')
251
+ if (/^(local\b|function\b)/.test(t)) return true
252
+ return isDeclarationMatch(word, queryChain)
253
+ }
package/src/lsp/server.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import { spawnServer, type Transport } from './transport.js'
8
8
  import { createLspClient, type LspClient } from './client.js'
9
- import { pathToFileUri, toWorkspacePath, isAbsolutePath } from './uri.js'
10
- import { deriveDefinitionFromLocations } from './derive.js'
9
+ import { pathToFileUri, toWorkspacePath, isAbsolutePath, fileUriToPath } from './uri.js'
10
+ import { deriveDefinitionFromLocations, deriveCrossFileDeclaration, isMisresolvedSameFileUsage } from './derive.js'
11
+ import { readFile } from 'node:fs/promises'
11
12
  import type { LspProviderSpec } from './providers.js'
12
13
  import { LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES } from '../shared/lsp.js'
13
14
  import type {
@@ -16,6 +17,7 @@ import type {
16
17
  LspCompletionTextEdit,
17
18
  LspLocation,
18
19
  LspParameterInformation,
20
+ LspPosition,
19
21
  LspRange,
20
22
  LspSemanticTokens,
21
23
  LspServerCapabilities,
@@ -71,6 +73,73 @@ export function lspDocVersion(incoming: number, previous?: number): number {
71
73
  return Number.isInteger(incoming) && incoming >= next && incoming <= MAX_DOC_VERSION ? incoming : next
72
74
  }
73
75
 
76
+ /**
77
+ * 把查询位置挪到光标所在 Lua 标识符词的末尾字符。
78
+ *
79
+ * 背景:EmmyLua 0.9.41 存在词边界怪癖——查询位置落在标识符 token 前半时,definition/hover/references
80
+ * 一律返回空 / "unknown" / [],落在 token 后半/末尾才正常解析(已对 EventId/ISTaskSystem/
81
+ * TaskStateChange 逐字符实测确认)。把位置挪到词尾即可让基标识符(如 `EventId`)正常解析。
82
+ * 仅当位置严格落在标识符词内时才挪;点号/空白/字符串/注释位置原样返回,不影响正常符号。
83
+ * @author ddj 2026年10月15号
84
+ * @param text 文档全文
85
+ * @param pos 查询位置(0-based)
86
+ * @returns 词末字符位置;不在词内则原样返回
87
+ */
88
+ export function nudgeToWordEnd(text: string, pos: LspPosition): LspPosition {
89
+ const lines = text.split(/\r\n|\r|\n/)
90
+ const line = lines[pos.line]
91
+ if (line === undefined) return pos
92
+ const identifier = /[A-Za-z_][A-Za-z0-9_]*/g
93
+ let match: RegExpExecArray | null
94
+ while ((match = identifier.exec(line)) !== null) {
95
+ const start = match.index
96
+ const end = start + match[0].length
97
+ if (pos.character >= start && pos.character < end) {
98
+ return { line: pos.line, character: end - 1 }
99
+ }
100
+ if (pos.character < start) break
101
+ }
102
+ return pos
103
+ }
104
+
105
+ /** hover 内容是否"空/unknown"(EmmyLua 对未解析符号、成员链字段 hover 的原生输出)。 */
106
+ function isEmptyHover(contents: string[]): boolean {
107
+ return contents.every((c) => {
108
+ const t = c.trim()
109
+ return !t || t === 'unknown'
110
+ })
111
+ }
112
+
113
+ /**
114
+ * 取光标所在成员链的基标识符范围(`EventId.ISTaskSystem.TaskStateChange` → `EventId`)。
115
+ * 链中无成员访问(无 '.')时返回 null——基标识符回查无意义。
116
+ */
117
+ function chainBaseRange(line: string, character: number): { start: number; end: number } | null {
118
+ const chain = /[A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*/g
119
+ let match: RegExpExecArray | null
120
+ while ((match = chain.exec(line)) !== null) {
121
+ const start = match.index
122
+ const end = start + match[0].length
123
+ if (character >= start && character < end) {
124
+ const dot = match[0].indexOf('.')
125
+ if (dot === -1) return null
126
+ return { start, end: start + dot }
127
+ }
128
+ if (character < start) break
129
+ }
130
+ return null
131
+ }
132
+
133
+ /** LSP position 是否落在 range 内(start 包含、end 排除)。 */
134
+ function rangeContains(pos: LspPosition, range: LspRange): boolean {
135
+ const start = range.start
136
+ const end = range.end ?? start
137
+ if (pos.line < start.line || pos.line > end.line) return false
138
+ if (pos.line === start.line && pos.character < start.character) return false
139
+ if (pos.line === end.line && pos.character >= end.character) return false
140
+ return true
141
+ }
142
+
74
143
  /**
75
144
  * 创建语言服务器会话。
76
145
  * @author ddj 2026年08月27号
@@ -98,6 +167,23 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
98
167
  const rel = toWorkspacePath(root, path)
99
168
  return isAbsolutePath(rel) ? pathToFileUri(rel) : pathToFileUri(root + '/' + rel.split('/').join('/'))
100
169
  }
170
+
171
+ /**
172
+ * 按 file:// URI 读取文件文本:已打开文档走内存(与语言服务器索引一致),其余读盘。
173
+ * @author ddj 2026年10月15号
174
+ * @param uri 目标文件 file:// URI
175
+ * @returns 文件文本;读取失败(文件不存在/非 UTF-8 等)返回 null
176
+ */
177
+ const readTextByUri = async (uri: string): Promise<string | null> => {
178
+ for (const doc of docs.values()) {
179
+ if (doc.uri === uri) return doc.text
180
+ }
181
+ try {
182
+ return await readFile(fileUriToPath(uri), 'utf8')
183
+ } catch {
184
+ return null
185
+ }
186
+ }
101
187
  let phase: LspServerPhase = 'idle'
102
188
  let transport: Transport | null = null
103
189
  let client: LspClient | null = null
@@ -283,7 +369,27 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
283
369
  const doc = docs.get(path)
284
370
  if (!doc || !(await readyWait)) return []
285
371
  const client = ensureClient()
286
- const position = { line, character }
372
+ // EmmyLua 词边界怪癖:位置在标识符前半返回空,nudge 到词尾后基标识符可正常解析
373
+ const position = nudgeToWordEnd(doc.text, { line, character })
374
+ // 跨文件声明推导(references + 词级校验),两处复用
375
+ const crossFromRefs = async (): Promise<LspLocation | null> => {
376
+ try {
377
+ const refs = normalizeLocations(
378
+ await client.request<unknown>(
379
+ 'textDocument/references',
380
+ {
381
+ textDocument: { uri: doc.uri },
382
+ position,
383
+ context: { includeDeclaration: true },
384
+ },
385
+ ),
386
+ )
387
+ return await deriveCrossFileDeclaration(position, refs, doc.text, doc.uri, { readText: readTextByUri })
388
+ } catch (error) {
389
+ log('references fallback failed: ' + String(error))
390
+ return null
391
+ }
392
+ }
287
393
  // ① 标准定义(方法/跨文件命名空间等)
288
394
  const direct = normalizeLocations(
289
395
  await client.request<unknown>(
@@ -291,7 +397,19 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
291
397
  { textDocument: { uri: doc.uri }, position },
292
398
  ),
293
399
  )
294
- if (direct.length) return direct
400
+ if (direct.length) {
401
+ // EmmyLua 0.9.41 对跨文件全局(EventId = {} 定义在别的文件)的 definition 常错误返回
402
+ // 当前文件最后一个使用点(同文件非声明形)。识别到误解析时降级走跨文件声明推导。
403
+ const mis = isMisresolvedSameFileUsage(doc.uri, direct, doc.text, position)
404
+ if (mis) {
405
+ const cross = await crossFromRefs()
406
+ if (cross) {
407
+ log('definition direct was same-file misresolution → cross-file declaration (' + path + ':' + line + ':' + character + ')')
408
+ return [cross]
409
+ }
410
+ }
411
+ return direct
412
+ }
295
413
  // ② 声明(服务器能力支持时):EmmyLua 对局部变量/参数/表字段 definition 常返回空,declaration 更准
296
414
  if (capabilities.declaration) {
297
415
  try {
@@ -323,10 +441,23 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
323
441
  ),
324
442
  )
325
443
  const derived = deriveDefinitionFromLocations(position, refs, doc.text, doc.uri)
326
- if (derived) {
444
+ // derived 为"点击处自身"(字段定义缺失空转)或"同文件非声明形使用点"(把 EventId 用
445
+ // 点当定义)时都不直接信任 → 先找跨文件声明;二者皆非才直接用同文件推导结果。
446
+ const selfDerived = derived ? rangeContains(position, derived.range) : false
447
+ const misDerived = derived ? isMisresolvedSameFileUsage(doc.uri, [derived], doc.text, position) : false
448
+ if (derived && !selfDerived && !misDerived) {
327
449
  log('definition fallback → references-derived (' + path + ':' + line + ':' + character + ')')
328
450
  return [derived]
329
451
  }
452
+ const cross = await deriveCrossFileDeclaration(position, refs, doc.text, doc.uri, { readText: readTextByUri })
453
+ if (cross) {
454
+ log('definition fallback → cross-file declaration (' + path + ':' + line + ':' + character + ')')
455
+ return [cross]
456
+ }
457
+ if (derived) {
458
+ // 点中的正是声明自身,或引用里没有可识别的跨文件声明 → 返回自身/同文件结果(无更优目标)
459
+ return [derived]
460
+ }
330
461
  } catch (error) {
331
462
  log('references fallback failed: ' + String(error))
332
463
  }
@@ -336,11 +467,12 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
336
467
  async references(path: string, line: number, character: number, includeDeclaration: boolean): Promise<LspLocation[]> {
337
468
  const doc = docs.get(path)
338
469
  if (!doc || !(await readyWait)) return []
470
+ const position = nudgeToWordEnd(doc.text, { line, character })
339
471
  const result = await ensureClient().request<unknown>(
340
472
  'textDocument/references',
341
473
  {
342
474
  textDocument: { uri: doc.uri },
343
- position: { line, character },
475
+ position,
344
476
  context: { includeDeclaration },
345
477
  },
346
478
  )
@@ -365,12 +497,29 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
365
497
  async hover(path: string, line: number, character: number): Promise<{ contents: string[] } | null> {
366
498
  const doc = docs.get(path)
367
499
  if (!doc || !(await readyWait)) return null
368
- const result = await ensureClient().request<{ contents?: unknown; range?: unknown } | null>(
500
+ const client = ensureClient()
501
+ // EmmyLua 词边界怪癖:位置在标识符前半 hover 返回 "unknown",nudge 到词尾可正常解析
502
+ const position = nudgeToWordEnd(doc.text, { line, character })
503
+ const result = await client.request<{ contents?: unknown; range?: unknown } | null>(
369
504
  'textDocument/hover',
370
- { textDocument: { uri: doc.uri }, position: { line, character } },
505
+ { textDocument: { uri: doc.uri }, position },
371
506
  )
372
- if (!result || !result.contents) return null
373
- return { contents: stringifyHoverContents(result.contents) }
507
+ const contents = result?.contents ? stringifyHoverContents(result.contents) : []
508
+ // EmmyLua 对成员链字段(EventId.ISTaskSystem.TaskStateChange)hover 恒 "unknown":
509
+ // 退回查询基标识符(EventId),显示其类型而非无信息文本
510
+ if (isEmptyHover(contents)) {
511
+ const lines = doc.text.split(/\r\n|\r|\n/)
512
+ const base = chainBaseRange(lines[position.line] ?? '', position.character)
513
+ if (base) {
514
+ const baseResult = await client.request<{ contents?: unknown } | null>(
515
+ 'textDocument/hover',
516
+ { textDocument: { uri: doc.uri }, position: { line: position.line, character: base.end - 1 } },
517
+ )
518
+ const baseContents = baseResult?.contents ? stringifyHoverContents(baseResult.contents) : []
519
+ if (!isEmptyHover(baseContents)) return { contents: baseContents }
520
+ }
521
+ }
522
+ return contents.length ? { contents } : null
374
523
  },
375
524
 
376
525
  /**