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/README.md +4 -3
- package/lib/client.js +66 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +281 -16
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/monaco/lsp/providers.ts +68 -1
- package/src/client/ui/EditorView.ts +3 -1
- package/src/lsp/derive.ts +157 -0
- package/src/lsp/server.ts +159 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.15.
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
505
|
+
{ textDocument: { uri: doc.uri }, position },
|
|
371
506
|
)
|
|
372
|
-
|
|
373
|
-
|
|
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
|
/**
|