From 402c66ccb0e0cd6b26aaf3258037591802ff434d Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 29 Oct 2026 10:42:27 -0700 Subject: [PATCH 1/2] fix(lsp): preserve completion refresh context across editor hosts --- ...guage-tooling-and-lsp-runtime-ownership.md | 16 ++++ frontend/editor/src/workbench.ts | 7 +- macos/Experiments/Monaco/main.swift | 5 ++ .../Monaco/workbench.integration.ts | 30 +++++++ macos/Resources/en.lproj/Localizable.strings | 4 + .../zh-Hans.lproj/Localizable.strings | 4 + .../Lithe/Core/Rust/RustCoreBridge.swift | 5 ++ .../RustLanguageServerRuntimeAdapter.swift | 16 +++- .../AppModel/AppModel+LanguageEditing.swift | 30 ++++--- .../MacOS/MonacoWorkbenchEditor.swift | 7 +- .../LanguageServerRuntimeContracts.swift | 1 + .../Language/LanguageToolingContracts.swift | 33 ++++++++ .../Providers/LanguageFeatureProvider.swift | 26 +++++- .../Runtime/LanguageServerSession.swift | 18 ++++- .../LanguageToolingSessionManager.swift | 30 +++++-- .../LanguageFeatureProviderTests.swift | 26 +++++- .../MacBuildProviderSessionsTests.swift | 2 +- .../MacPluginBackendSessionsTests.swift | 2 +- .../RunConfigurationIntegrationTests.swift | 15 ++-- rust/lithe-core/src/lsp/interface/client.rs | 39 ++++++++- rust/lithe-core/src/lsp/interface/engine.rs | 26 ++++++ .../lsp/interface/engine_real_jdt_tests.rs | 4 + rust/lithe-core/src/lsp/interface/types.rs | 3 + rust/lithe-core/src/lsp/tests.rs | 81 +++++++++++++++++++ shared/contracts/rust-core-api.md | 12 +++ .../fixtures/lsp/completion-refresh-v1.json | 48 +++++++++++ shared/i18n/locales/en-US.json | 4 +- shared/i18n/locales/zh-CN.json | 4 +- shared/i18n/messages.json | 24 ++++++ .../features/lsp-completion-hover.json | 8 +- .../editor/engines/monaco/lsp-providers.ts | 10 ++- .../src/features/editor/lsp/lsp-client.ts | 14 ++-- windows/tauri/src/i18n/shared.generated.ts | 4 + .../src/platform/lsp-core-adapter.test.ts | 34 ++++++++ .../tauri/src/platform/lsp-core-adapter.ts | 8 +- 35 files changed, 547 insertions(+), 53 deletions(-) create mode 100644 shared/fixtures/lsp/completion-refresh-v1.json diff --git a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md index 76b4ba1c88..cd1531cb8b 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md +++ b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md @@ -82,6 +82,22 @@ Rename/Formatting/Code Action/Resolve/Execute Command 目前仍是 LSP-only,未声明能力时返回明确的 capability 错误。Provider 抛错不会 让路由提前结束,用于隔离第三方语言服务器故障。 +### 连续输入时刷新补全 + +补全结果不仅包含候选项,还包含服务器的 `isIncomplete`(候选尚不完整)标记。 +Core 必须保留这个列表级标记,空列表也不能丢;平台合并本地候选时保留任一 +provider 的未完整状态。两端把它映射为 Monaco 的 `incomplete`,把 Monaco +触发原因换算为 LSP 数值,再交给 Core 校验并生成请求 context。继续输入需要新候选时,由 Monaco +重新请求,不在 Mac 和 Windows 各实现缓存刷新算法。旧的只读候选数组接口 +只供不消费列表状态的兼容调用,编辑器必须使用完整列表接口。 + +参考 OpenIDEA `fff2c711` 的 `convert.rs` 和 `completion.ts` 保留列表状态的 +处理方式;它使用 CodeMirror,我们复用现有 Monaco 0.55.1 的对应机制,不迁移 +其编辑器实现,也不增加语言语义引擎。仅把 `isIncomplete` 固定为 true 会增加 +不必要的服务器请求,因此保留上游实际值。验证包括共享 fixture、Swift 路由、 +Windows 适配器,以及真实 WebKit/Monaco 中从 `a` 继续输入 `b` 后出现第二次 +请求才返回的 `abacus` 候选;原有过期结果与资源清理边界继续适用。 + ### Windows 语义颜色请求 Windows 的语义高亮复用 Core 已有 `semanticTokens` 操作与请求时捕获的图例, diff --git a/frontend/editor/src/workbench.ts b/frontend/editor/src/workbench.ts index 1ed8a86462..4617334e88 100644 --- a/frontend/editor/src/workbench.ts +++ b/frontend/editor/src/workbench.ts @@ -1541,7 +1541,7 @@ export function mountWorkbench(host: WorkbenchHost) { }>(); monaco.languages.registerCompletionItemProvider("*", { triggerCharacters: ["."], - async provideCompletionItems(model, position, _context, token) { + async provideCompletionItems(model, position, context, token) { const pair = [...entries].find(([, entry]) => entry.model === model); const id = pair?.[0]; const entry = pair?.[1]; if (!id || !entry || entry.model !== model) return { suggestions: [] }; @@ -1551,10 +1551,11 @@ export function mountWorkbench(host: WorkbenchHost) { const revision = entry.revision, version = model.getVersionId(); const request = {}; completionRequests.set(entry, request); - const reply = await languageRequest({ type: "completion", id, revision, line: position.lineNumber - 1, column: position.column - 1 }); + const reply = await languageRequest({ type: "completion", id, revision, line: position.lineNumber - 1, column: position.column - 1, + triggerKind: context.triggerKind + 1, triggerCharacter: context.triggerCharacter }); if (!current() || token.isCancellationRequested || entries.get(id) !== entry || model.isDisposed() || entry.revision !== revision || model.getVersionId() !== version || completionRequests.get(entry) !== request || reply.cancelled) return { suggestions: [] }; const word = model.getWordUntilPosition(position); - return { suggestions: (reply.items ?? []).map((item: any) => { + return { incomplete: reply.isIncomplete === true, suggestions: (reply.items ?? []).map((item: any) => { const suggestion = { ...item, kind: mapCompletionKind(item.kind), insertTextRules: item.insertTextFormat === 2 ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined, range: item.range ?? new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) }; diff --git a/macos/Experiments/Monaco/main.swift b/macos/Experiments/Monaco/main.swift index 18a725fbf7..1cec90b856 100644 --- a/macos/Experiments/Monaco/main.swift +++ b/macos/Experiments/Monaco/main.swift @@ -371,6 +371,11 @@ final class Probe: NSObject, NSApplicationDelegate, WKScriptMessageHandlerWithRe "textEdits": [["range": ["startLineNumber": 1, "startColumn": 5, "endLineNumber": 1, "endColumn": 5], "text": "value: "]]]]], nil) case "hover": replyHandler(["contents": "Host hover"], nil) case "debugHover": replyHandler(["contents": "second = [literal]"], nil) + case "completion" where body["id"] as? String == "completion-refresh": + let refreshed = body["triggerKind"] as? Int == 3 + replyHandler(["isIncomplete": !refreshed, "items": [[ + "label": refreshed ? "abacus" : "alpha", "insertText": refreshed ? "abacus" : "alpha", "kind": 6 + ]]], nil) case "completion": replyHandler(["items": [ ["label": "sampleMethod", "insertText": "sampleMethod(${1:value})$0", "kind": 2, "insertTextFormat": 2, "sortText": "001", "filterText": "sample", "completionList": "fixture-list", "completionIndex": 0] as [String: Any] diff --git a/macos/Experiments/Monaco/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index 42b23b1ae6..096e8502c5 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -1576,6 +1576,36 @@ async function verify() { } finally { await window.lithe.retain([]); } } }); + await check("typing after an incomplete list asks the host for new candidates", async () => { + const id = "completion-refresh", text = "a"; + await send({ type: "open", id, text }); + await window.lithe.activate({ id, text, revision: 0, language: "java", readonly: false }); + editor.setPosition(new Position(1, 2)); + const controller = SuggestController.get(editor)!; + const suggestModel = (controller as any).model; + const listeners: { dispose(): void }[] = []; + const candidate = (label: string) => new Promise((resolve) => { + const listener = suggestModel.onDidSuggest((event: any) => { + if (event.completionModel.items.some((item: any) => item.completion.label === label)) { + listener.dispose(); resolve(); + } + }); + listeners.push(listener); + }); + try { + const initial = candidate("alpha"); + suggestModel.trigger({ auto: false }); + await withDeadline(initial, "initial completion was not displayed"); + const refreshed = candidate("abacus"); + editor.trigger("keyboard", "type", { text: "b" }); + await withDeadline(refreshed, "continued typing reused an incomplete list instead of requesting new candidates"); + assert(editor.getModel()!.getValue() === "ab", "completion refresh changed the buffer"); + } finally { + for (const listener of listeners) listener.dispose(); + suggestModel.cancel(); + await window.lithe.retain([]); + } + }); await check("accepted completion expands snippets imports and tab stops in one undo", async () => { const text = "// header\nsample", id = "snippet-accept"; await send({ type: "open", text }); diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 40734d2a1c..40c4632dd5 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -830,6 +830,7 @@ "Complete commit message" = "Complete commit message"; "Complete sign-in in your browser. Your credentials are managed by Codex." = "Complete sign-in in your browser. Your credentials are managed by Codex."; "Completed" = "Completed"; +"Completion trigger character is required" = "Completion trigger character is required"; "Completions" = "Completions"; "Compression is unavailable. Showing complete retained output." = "Compression is unavailable. Showing complete retained output."; "Concise" = "Concise"; @@ -1821,6 +1822,7 @@ "Invalid JVM performance output." = "Invalid JVM performance output."; "Invalid MCP tool configuration" = "Invalid MCP tool configuration"; "Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”)." = "Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”)."; +"Invalid completion trigger kind" = "Invalid completion trigger kind"; "Invalid plugin icon resource: %@" = "Invalid plugin icon resource: %@"; "Invalid plugin web entry: %@" = "Invalid plugin web entry: %@"; "Invalid shared instruction request." = "Invalid shared instruction request."; @@ -4616,6 +4618,8 @@ "https://github.com/owner/repository" = "https://github.com/owner/repository"; "just now" = "just now"; "local commit(s)" = "local commit(s)"; +"lsp.completion.invalidTriggerKind" = "Invalid completion trigger kind"; +"lsp.completion.missingTriggerCharacter" = "Completion trigger character is required"; "macOS could not prepare the update disk image. Download the release manually and try again." = "macOS could not prepare the update disk image. Download the release manually and try again."; "macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS could not prepare the update disk image. Open the Release page and install it manually."; "main Java" = "main Java"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 284cb78df5..2110be57f3 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -830,6 +830,7 @@ "Complete commit message" = "完整提交消息"; "Complete sign-in in your browser. Your credentials are managed by Codex." = "请在浏览器中完成登录,凭据由 Codex 管理。"; "Completed" = "已完成"; +"Completion trigger character is required" = "补全请求缺少有效的触发字符"; "Completions" = "补全"; "Compression is unavailable. Showing complete retained output." = "暂时无法压缩,正在显示完整保留的输出。"; "Concise" = "简洁"; @@ -1821,6 +1822,7 @@ "Invalid JVM performance output." = "JVM 性能输出无效。"; "Invalid MCP tool configuration" = "MCP 工具配置无效"; "Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”)." = "TOML 格式无效,请使用直引号(\"),不要使用智能引号(“ ”)。"; +"Invalid completion trigger kind" = "补全请求的触发类型无效"; "Invalid plugin icon resource: %@" = "无效的插件图标资源:%@"; "Invalid plugin web entry: %@" = "无效的插件网页入口:%@"; "Invalid shared instruction request." = "共享指令请求无效。"; @@ -4616,6 +4618,8 @@ "https://github.com/owner/repository" = "https://github.com/owner/repository"; "just now" = "刚刚"; "local commit(s)" = "个本地提交"; +"lsp.completion.invalidTriggerKind" = "补全请求的触发类型无效"; +"lsp.completion.missingTriggerCharacter" = "补全请求缺少有效的触发字符"; "macOS could not prepare the update disk image. Download the release manually and try again." = "macOS 无法准备更新磁盘映像,请手动下载版本后重试。"; "macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS 无法准备更新磁盘映像。请打开 Release 页面手动安装。"; "main Java" = "主代码"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index fa0a96b17b..a4c8d8e634 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2004,6 +2004,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let newName: String? let range: LspTextEditsRequest.TextEdit.Range? let diagnostics: [LspClientDiagnosticRequest] + let completionContext: LanguageServerCompletionContext? let completionItem: LspClientCompletionItemRequest? let codeAction: LspClientCodeActionRequest? let command: LspClientCommandRequest? @@ -2018,6 +2019,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { newName: String? = nil, range: LspTextEditsRequest.TextEdit.Range? = nil, diagnostics: [LspClientDiagnosticRequest] = [], + completionContext: LanguageServerCompletionContext? = nil, completionItem: LspClientCompletionItemRequest? = nil, codeAction: LspClientCodeActionRequest? = nil, command: LspClientCommandRequest? = nil @@ -2031,6 +2033,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { self.newName = newName self.range = range self.diagnostics = diagnostics + self.completionContext = completionContext self.completionItem = completionItem self.codeAction = codeAction self.command = command @@ -3905,6 +3908,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { newName: String? = nil, range: LanguageServerRange? = nil, diagnostics: [LanguageServerDiagnostic] = [], + completionContext: LanguageServerCompletionContext? = nil, completionItem: LanguageServerCompletionItem? = nil, codeAction: LanguageServerCodeAction? = nil, command: LanguageServerCommand? = nil @@ -3922,6 +3926,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { newName: newName, range: range.map(Self.makeRangeRequest), diagnostics: diagnostics.map(Self.makeDiagnosticRequest), + completionContext: completionContext, completionItem: completionItem.map(Self.makeCompletionItemRequest), codeAction: codeAction.map(Self.makeCodeActionRequest), command: command.map(Self.makeCommandRequest) diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 85a0e3153e..38bc81cb96 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -163,6 +163,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore { newName: String?, range: LanguageServerRange?, diagnostics: [LanguageServerDiagnostic], + completionContext: LanguageServerCompletionContext?, completionItem: LanguageServerCompletionItem?, codeAction: LanguageServerCodeAction?, command: LanguageServerCommand? @@ -176,6 +177,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore { newName: newName, range: range, diagnostics: diagnostics, + completionContext: completionContext, completionItem: completionItem, codeAction: codeAction, command: command @@ -418,9 +420,19 @@ extension RustCoreBridge: LanguageServerRuntimeCore { } private static func runtimeFailure(_ error: CoreCallError) -> LanguageServerRuntimeFailure { - LanguageServerRuntimeFailure( + // Only translate our own request validation; upstream diagnostics stay intact. + let message: String + switch (error.code, error.message) { + case ("invalid_request", "Invalid completion trigger kind"): + message = L10n.string("Invalid completion trigger kind") + case ("invalid_request", "Completion trigger character is required"): + message = L10n.string("Completion trigger character is required") + default: + message = error.message + } + return LanguageServerRuntimeFailure( code: error.code, - message: error.message, + message: message, details: error.details ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift index 0f0a954500..c60da77434 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift @@ -75,13 +75,23 @@ extension AppModel { } func requestLanguageCompletions( + for requestedDocument: EditorDocument? = nil, line: Int, utf16Column: Int, + completion: @escaping ([LanguageServerCompletionItem]) -> Void + ) { + requestLanguageCompletionList(for: requestedDocument, line: line, utf16Column: utf16Column) { + completion($0.items) + } + } + + func requestLanguageCompletionList( for requestedDocument: EditorDocument? = nil, line: Int, utf16Column: Int, - completion: @escaping ([LanguageServerCompletionItem]) -> Void + context: LanguageServerCompletionContext? = nil, + completion: @escaping (LanguageServerCompletionList) -> Void ) { guard let document = requestedDocument ?? focusedEditorDocument else { - completion([]) + completion(.init()) return } let springCompletions = springFeature.completions( @@ -92,32 +102,34 @@ extension AppModel { guard featureGraph.languageCapabilityPolicy.supports(.completion, documentURL: document.url, sessions: languageToolingSessionsIfActive), let workspaceURL else { - completion(springCompletions) + completion(.init(items: springCompletions)) return } do { - try languageToolingSessionsIfActive?.completions( + try languageToolingSessionsIfActive?.completionList( fileURL: document.url, text: document.text, position: featureGraph.languageEditing.position( line: line, utf16Column: utf16Column ), - rootURL: workspaceURL + rootURL: workspaceURL, completionContext: context ) { [weak self] result in guard let self else { - completion(springCompletions) + completion(.init(items: springCompletions)) return } self.featureGraph.languageEditing.handleCompletionResult( - result, + result.map(\.items), fallback: springCompletions, - completion: completion + completion: { items in + completion(.init(items: items, isIncomplete: (try? result.get().isIncomplete) ?? false)) + } ) } } catch { showNotification(error.localizedDescription) - completion(springCompletions) + completion(.init(items: springCompletions)) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift index 5db8f8f038..fdbac67aa4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift @@ -1569,7 +1569,10 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele } else { let key = UUID().uuidString completionLists[id] = (context, revision, key, []) - model.requestLanguageCompletions(for: document, line: line, utf16Column: column) { [weak self] items in + model.requestLanguageCompletionList(for: document, line: line, utf16Column: column, + context: .init(triggerKind: body["triggerKind"] as? Int ?? 1, + triggerCharacter: body["triggerCharacter"] as? String)) { [weak self] list in + let items = list.items guard let self, self.revisions[id] == revision, self.isCurrent(context), self.completionLists[id]?.key == key else { reply(["items": []], nil); return } self.completionLists[id] = (context, revision, key, items) @@ -1579,7 +1582,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele value["completionIndex"] = index return value } - reply(["items": values], nil) + reply(["items": values, "isIncomplete": list.isIncomplete], nil) } } case "definition": diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index f9dd1c5820..9d3fa54fae 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -444,6 +444,7 @@ package protocol LanguageServerRuntimeCore: Sendable { newName: String?, range: LanguageServerRange?, diagnostics: [LanguageServerDiagnostic], + completionContext: LanguageServerCompletionContext?, completionItem: LanguageServerCompletionItem?, codeAction: LanguageServerCodeAction?, command: LanguageServerCommand? diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 508ecaae75..9cbbcedb8b 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -912,6 +912,10 @@ package protocol LanguageServerSession: AnyObject { func synchronize(fileURL: URL, text: String, languageID: String) throws func notifyWorkspaceFilesChanged(_ changes: [LanguageServerWorkspaceFileChange]) throws func closeDocument(_ fileURL: URL) + func completionList( + fileURL: URL, position: LanguageServerPosition, context: LanguageServerCompletionContext?, + completion: @escaping (Result) -> Void + ) throws func completions( fileURL: URL, position: LanguageServerPosition, @@ -1017,7 +1021,36 @@ package protocol LanguageServerSession: AnyObject { func stop() } +/// Completion metadata is list-scoped, including when the list is empty. +package struct LanguageServerCompletionList: Sendable { + package var items: [LanguageServerCompletionItem] + package var isIncomplete: Bool + package init(items: [LanguageServerCompletionItem] = [], isIncomplete: Bool = false) { + self.items = items + self.isIncomplete = isIncomplete + } +} + +/// LSP trigger kinds: invoked (1), trigger character (2), incomplete-list refresh (3). +package struct LanguageServerCompletionContext: Encodable, Sendable { + package let triggerKind: Int + package let triggerCharacter: String? + package init(triggerKind: Int = 1, triggerCharacter: String? = nil) { + self.triggerKind = triggerKind + self.triggerCharacter = triggerCharacter + } +} + package extension LanguageServerSession { + func completionList( + fileURL: URL, position: LanguageServerPosition, context: LanguageServerCompletionContext?, + completion: @escaping (Result) -> Void + ) throws { + try completions(fileURL: fileURL, position: position) { result in + completion(result.map { LanguageServerCompletionList(items: $0) }) + } + } + @discardableResult func updateWorkspaceConfiguration(_: ToolingJSONValue) -> Bool { false } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift b/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift index 47f7f0b6fe..9076538196 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift @@ -52,19 +52,22 @@ package struct LanguageFeatureRequestContext: Sendable { package let position: LanguageServerPosition package let languageID: String? package let workspaceURL: URL? + package let completionContext: LanguageServerCompletionContext? package init( fileURL: URL, text: String, position: LanguageServerPosition, languageID: String? = nil, - workspaceURL: URL? = nil + workspaceURL: URL? = nil, + completionContext: LanguageServerCompletionContext? = nil ) { self.fileURL = fileURL.standardizedFileURL self.text = text self.position = position self.languageID = languageID self.workspaceURL = workspaceURL?.standardizedFileURL + self.completionContext = completionContext } } @@ -74,6 +77,10 @@ package protocol LanguageFeatureProvider: AnyObject { var priority: LanguageFeatureProviderPriority { get } func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool + func completionList( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws func completions( in context: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void @@ -89,6 +96,15 @@ package protocol LanguageFeatureProvider: AnyObject { ) throws } +package extension LanguageFeatureProvider { + func completionList( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + try completions(in: context) { completion($0.map { LanguageServerCompletionList(items: $0) }) } + } +} + @MainActor package final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { package let id = "builtin" @@ -198,6 +214,14 @@ package final class LanguageServerFeatureProvider: LanguageFeatureProvider { } } + package func completionList( + in context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void + ) throws { + try session.completionList(fileURL: context.fileURL, position: context.position, + context: context.completionContext, completion: completion) + } + package func completions( in context: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index cc68ae2d59..ba1ac8217f 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -582,10 +582,19 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { - try request(.completion, fileURL: fileURL, position: position) { result in + try completionList(fileURL: fileURL, position: position, context: nil) { + completion($0.map(\.items)) + } + } + + package func completionList( + fileURL: URL, position: LanguageServerPosition, context: LanguageServerCompletionContext?, + completion: @escaping (Result) -> Void + ) throws { + try request(.completion, fileURL: fileURL, position: position, completionContext: context) { result in completion(result.flatMap { Self.decodeEventResult($0, as: CompletionPayload.self) - }.map { $0.makeModels() }) + }.map { LanguageServerCompletionList(items: $0.makeModels(), isIncomplete: $0.isIncomplete ?? false) }) } } @@ -742,7 +751,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { core.requestLanguageServerOperation( sessionID: sessionID, operation: .executeCommand, fileURL: nil, virtualURI: nil, position: nil, newName: nil, range: nil, - diagnostics: [], completionItem: nil, codeAction: nil, command: command + diagnostics: [], completionContext: nil, completionItem: nil, codeAction: nil, command: command ) }) { case .success(let started): operation = started @@ -1159,6 +1168,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { newName: String? = nil, range: LanguageServerRange? = nil, diagnostics: [LanguageServerDiagnostic] = [], + completionContext: LanguageServerCompletionContext? = nil, completionItem: LanguageServerCompletionItem? = nil, codeAction: LanguageServerCodeAction? = nil, command: LanguageServerCommand? = nil, @@ -1176,6 +1186,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { newName: newName, range: range, diagnostics: diagnostics, + completionContext: completionContext, completionItem: completionItem, codeAction: codeAction, command: command @@ -1689,6 +1700,7 @@ private struct CompletionItemPayload: Decodable { } private struct CompletionPayload: Decodable { + let isIncomplete: Bool? let items: [CompletionItemPayload] func makeModels() -> [LanguageServerCompletionItem] { items.map { $0.makeModel() } } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 914592b187..35f3333aac 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -1331,17 +1331,27 @@ package final class LanguageToolingSessionManager: ObservableObject, } package func completions( + fileURL: URL, text: String, position: LanguageServerPosition, rootURL: URL, + completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + ) throws { + try completionList(fileURL: fileURL, text: text, position: position, rootURL: rootURL) { + completion($0.map(\.items)) + } + } + + package func completionList( fileURL: URL, text: String, position: LanguageServerPosition, rootURL: URL, - completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + completionContext: LanguageServerCompletionContext? = nil, + completion: @escaping (Result) -> Void ) throws { let context = featureContext( fileURL: fileURL, text: text, position: position, - rootURL: rootURL + rootURL: rootURL, completionContext: completionContext ) let providers = featureProviders(for: .completion, context: context) guard !providers.isEmpty else { @@ -1352,6 +1362,7 @@ package final class LanguageToolingSessionManager: ObservableObject, index: 0, context: context, items: [], + isIncomplete: false, seenLabels: [], completion: completion ) @@ -1921,7 +1932,8 @@ package final class LanguageToolingSessionManager: ObservableObject, fileURL: URL, text: String, position: LanguageServerPosition, - rootURL: URL? + rootURL: URL?, + completionContext: LanguageServerCompletionContext? = nil ) -> LanguageFeatureRequestContext { let descriptor = catalog.provider(for: fileURL) return LanguageFeatureRequestContext( @@ -1929,7 +1941,7 @@ package final class LanguageToolingSessionManager: ObservableObject, text: text, position: position, languageID: descriptor?.languageIdentifier(for: fileURL), - workspaceURL: rootURL + workspaceURL: rootURL, completionContext: completionContext ) } @@ -1952,20 +1964,21 @@ package final class LanguageToolingSessionManager: ObservableObject, index: Int, context: LanguageFeatureRequestContext, items: [LanguageServerCompletionItem], + isIncomplete: Bool, seenLabels: Set, - completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void + completion: @escaping (Result) -> Void ) { guard index < providers.count else { - completion(.success(items)) + completion(.success(LanguageServerCompletionList(items: items, isIncomplete: isIncomplete))) return } do { - try providers[index].completions(in: context) { [self] result in + try providers[index].completionList(in: context) { [self] result in switch result { case .success(let providerItems): var merged = items var labels = seenLabels - for item in providerItems where labels.insert(item.label).inserted { + for item in providerItems.items where labels.insert(item.label).inserted { merged.append(item) } routeCompletions( @@ -1973,6 +1986,7 @@ package final class LanguageToolingSessionManager: ObservableObject, index: index + 1, context: context, items: merged, + isIncomplete: isIncomplete || providerItems.isIncomplete, seenLabels: labels, completion: completion ) diff --git a/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift b/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift index 93c76246c8..72b40209cb 100644 --- a/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift +++ b/macos/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -53,6 +53,21 @@ struct LanguageFeatureProviderTests { #expect(items.contains { $0.label == "for" && $0.detail == "Go keyword" }) } + @Test(arguments: [true, false]) + func emptyProviderListPreservesRefreshStateWhenMergedWithLocalKeywords(incomplete: Bool) throws { + let remote = CompletionFeatureProvider(items: [], isIncomplete: incomplete) + let manager = LanguageToolingSessionManager(languageFeatureProviders: [remote]) + let fileURL = URL(fileURLWithPath: "/tmp/main.go") + var result: Result? + try manager.completionList(fileURL: fileURL, text: "f", + position: .init(line: 0, utf16Column: 1), rootURL: fileURL.deletingLastPathComponent(), + completionContext: .init(triggerKind: 3)) { result = $0 } + let list = try #require(result).get() + #expect(list.isIncomplete == incomplete) + #expect(list.items.contains { $0.label == "func" }) + #expect(remote.context?.triggerKind == 3) + } + private static func item(label: String, detail: String) -> LanguageServerCompletionItem { LanguageServerCompletionItem( label: label, @@ -74,15 +89,24 @@ private final class CompletionFeatureProvider: LanguageFeatureProvider { let id = "test.remote" let priority: LanguageFeatureProviderPriority = .languageServer private let items: [LanguageServerCompletionItem] + private let isIncomplete: Bool + private(set) var context: LanguageServerCompletionContext? - init(items: [LanguageServerCompletionItem]) { + init(items: [LanguageServerCompletionItem], isIncomplete: Bool = false) { self.items = items + self.isIncomplete = isIncomplete } func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { feature == .completion } + func completionList(in context: LanguageFeatureRequestContext, + completion: @escaping (Result) -> Void) throws { + self.context = context.completionContext + completion(.success(.init(items: items, isIncomplete: isIncomplete))) + } + func completions( in _: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 47f4abb29a..10023da23f 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -1023,7 +1023,7 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked func requestLanguageServerOperation( sessionID _: String, operation: LanguageServerOperation, fileURL _: URL?, virtualURI _: String?, position _: LanguageServerPosition?, newName _: String?, range _: LanguageServerRange?, - diagnostics _: [LanguageServerDiagnostic], completionItem _: LanguageServerCompletionItem?, + diagnostics _: [LanguageServerDiagnostic], completionContext: LanguageServerCompletionContext?, completionItem _: LanguageServerCompletionItem?, codeAction _: LanguageServerCodeAction?, command: LanguageServerCommand? ) -> Result { if operation == .executeCommand, command?.command == "example.startDebugSession" { diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift index c79253d730..c2243ae3ad 100644 --- a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -344,7 +344,7 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S func requestLanguageServerOperation( sessionID _: String, operation _: LanguageServerOperation, fileURL _: URL?, virtualURI _: String?, position _: LanguageServerPosition?, newName _: String?, range _: LanguageServerRange?, - diagnostics _: [LanguageServerDiagnostic], completionItem _: LanguageServerCompletionItem?, + diagnostics _: [LanguageServerDiagnostic], completionContext: LanguageServerCompletionContext?, completionItem _: LanguageServerCompletionItem?, codeAction _: LanguageServerCodeAction?, command _: LanguageServerCommand? ) -> Result { .success(LanguageServerRuntimeOperation(operationID: "unused")) diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 9694fd0302..b97eddbccd 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -2301,16 +2301,17 @@ struct RunConfigurationIntegrationTests { #expect(hint.tooltip == "Parameter name" && hint.paddingLeft && !hint.paddingRight) #expect(hint.textEdits.first?.newText == "value: ") - var completionsResult: Result<[LanguageServerCompletionItem], Error>? - try manager.completions( + var completionsResult: Result? + try manager.completionList( fileURL: source, text: "struct App { let tit }\n", position: LanguageServerPosition(line: 0, utf16Column: 20), - rootURL: root + rootURL: root, completionContext: .init(triggerKind: 3) ) { result in completionsResult = result } core.enqueueRequestSuccess(operation: .completion, result: [ + "isIncomplete": true, "items": [[ "label": "title", "insertText": "title", @@ -2319,7 +2320,9 @@ struct RunConfigurationIntegrationTests { ]] ]) #expect(await Self.waitForMainActorCondition { completionsResult != nil }) - #expect(try completionsResult?.get().first?.label == "title") + #expect(try completionsResult?.get().items.first?.label == "title") + #expect(try completionsResult?.get().isIncomplete == true) + #expect(core.requestCalls.last?.completionContext?.triggerKind == 3) let completionOperationID = try #require( core.requestCalls.last(where: { $0.operation == .completion })?.operationID ) @@ -4595,6 +4598,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u let newName: String? let range: LanguageServerRange? let diagnostics: [LanguageServerDiagnostic] + let completionContext: LanguageServerCompletionContext? let completionItem: LanguageServerCompletionItem? let codeAction: LanguageServerCodeAction? let command: LanguageServerCommand? @@ -4735,7 +4739,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u newName: String?, range: LanguageServerRange?, diagnostics: [LanguageServerDiagnostic], - completionItem: LanguageServerCompletionItem?, + completionContext: LanguageServerCompletionContext?, completionItem: LanguageServerCompletionItem?, codeAction: LanguageServerCodeAction?, command: LanguageServerCommand? ) -> Result { @@ -4751,6 +4755,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u newName: newName, range: range, diagnostics: diagnostics, + completionContext: completionContext, completionItem: completionItem, codeAction: codeAction, command: command diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 3e118affc6..c158e4e5b5 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -942,8 +942,40 @@ fn validate_lsp_method(method: &str) -> Result<(), CoreError> { fn feature_request_params(request: &ClientFeatureRequest) -> Result { let text_document = json!({ "uri": request.uri }); match request.method.as_str() { - "textDocument/completion" - | "textDocument/hover" + "textDocument/completion" => { + let context = request.completion_context.as_ref(); + let kind = context + .and_then(|v| v.get("triggerKind")) + .and_then(Value::as_u64) + .unwrap_or(1); + if !(1..=3).contains(&kind) + || context.is_some_and(|v| { + !v.is_object() || v.get("triggerKind").and_then(Value::as_u64).is_none() + }) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid completion trigger kind", + )); + } + let mut trigger = json!({"triggerKind": kind}); + if kind == 2 { + let character = context + .and_then(|v| v.get("triggerCharacter")) + .and_then(Value::as_str) + .filter(|v| v.chars().count() == 1) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Completion trigger character is required", + ) + })?; + trigger["triggerCharacter"] = json!(character); + } + Ok(json!({"textDocument": text_document, + "position": lsp_position_json(required_position(request)?), "context": trigger})) + } + "textDocument/hover" | "textDocument/definition" | "textDocument/declaration" | "textDocument/typeDefinition" @@ -1173,7 +1205,8 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - let result = result?; match method { Some("textDocument/completion") => Some(json!({ - "items": parse_completion_items(result) + "items": parse_completion_items(result), + "isIncomplete": result.get("isIncomplete").and_then(Value::as_bool).unwrap_or(false) })), Some("completionItem/resolve") => parse_completion_item(result).map(|item| { json!({ diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 1989c25f27..cc587bd642 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -374,6 +374,9 @@ pub struct SemanticRequest { pub range: Option, #[serde(default)] pub diagnostics: Vec, + /// LSP completion trigger context; validated by the shared request mapper. + #[serde(default)] + pub completion_context: Option, #[serde(default)] pub completion_item: Option, #[serde(default)] @@ -1207,6 +1210,7 @@ pub fn java_navigation_markers( new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1257,6 +1261,7 @@ pub fn java_resolve_navigation( new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -2639,6 +2644,7 @@ impl RuntimeSession { new_name: request.new_name, range: request.range, diagnostics: request.diagnostics, + completion_context: request.completion_context, completion_item: request.completion_item, code_action: request.code_action, command: request.command, @@ -6751,6 +6757,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -6776,6 +6783,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: Some(command), @@ -6889,6 +6897,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -8817,6 +8826,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -8883,6 +8893,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9016,6 +9027,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: Some(json!({ @@ -9197,6 +9209,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9250,6 +9263,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9549,6 +9563,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9577,6 +9592,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9661,6 +9677,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9751,6 +9768,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9794,6 +9812,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -9957,6 +9976,7 @@ mod tests { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -10271,6 +10291,7 @@ public class Main { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -10417,6 +10438,7 @@ public class Main { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -10536,6 +10558,7 @@ public class Main { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -10611,6 +10634,7 @@ public class Main { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -10689,6 +10713,7 @@ public class Main { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -10778,6 +10803,7 @@ public class Main { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs index f903d2ae29..b78beba4e3 100644 --- a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs @@ -228,6 +228,7 @@ fn execute_real_command( new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: Some(command.clone()), @@ -282,6 +283,7 @@ fn request_real_entrypoints( new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -310,6 +312,7 @@ fn request_real_test_items( new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -516,6 +519,7 @@ fn real_jdtls_discovers_builds_and_launches_java_25_entrypoints() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index e49be8d2df..c7a3a86f6b 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -294,6 +294,9 @@ pub struct ClientFeatureRequest { pub range: Option, #[serde(default)] pub diagnostics: Vec, + /// LSP completion trigger context; validated by the shared request mapper. + #[serde(default)] + pub completion_context: Option, #[serde(default)] pub completion_item: Option, #[serde(default)] diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 7ac3d4b7fa..86f4ee4c79 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::protocol::ErrorCode; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; @@ -659,6 +660,7 @@ fn client_core_tracks_documents_and_feature_requests() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1057,6 +1059,7 @@ fn client_core_preserves_class_completion_namespace_and_import_edits() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1098,6 +1101,7 @@ fn client_core_preserves_class_completion_namespace_and_import_edits() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: Some(json!({ "label": "Carbon", "kind": 7, @@ -1479,6 +1483,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1529,6 +1534,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { new_name: Some("start".to_string()), range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1568,6 +1574,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1644,6 +1651,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { message: "declared here".to_string(), }], }], + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1719,6 +1727,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: Some(json!({ "title": "Apply rename", @@ -1776,6 +1785,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: Some(json!({ "label": "launch", "insertText": "launch", @@ -1834,6 +1844,7 @@ fn client_core_shapes_feature_responses_for_swift_models() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: Some(json!({ @@ -1890,6 +1901,7 @@ fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() }, }), diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1941,6 +1953,7 @@ fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -1982,6 +1995,7 @@ fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -2036,6 +2050,7 @@ fn navigation_locations_preserve_uris_without_fabricating_virtual_file_paths() { new_name: None, range: None, diagnostics: Vec::new(), + completion_context: None, completion_item: None, code_action: None, command: None, @@ -2356,3 +2371,69 @@ fn client_core_rejects_unknown_server_requests_with_method_not_found() { }) ); } + +#[test] +fn client_completion_preserves_refresh_context_and_incomplete_lists() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/lsp/completion-refresh-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/completion/Main.java".into(), + language_id: "java".into(), + text: "class Main {}".into(), + }) + .unwrap(); + let request: ClientFeatureRequest = serde_json::from_value(json!({ + "state": opened.state, "uri": "file:///tmp/completion/Main.java", + "method": "textDocument/completion", "position": {"line":0,"utf16Column":0}, + "completionContext": case["context"] + })) + .unwrap(); + let requested = client_feature_request(request).unwrap(); + let message: Value = serde_json::from_str(&requested.messages[0]).unwrap(); + assert_eq!(message["params"]["context"], case["context"]); + let completed = client_apply_server_message(ClientApplyServerMessageRequest { + state: requested.state, + message: json!({"jsonrpc":"2.0", "id":message["id"], "result":case["response"]}) + .to_string(), + }) + .unwrap(); + let result = completed.events[0].result.as_ref().unwrap(); + assert_eq!(result["isIncomplete"], case["isIncomplete"]); + } +} + +#[test] +fn client_completion_rejects_invalid_trigger_context() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/Main.java".into(), + language_id: "java".into(), + text: "class Main {}".into(), + }) + .unwrap(); + for context in [ + json!({"triggerKind":0}), + json!({"triggerKind":4}), + json!({"triggerKind":2}), + json!({"triggerKind":2,"triggerCharacter":"ab"}), + json!({"triggerKind":"3"}), + json!([]), + ] { + let request: ClientFeatureRequest = serde_json::from_value(json!({ + "state": opened.state, + "uri":"file:///tmp/Main.java", "method":"textDocument/completion", + "position":{"line":0,"utf16Column":0}, "completionContext":context + })) + .unwrap(); + let error = client_feature_request(request).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert!(matches!( + error.message.as_str(), + "Invalid completion trigger kind" | "Completion trigger character is required" + )); + } +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2cd048b917..bfed7d1ddf 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3907,6 +3907,18 @@ Missing or stale HEAD rejects the amendment before writing; plain Continue, Skip and Abort do not require this field. Both products must send the HEAD reviewed by the amendment editor. See [Rebase sessions](git-rebase-session.md). +Completion requests accept optional `completionContext` with LSP `triggerKind` +(`1` invoked, `2` trigger character, `3` incomplete-list refresh) and +`triggerCharacter` (required and one character for kind `2`). Missing context +means invoked. Core validates and maps this into LSP `params.context`. +Completion results carry `{ items, isIncomplete }`; array and null server results +are complete. The flag is list-scoped and survives empty lists and host provider +merging. Both Monaco adapters set `CompletionList.incomplete` and forward Monaco's +trigger context, so continued typing can request additional candidates instead of +filtering a partial cache. Existing document-version, cancellation and attachment +checks still reject obsolete results. The fixture is +`shared/fixtures/lsp/completion-refresh-v1.json`. + Completion items returned by the LSP client and runtime preserve `insertTextFormat` (`1` for plain text, `2` for snippets; absent values default to `1`). Hosts retain this field through completion resolution. Monaco applies snippet text with its diff --git a/shared/fixtures/lsp/completion-refresh-v1.json b/shared/fixtures/lsp/completion-refresh-v1.json new file mode 100644 index 0000000000..405f23f868 --- /dev/null +++ b/shared/fixtures/lsp/completion-refresh-v1.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "cases": [ + { + "context": { + "triggerKind": 1 + }, + "response": [ + { + "label": "alpha" + } + ], + "isIncomplete": false + }, + { + "context": { + "triggerKind": 2, + "triggerCharacter": "." + }, + "response": { + "items": [ + { + "label": "beta" + } + ], + "isIncomplete": true + }, + "isIncomplete": true + }, + { + "context": { + "triggerKind": 3 + }, + "response": { + "items": [], + "isIncomplete": true + }, + "isIncomplete": true + }, + { + "context": { + "triggerKind": 3 + }, + "response": null, + "isIncomplete": false + } + ] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 4acee3de83..c125253084 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9515,7 +9515,9 @@ "document.fileAccess.theSharedFileCouldNotBeFullySavedAllHardLinksMayContainPartialChangesYourEditorContentsAreRetainedCheckTheFileBeforeRetrying": "The shared file could not be fully saved. All hard links may contain partial changes. Your editor contents are retained; check the file before retrying.", "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "This file type cannot be opened in this editor", "document.fileAccess.updateAllHardLinks": "Update All Hard Links", - "document.fileAccess.savePermissionDenied": "Could not save {arg1}: permission denied. Check the file and parent folder permissions. Your edits are still unsaved." + "document.fileAccess.savePermissionDenied": "Could not save {arg1}: permission denied. Check the file and parent folder permissions. Your edits are still unsaved.", + "lsp.completion.invalidTriggerKind": "Invalid completion trigger kind", + "lsp.completion.missingTriggerCharacter": "Completion trigger character is required" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index d3d242a32e..f62970555a 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9515,7 +9515,9 @@ "document.fileAccess.theSharedFileCouldNotBeFullySavedAllHardLinksMayContainPartialChangesYourEditorContentsAreRetainedCheckTheFileBeforeRetrying": "未能完整保存共享文件。所有硬链接可能已包含部分修改。编辑器中的内容已保留,请检查文件后再重试。", "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "此编辑器不支持打开这种文件类型", "document.fileAccess.updateAllHardLinks": "更新所有硬链接", - "document.fileAccess.savePermissionDenied": "无法保存 {arg1}:权限不足。请检查文件及其父文件夹的权限,编辑内容尚未保存。" + "document.fileAccess.savePermissionDenied": "无法保存 {arg1}:权限不足。请检查文件及其父文件夹的权限,编辑内容尚未保存。", + "lsp.completion.invalidTriggerKind": "补全请求的触发类型无效", + "lsp.completion.missingTriggerCharacter": "补全请求缺少有效的触发字符" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index c391ee48c3..d731c0c2d5 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -90792,6 +90792,30 @@ "arg1" ] } + }, + { + "id": "lsp.completion.invalidTriggerKind", + "description": "Completion request validation from shared Core", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Invalid completion trigger kind", + "arguments": [] + } + }, + { + "id": "lsp.completion.missingTriggerCharacter", + "description": "Completion request validation from shared Core", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Completion trigger character is required", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/lsp-completion-hover.json b/shared/platform-feature-matrix/features/lsp-completion-hover.json index ff19cc49f2..57f0698fb6 100644 --- a/shared/platform-feature-matrix/features/lsp-completion-hover.json +++ b/shared/platform-feature-matrix/features/lsp-completion-hover.json @@ -9,7 +9,8 @@ "rust/lithe-core/src/lsp/languages/jdt.rs", "rust/lithe-core/src/lsp/interface/engine.rs", "shared/fixtures/lsp/java-completion-v1.json", - "shared/fixtures/lsp" + "shared/fixtures/lsp", + "shared/fixtures/lsp/completion-refresh-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -20,11 +21,12 @@ "rust/lithe-core/src/lsp/languages/jdt.rs", "rust/lithe-core/src/lsp/interface/engine.rs", "shared/fixtures/lsp/java-completion-v1.json", - "shared/fixtures/lsp" + "shared/fixtures/lsp", + "shared/fixtures/lsp/completion-refresh-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。Java 构造器和方法补全使用 JDT LS 的 guessMethodArguments=off:输入 new No 后接受 Node 候选应得到 Node(),光标在括号内;方法调用同样不插入参数名,构造器重载候选由 JDT LS 合并为类型候选。验证 initialize、didChangeConfiguration 与 workspace/configuration 三个配置入口一致,父级和叶级 section 均保留插件 favoriteStaticMembers、filteredTypes 等其他补全配置,其他语言 snippet、Java 方法补全签名展示及自动导入仍正常。" + "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。Java 构造器和方法补全使用 JDT LS 的 guessMethodArguments=off:输入 new No 后接受 Node 候选应得到 Node(),光标在括号内;方法调用同样不插入参数名,构造器重载候选由 JDT LS 合并为类型候选。验证 initialize、didChangeConfiguration 与 workspace/configuration 三个配置入口一致,父级和叶级 section 均保留插件 favoriteStaticMembers、filteredTypes 等其他补全配置,其他语言 snippet、Java 方法补全签名展示及自动导入仍正常。 连续输入时保留 isIncomplete 并通过 LSP triggerKind=3 重新请求候选;验证空未完整列表、完整/空响应、触发字符、快速修改及文档关闭/会话替换后的过期结果。" } diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index 09be45443f..d8e3d07996 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -188,7 +188,9 @@ export function registerMonacoLspProviders() { >(); languages.registerCompletionItemProvider(selector, { triggerCharacters: [".", ":", "<", '"', "'", "/", "@", "#", "$", "\\"], - async provideCompletionItems(model, position) { + async provideCompletionItems(model, position, context, token) { + const version = model.getVersionId(); + const current = () => !token.isCancellationRequested && !model.isDisposed() && model.getVersionId() === version; const target = availableTarget(model, "completion"); if (!target) return { suggestions: [] }; @@ -197,11 +199,14 @@ export function registerMonacoLspProviders() { // completion from the text they hold, so without flushing the cursor sits // one character past what they know and the answer is empty. await flushLspDocumentChanges(target.filePath); + if (!current()) return { suggestions: [] }; const completions = await lspClient.getCompletions( target, position.lineNumber - 1, position.column - 1, + { triggerKind: context.triggerKind + 1, triggerCharacter: context.triggerCharacter }, ); + if (!current()) return { suggestions: [] }; const word = model.getWordUntilPosition(position); const range = new MonacoRange( position.lineNumber, @@ -211,7 +216,8 @@ export function registerMonacoLspProviders() { ); return { - suggestions: completions.map((item) => { + incomplete: completions.isIncomplete, + suggestions: completions.items.map((item) => { const suggestion = toCompletionItem(item, range); const origin = { item, target, range }; completionOrigins.set(suggestion, origin); diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 8a78207a54..e52fab8fae 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -18,6 +18,8 @@ import { } from "./language-server-feedback"; import type { CompletionItem, + CompletionList, + CompletionContext, Hover, PublishDiagnosticsParams, } from "vscode-languageserver-protocol"; @@ -1361,7 +1363,8 @@ export class LspClient { target: LspDocumentTargetInput, line: number, character: number, - ): Promise { + completionContext?: CompletionContext, + ): Promise { const document = normalizeLspDocumentTarget(target); try { logger.debug( @@ -1372,20 +1375,21 @@ export class LspClient { "LSPClient", `Active language servers: ${Array.from(this.activeLanguageServers).join(", ")}`, ); - const completions = await invoke("lsp_get_completions", { + const completions = await invoke("lsp_get_completions", { ...lspDocumentRequestArgs(document), line, character, + completionContext, }); - if (completions.length === 0) { + if (completions.items.length === 0) { logger.warn("LSPClient", "LSP returned 0 completions - checking LSP status"); } else { - logger.debug("LSPClient", `Got ${completions.length} completions from LSP server`); + logger.debug("LSPClient", `Got ${completions.items.length} completions from LSP server`); } return completions; } catch (error) { logger.error("LSPClient", "LSP completion error:", error); - return []; + return { items: [], isIncomplete: false }; } } diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index f476af8655..f139cbd437 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -3242,6 +3242,8 @@ export const sharedCatalogs = { "localHistory.title": "Local History", "localHistory.titleForFile": "Local History: {file}", "lsp.active": "Language Servers Active", + "lsp.completion.invalidTriggerKind": "Invalid completion trigger kind", + "lsp.completion.missingTriggerCharacter": "Completion trigger character is required", "lsp.connecting": "Connecting to Language Server...", "lsp.connectingShort": "Connecting", "lsp.didNotStart": "Language server did not start.", @@ -8191,6 +8193,8 @@ export const sharedCatalogs = { "localHistory.title": "本地历史", "localHistory.titleForFile": "本地历史:{file}", "lsp.active": "语言服务器运行中", + "lsp.completion.invalidTriggerKind": "补全请求的触发类型无效", + "lsp.completion.missingTriggerCharacter": "补全请求缺少有效的触发字符", "lsp.connecting": "正在连接语言服务器...", "lsp.connectingShort": "连接中", "lsp.didNotStart": "语言服务器未启动。", diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index 2b6f26421f..d64be50cd8 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -1559,6 +1559,20 @@ describe("Rust Core LSP adapter failures", () => { expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(3); }); + test("preserves completion-list refresh metadata and trigger context", async () => { + scenario = "semantic-request"; + const fixture = JSON.parse(readFileSync(new URL("../../../../shared/fixtures/lsp/completion-refresh-v1.json", import.meta.url), "utf8")); + const filePath = "C:/work/Main.java"; + await invokeLsp("lsp_start_for_file", { workspacePath: "C:/work", filePath, languageId: "java", serverPath: "java-lsp" }); + for (const entry of fixture.cases) { + semanticRequestResult = { items: [], isIncomplete: entry.isIncomplete }; + expect(await invokeLsp("lsp_get_completions", { + filePath, line: 0, character: 1, completionContext: entry.context, + })).toEqual(semanticRequestResult); + expect(requestPayload?.completionContext).toEqual(entry.context); + } + }); + test("routes semantic tokens through Core and preserves its negotiated legend and null results", async () => { scenario = "semantic-request"; const fixture = JSON.parse(readFileSync(new URL("../../../../shared/fixtures/lsp/semantic-tokens-v1.json", import.meta.url), "utf8")); @@ -1639,6 +1653,26 @@ describe("Rust Core LSP adapter failures", () => { } }); + test("discards completions from a replaced file attachment", async () => { + scenario = "semantic-request"; + semanticRequestResult = { items: [{ label: "old" }], isIncomplete: true }; + const filePath = "C:/work/Main.java"; + const start = { workspacePath: "C:/work", filePath, languageId: "java", serverPath: "java-lsp" }; + await invokeLsp("lsp_start_for_file", { ...start, attachmentId: "old" }); + semanticResultGate = new Promise((resolve) => { releaseSemanticResult = resolve; }); + const requested = new Promise((resolve) => { onSemanticRequest = resolve; }); + const result = invokeLsp("lsp_get_completions", { filePath }); + try { + await requested; + await invokeLsp("lsp_start_for_file", { ...start, attachmentId: "replacement" }); + releaseSemanticResult!(); + expect(await result).toEqual({ items: [], isIncomplete: false }); + } finally { + releaseSemanticResult!(); + await result.catch(() => undefined); + } + }); + test("returns a structured capability error for explicitly unavailable commands", async () => { await expect( invokeLsp("lsp_prepare_rename", { diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 8a7755ec95..529350bd1f 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -1342,6 +1342,7 @@ function semanticPayload(command: string, args: JsonRecord, session: Session): J if (typeof args.line === "number") { payload.position = { line: args.line, utf16Column: args.character ?? 0 }; } + if (command === "lsp_get_completions") payload.completionContext = args.completionContext; if (command === "lsp_rename") payload.newName = args.newName; if (command === "lsp_resolve_completion") payload.completionItem = args.completionItem; if (command === "lsp_get_inlay_hints") { @@ -1376,7 +1377,7 @@ function unwrapResult(command: string, result: any): unknown { const normalized = normalizeCoreValue(result) as JsonRecord; switch (command) { case "lsp_get_completions": - return normalized.items ?? []; + return { items: normalized.items ?? [], isIncomplete: normalized.isIncomplete === true }; case "lsp_resolve_completion": return normalized.item ?? null; case "lsp_get_hover": { @@ -1427,7 +1428,10 @@ async function semanticRequest(command: string, args: JsonRecord): Promise Date: Thu, 29 Oct 2026 11:11:40 -0700 Subject: [PATCH 2/2] fix(completion): type trigger mapping and adapter expectations --- .../features/editor/engines/monaco/lsp-providers.ts | 10 +++++++++- windows/tauri/src/platform/lsp-core-adapter.test.ts | 10 ++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index d8e3d07996..a9b366f89c 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -13,6 +13,7 @@ import type * as Monaco from "monaco-editor"; // Ctrl+hover underline for go-to-definition. import "monaco-editor/esm/vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition.js"; import type { CompletionItem, Hover } from "vscode-languageserver-protocol"; +import { CompletionTriggerKind as LspCompletionTriggerKind } from "vscode-languageserver-protocol"; import { listen } from "@tauri-apps/api/event"; import { ownsLspSession } from "@/platform/lsp-core-adapter"; import { isDocumentFeatureAvailable, LspClient } from "@/features/editor/lsp/lsp-client"; @@ -204,7 +205,14 @@ export function registerMonacoLspProviders() { target, position.lineNumber - 1, position.column - 1, - { triggerKind: context.triggerKind + 1, triggerCharacter: context.triggerCharacter }, + { + triggerKind: context.triggerKind === languages.CompletionTriggerKind.TriggerCharacter + ? LspCompletionTriggerKind.TriggerCharacter + : context.triggerKind === languages.CompletionTriggerKind.TriggerForIncompleteCompletions + ? LspCompletionTriggerKind.TriggerForIncompleteCompletions + : LspCompletionTriggerKind.Invoked, + triggerCharacter: context.triggerCharacter, + }, ); if (!current()) return { suggestions: [] }; const word = model.getWordUntilPosition(position); diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index d64be50cd8..40b737dc7f 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -2,6 +2,7 @@ import { getProjectPreparation } from "@/features/run/stores/project-preparation import type { JavaRunLaunchPreparation } from "@/features/run/services/java-run-launch"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { readFileSync } from "node:fs"; +import type { CompletionList } from "vscode-languageserver-protocol"; const emit = mock(async () => undefined); const emitTo = mock(async () => undefined); @@ -1565,11 +1566,12 @@ describe("Rust Core LSP adapter failures", () => { const filePath = "C:/work/Main.java"; await invokeLsp("lsp_start_for_file", { workspacePath: "C:/work", filePath, languageId: "java", serverPath: "java-lsp" }); for (const entry of fixture.cases) { - semanticRequestResult = { items: [], isIncomplete: entry.isIncomplete }; - expect(await invokeLsp("lsp_get_completions", { + const expected: CompletionList = { items: [], isIncomplete: entry.isIncomplete }; + semanticRequestResult = expected; + expect(await invokeLsp("lsp_get_completions", { filePath, line: 0, character: 1, completionContext: entry.context, - })).toEqual(semanticRequestResult); - expect(requestPayload?.completionContext).toEqual(entry.context); + })).toEqual(expected); + expect(requestPayload).toEqual(expect.objectContaining({ completionContext: entry.context })); } });