From dbfbfbfe8fe6b53c60e819b342cff62fe4a38258 Mon Sep 17 00:00:00 2001 From: Poolside Agent Date: Sat, 10 Oct 2026 18:25:55 +0300 Subject: [PATCH 1/2] test(lsp): cover JSONRPCTransport framing, error envelopes and server death Extract the Content-Length message splitter into a pure, testable `parseNextMessage(from:)` and the response-routing decision into a testable `routeResponse`, then exercise both. Round-trip tests (request -> framed response) and a server-crash test confirm that: - messages split across reads are reassembled, and malformed headers resync; - an `error:` envelope resumes the parked request throwing the server's ResponseError (previously it was silently turned into `null` and surfaced as an opaque decode failure); - a server exit fails in-flight requests with `notConnected` instead of hanging, and fires a new `setDisconnectionHandler` callback. LSPClient.connect now registers a disconnection handler that flips the client to not-ready on a dead sourcekit-lsp, so callers fail fast instead of waiting forever. Docs in the working tree are updated but intentionally not committed. --- Sources/CodeEditorLSP/JSONRPCTransport.swift | 91 +++++++- Sources/CodeEditorLSP/LSPClient.swift | 17 ++ .../JSONRPCTransportTests.swift | 218 ++++++++++++++++++ 3 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 Tests/CodeEditorTests/JSONRPCTransportTests.swift diff --git a/Sources/CodeEditorLSP/JSONRPCTransport.swift b/Sources/CodeEditorLSP/JSONRPCTransport.swift index 1de3866..937f5df 100644 --- a/Sources/CodeEditorLSP/JSONRPCTransport.swift +++ b/Sources/CodeEditorLSP/JSONRPCTransport.swift @@ -33,10 +33,15 @@ public actor JSONRPCTransport { private var process: Process? private var writeHandle: FileHandle? private var readHandle: FileHandle? + /// Held open by `startForTesting` so writes to the test pipe don't trip SIGPIPE. + private var testPipe: Pipe? private var buffer = Data() private var pending: [Int: CheckedContinuation] = [:] private var notificationHandlers: [@Sendable (String, JSONValue) -> Void] = [] + /// Called when the server process disappears (crash/exit). Lets the LSP client react — e.g. + /// mark itself not-ready and clear the `lastError` so the editor can stop waiting on it. + private var onDisconnection: (@Sendable () -> Void)? private var nextId = 1 private var isRunning = false @@ -89,6 +94,7 @@ public actor JSONRPCTransport { readHandle = nil try? writeHandle?.close() writeHandle = nil + testPipe = nil if let process, process.isRunning { process.terminate() @@ -104,6 +110,26 @@ public actor JSONRPCTransport { pending.removeAll() } + // MARK: - Testing seam + + /// Drives a request/response cycle in tests without launching a real server. + /// Sets the running flag and attaches a discarding write pipe so `request(...)` can + /// park a continuation; tests then feed responses via `ingest` or simulate a crash + /// via `handleServerClosed`. + internal func startForTesting() { + self.isRunning = true + self.nextId = 1 + self.process = nil + let pipe = Pipe() + self.testPipe = pipe + self.writeHandle = pipe.fileHandleForWriting + self.readHandle = nil + } + + /// The ids of requests currently waiting on a response. Used to wait for a parked + /// request before feeding it a response in tests. + internal var testPendingIds: [Int] { pending.keys.sorted() } + // MARK: - Reading private func startReading() { @@ -120,16 +146,19 @@ public actor JSONRPCTransport { private static let queueKey = DispatchSpecificKey() /// Append bytes and dispatch every complete framed message. - private func ingest(_ data: Data) { + internal func ingest(_ data: Data) { buffer.append(data) - while let message = nextMessage() { + while let message = Self.parseNextMessage(from: &buffer) { handle(message) } } /// Extract the next `Content-Length`-framed message, or nil if more bytes are needed. - private func nextMessage() -> Data? { + /// + /// Pure with respect to the actor: it only touches the supplied buffer, so it is + /// unit-tested directly (including messages split across reads and malformed headers). + internal static func parseNextMessage(from buffer: inout Data) -> Data? { let headerEnd = buffer.range(of: Data([0x0D, 0x0A, 0x0D, 0x0A])) guard let headerEnd else { return nil } @@ -145,7 +174,7 @@ public actor JSONRPCTransport { else { // Malformed header; drop it and resynchronize on the next one. buffer.removeSubrange(buffer.startIndex...headerEnd.upperBound) - return nextMessage() + return parseNextMessage(from: &buffer) } let bodyStart = headerEnd.upperBound @@ -164,9 +193,15 @@ public actor JSONRPCTransport { if let id = message.id { // A response to a request we sent. - let result = message.result.map { try? encoder.encode($0) } ?? nil - let value = result ?? Data("null".utf8) - pending.removeValue(forKey: id)?.resume(returning: value) + switch Self.routeResponse(message) { + case .failure(let error): + pending.removeValue(forKey: id)?.resume(throwing: error) + case .resume(let value): + pending.removeValue(forKey: id)?.resume(returning: value) + case .none: + // No response id to match against — should not happen here. + break + } return } @@ -178,13 +213,46 @@ public actor JSONRPCTransport { } } - private func handleServerClosed() { + /// Decide what a parked request continuation resumes with for one decoded message. + /// + /// - A `result` resumes the caller with that value (JSON-encoded). + /// - An `error` envelope resumes the caller **throwing** the server's `ResponseError`, + /// so the error message is surfaced instead of being silently turned into `null` and + /// later failing as an opaque decode error. + /// + /// Pure with respect to actor state: it does not read or write `pending`, so it is + /// unit-tested directly. + internal static func routeResponse(_ message: RawMessage) -> ResponseAction { + guard message.id != nil else { return .none } + if let error = message.error { + return .failure(error) + } + let result = message.result.flatMap { try? responseEncoder.encode($0) } + return .resume(result ?? Data("null".utf8)) + } + + internal enum ResponseAction { + /// Resume the parked request with this data. + case resume(Data) + /// Resume the parked request throwing this error. + case failure(Error) + /// Not a response (e.g. a notification with no id). + case none + } + + private static let responseEncoder = JSONEncoder() + + /// Called when the server's read pipe reports EOF — i.e. the process exited, most + /// likely a crash. Any in-flight request is failed with `notConnected` so callers do + /// not hang, and `onDisconnection` lets the LSP client flip to not-ready. + internal func handleServerClosed() { guard isRunning else { return } isRunning = false for (_, continuation) in pending { continuation.resume(throwing: TransportError.notConnected) } pending.removeAll() + onDisconnection?() } // MARK: - Writing @@ -193,6 +261,13 @@ public actor JSONRPCTransport { notificationHandlers.append(handler) } + /// Register a callback fired when the server process exits unexpectedly (crash or clean + /// exit detected via a closed read pipe). The LSP client uses this to flip to a + /// not-ready state so callers fail fast instead of hanging. + public func setDisconnectionHandler(_ handler: @escaping @Sendable () -> Void) { + onDisconnection = handler + } + public func notify(method: String, params: (any Encodable)?) async throws { let payload = try encoder.encode(LSPNotification(method: method, params: params.map(AnyEncodable.init))) write(payload) diff --git a/Sources/CodeEditorLSP/LSPClient.swift b/Sources/CodeEditorLSP/LSPClient.swift index c04a58f..9d89511 100644 --- a/Sources/CodeEditorLSP/LSPClient.swift +++ b/Sources/CodeEditorLSP/LSPClient.swift @@ -94,6 +94,12 @@ public final class LSPClient { isConnected = true lastError = nil + await transport.setDisconnectionHandler { [weak self] in + Task { @MainActor [weak self] in + self?.handleTransportDisconnect() + } + } + await transport.onNotification { method, params in Task { @MainActor [weak self] in self?.handleNotification(method: method, params: params) @@ -133,6 +139,17 @@ public final class LSPClient { versions.removeAll() } + /// Called by the transport when the server process exits unexpectedly. Marks the + /// client not-ready so `languageServer(for:)` will drop and reconnect on the next + /// request, rather than leaving callers parked on a dead connection. + private func handleTransportDisconnect() { + guard isConnected else { return } + isConnected = false + isReady = false + serverCapabilities = nil + lastError = "SourceKit-LSP stopped responding." + } + // MARK: - Document synchronization public func openDocument(uri: String, languageId: String, content: String) async { diff --git a/Tests/CodeEditorTests/JSONRPCTransportTests.swift b/Tests/CodeEditorTests/JSONRPCTransportTests.swift new file mode 100644 index 0000000..7fd0af7 --- /dev/null +++ b/Tests/CodeEditorTests/JSONRPCTransportTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing + +@testable import CodeEditorLSP + +// MARK: - Test helpers + +/// Frames a JSON-RPC body with the `Content-Length` header the transport expects. +private func framed(_ json: String) -> Data { + let body = Data(json.utf8) + var data = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + data.append(body) + return data +} + +/// A one-cell box so a `@Sendable` disconnection handler can signal back to the test. +private final class Flag: @unchecked Sendable { + var value = false +} + +/// Wait until the transport reports `count` parked requests, or fail after `timeout`. +private func waitForPending( + _ transport: JSONRPCTransport, + _ count: Int = 1, + timeout: TimeInterval = 1.0 +) async { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await transport.testPendingIds.count >= count { return } + try? await Task.sleep(nanoseconds: 1_000_000) + } +} + +// MARK: - Framing + +@Suite("JSONRPCTransport framing") +struct JSONRPCTransportFramingTests { + @Test("extracts a message once enough bytes have arrived") + func completeFrame() { + let body = Data(#"{"jsonrpc":"2.0","id":1,"result":42}"#.utf8) + var buffer = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + buffer.append(body) + + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == body) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == nil) + } + + @Test("waits for the body when only a header has arrived") + func partialMessage() { + var buffer = Data("Content-Length: 16\r\n\r\n".utf8) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == nil) + + buffer.append(Data("partial".utf8)) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == nil) + + buffer.append(Data("body!more".utf8)) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == Data("partialbody!more".utf8)) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == nil) + } + + @Test("reassembles a message split across two reads") + func splitAcrossReads() { + let body = Data(#"{"jsonrpc":"2.0","id":1,"result":"ok"}"#.utf8) + let header = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + var buffer = Data() + + buffer.append(header + body.prefix(5)) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == nil) + buffer.append(body.dropFirst(5)) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == body) + } + + @Test("extracts consecutive messages from one buffer") + func twoMessagesOneRead() { + let body1 = Data(#"{"jsonrpc":"2.0","id":1,"result":1}"#.utf8) + let body2 = Data(#"{"jsonrpc":"2.0","id":2,"result":2}"#.utf8) + var buffer = Data() + buffer.append(Data("Content-Length: \(body1.count)\r\n\r\n".utf8)) + buffer.append(body1) + buffer.append(Data("Content-Length: \(body2.count)\r\n\r\n".utf8)) + buffer.append(body2) + + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == body1) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == body2) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == nil) + } + + @Test("resynchronizes past a malformed header") + func malformedHeader() { + let body = Data(#"{"a":1}"#.utf8) + var buffer = Data() + buffer.append(Data("GARBAGE_LINE_WITH_NO_LENGTH\r\n".utf8)) + buffer.append(Data("Content-Length: \(body.count)\r\n\r\n".utf8)) + buffer.append(body) + + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == body) + #expect(JSONRPCTransport.parseNextMessage(from: &buffer) == nil) + } +} + +// MARK: - Response routing + +@Suite("JSONRPCTransport response routing") +private struct ResponseRoutingTests { + private func decode(_ json: String) throws -> RawMessage { + try JSONDecoder().decode(RawMessage.self, from: Data(json.utf8)) + } + + @Test("a result resumes the parked request with the encoded value") + func resultResumes() throws { + let message = try decode(#"{"jsonrpc":"2.0","id":1,"result":"ok"}"#) + switch JSONRPCTransport.routeResponse(message) { + case .resume(let data): + #expect(String(data: data, encoding: .utf8) == #""ok""#) + default: + Issue.record("expected a resume action") + } + } + + @Test("a null result resumes with null") + func nullResultResumes() throws { + let message = try decode(#"{"jsonrpc":"2.0","id":1,"result":null}"#) + switch JSONRPCTransport.routeResponse(message) { + case .resume(let data): + #expect(String(data: data, encoding: .utf8) == "null") + default: + Issue.record("expected a resume action") + } + } + + @Test("an error envelope fails the request with the server's message") + func errorEnvelopeFails() throws { + let message = try decode(#"{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"boom"}}"#) + switch JSONRPCTransport.routeResponse(message) { + case .failure(let error): + if let responseError = error as? RawMessage.ResponseError { + #expect(responseError.code == -32603) + #expect(responseError.message == "boom") + } else { + Issue.record("expected a ResponseError") + } + default: + Issue.record("expected a failure action") + } + } + + @Test("a notification (no id) is not routed to a parked request") + func notificationIsNotAResponse() throws { + let message = try decode(#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{}}"#) + switch JSONRPCTransport.routeResponse(message) { + case .none: + break + default: + Issue.record("a notification should not be routed as a response") + } + } +} + +// MARK: - Round-trip and crash behaviour + +@Suite("JSONRPCTransport process behaviour") +private struct ProcessBehaviourTests { + @Test("a response resumes the parked request") + func responseResumesRequest() async throws { + let transport = JSONRPCTransport() + await transport.startForTesting() + + let task = Task { try await transport.request(method: "m", params: nil) } + await waitForPending(transport) + + await transport.ingest(framed(#"{"jsonrpc":"2.0","id":1,"result":"ok"}"#)) + let data = try await task.value + #expect(String(data: data, encoding: .utf8) == #""ok""#) + } + + @Test("a server error is surfaced instead of an opaque decode failure") + func serverErrorThrows() async throws { + let transport = JSONRPCTransport() + await transport.startForTesting() + + let task = Task { try await transport.request(method: "m", params: nil) } + await waitForPending(transport) + + await transport.ingest(framed(#"{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"boom"}}"#)) + do { + _ = try await task.value + Issue.record("expected the request to throw") + } catch let error as RawMessage.ResponseError { + #expect(error.code == -32603) + #expect(error.message == "boom") + } catch { + Issue.record("expected a ResponseError, got \(error)") + } + } + + @Test("a server exit fails in-flight requests and notifies the disconnection handler") + func serverExitFailsRequests() async throws { + let transport = JSONRPCTransport() + await transport.startForTesting() + + let flag = Flag() + await transport.setDisconnectionHandler { flag.value = true } + + let task = Task { try await transport.request(method: "m", params: nil) } + await waitForPending(transport, 1) + + await transport.handleServerClosed() + + do { + _ = try await task.value + Issue.record("expected the request to throw") + } catch { + #expect(error is TransportError, "\(error) should be a TransportError") + } + #expect(flag.value) + #expect(await transport.isConnected == false) + } +} From 2bf4358f413031ea910dd89c800cbcda3592748f Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 20:53:36 +0300 Subject: [PATCH 2/2] feat(lsp): wire sourcekit-lsp completion into the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing called the transport or LSPClient until now (#1). This connects them to SourceEditor: - AppState owns the LSPClient lifecycle (start on launch, (re)connect on first request, disconnect on quit) so a dead server recovers instead of wedging the UI. - LSPTextViewCoordinator is bound in via CodeEditorView's appState param and completionDelegate. It filters the trigger prefix, fuzzy-matches + sortTexts via CompletionMatching, applies the server textEdit as replaceCharacters, debounces didChange to a 100ms full-content sync, and turns a server crash or an undecodable response into a single inline error row rather than a hang. - ClientCapabilities.default advertises static completion. sourcekit-lsp's dynamically-registered completion path emits structurally-invalid ~212KB JSON for some Array members (bare-string textEdit values, dangling "markdown" tokens, sortText carrying a docstring); static capabilities return the same items as compact, strictly-valid JSON. - JSONRPCTransport.repairMalformedJSON is a defensive fallback for that shape; it only rewrites `:"<...>"}` -> `:null`, so well-formed messages pass untouched. Verified: swift build (clean, pre-existing EditorState warning only), swift test 108/108 (30 suites; the sourcekit case is local-only, gated on the toolchain binary — CI cannot build/run it, see MEMORY.md #1), swift-format lint --strict clean. Refs #1 --- .../CodeEditorApp/Sources/AppDelegate.swift | 2 + .../Sources/MainWindowView.swift | 3 +- .../Document/Document+Completion.swift | 48 +++ .../Completion/CompletionMatching.swift | 50 +++ Sources/CodeEditorLSP/JSONRPCTransport.swift | 88 +++++- Sources/CodeEditorLSP/Types/LSPTypes.swift | 105 ++++++- Sources/CodeEditorUI/AppState.swift | 70 +++++ .../Sources/Editor/CodeEditorView.swift | 20 +- .../Editor/LSPTextViewCoordinator.swift | 294 ++++++++++++++++++ .../CompletionMatchingTests.swift | 131 ++++++++ .../DocumentCompletionTests.swift | 58 ++++ .../JSONRPCTransportTests.swift | 38 +++ .../LSPClientEndToEndTests.swift | 118 +++++++ .../ServerCapabilitiesTests.swift | 55 ++++ 14 files changed, 1060 insertions(+), 20 deletions(-) create mode 100644 Sources/CodeEditorCore/Sources/Document/Document+Completion.swift create mode 100644 Sources/CodeEditorLSP/Completion/CompletionMatching.swift create mode 100644 Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift create mode 100644 Tests/CodeEditorTests/CompletionMatchingTests.swift create mode 100644 Tests/CodeEditorTests/DocumentCompletionTests.swift create mode 100644 Tests/CodeEditorTests/LSPClientEndToEndTests.swift create mode 100644 Tests/CodeEditorTests/ServerCapabilitiesTests.swift diff --git a/Sources/CodeEditorApp/Sources/AppDelegate.swift b/Sources/CodeEditorApp/Sources/AppDelegate.swift index 2e969fd..d47caf5 100644 --- a/Sources/CodeEditorApp/Sources/AppDelegate.swift +++ b/Sources/CodeEditorApp/Sources/AppDelegate.swift @@ -45,11 +45,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // A cancelled panel on an untitled document means the work is still there. // Abandoning the quit is the only way not to throw it away. if await appState?.saveAllModified() == true { + appState?.disconnectLanguageServers() sender.terminate(nil) } else { isResolvingTermination = false } case .discard: + appState?.disconnectLanguageServers() sender.terminate(nil) case .cancel: isResolvingTermination = false diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index 8ae5734..2660cf0 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -162,7 +162,8 @@ struct MainWindowView: View { CodeEditorView( document: document, theme: appState.theme, - editorState: appState.editorState + editorState: appState.editorState, + appState: appState ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { diff --git a/Sources/CodeEditorCore/Sources/Document/Document+Completion.swift b/Sources/CodeEditorCore/Sources/Document/Document+Completion.swift new file mode 100644 index 0000000..5552004 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/Document/Document+Completion.swift @@ -0,0 +1,48 @@ +import Foundation + +extension Document { + /// LSP position (0-indexed line; 0-indexed character in UTF-16 code units) for a UTF-8 + /// content offset. + /// + /// `lineAndColumn(for:)` works in UTF-8 bytes; LSP wants UTF-16 code units for + /// `character`. They coincide for ASCII source, which covers every identifier the + /// completion popup is offered on. Non-ASCII content (e.g. accented letters in strings) + /// is a known v1 limitation. + public func lspPosition(for offset: Int) -> (line: Int, character: Int)? { + guard let (line, column) = lineAndColumn(for: offset), + lines.indices.contains(line) + else { return nil } + let lineText = lines[line] + let utf16 = lineText.prefix(column).utf16.count + return (line, utf16) + } + + /// The contiguous run of identifier characters (letters, digits, `_`) ending at + /// `offset` — the text the user is mid-word typing. Returned as a UTF-16 range (the + /// unit the text view addresses by); `nil` when no identifier character sits + /// immediately before `offset`. + /// + /// `offset` is a UTF-8 offset treated as a UTF-16 index here; for ASCII source these + /// are the same value, which is the contract honoured throughout the completion path. + public func completionPrefixRange(at offset: Int) -> NSRange? { + let source = content + guard offset > 0, offset <= source.utf16.count else { return nil } + var start = offset + while start > 0 { + let index = source.index(source.startIndex, offsetBy: start - 1) + let char = source[index] + if char.isASCII, char.isNumber || char.isLetter || String(char) == "_" { + start -= 1 + } else { + break + } + } + return start < offset ? NSRange(location: start, length: offset - start) : nil + } + + /// The prefix the user is typing at `offset`, for fuzzy filtering of completions. + public func completionPrefix(at offset: Int) -> String { + guard let range = completionPrefixRange(at: offset) else { return "" } + return (content as NSString).substring(with: range) + } +} diff --git a/Sources/CodeEditorLSP/Completion/CompletionMatching.swift b/Sources/CodeEditorLSP/Completion/CompletionMatching.swift new file mode 100644 index 0000000..7dca3c0 --- /dev/null +++ b/Sources/CodeEditorLSP/Completion/CompletionMatching.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Case- and diacritic-insensitive subsequence (fuzzy) match. +/// +/// A completion labeled `forEachPrefix(_:)` matches the query `ffp` because its +/// characters appear in order. Used to narrow the server's list as the user types, +/// mirroring the trigger behaviour upstream expects from a `CompletionMode`. +public func fuzzyMatch(_ query: String, _ label: String) -> Bool { + let query = query.lowercased() + let label = label.lowercased() + guard !query.isEmpty else { return true } + var scan = label.startIndex + for char in query { + guard let range = label[scan...].range(of: String(char)) else { return false } + scan = range.upperBound + } + return true +} + +extension CompletionItem { + /// The string used to filter and rank an item. Servers may set `filterText` to a + /// substring of the label; absent that we match against the label itself. + public var filterLabel: String { filterText ?? label } + + /// The value `sortText` when the server supplied it, otherwise the filter label. + /// sourcekit-lsp emits `sortText`; other servers may not, in which case we fall back + /// to the label so ordering stays deterministic. + public var sortKey: String { sortText ?? filterLabel } +} + +/// Order completions the way a stable, predictable popup would: by the server's +/// `sortText` first, then by label. +public func completionOrdered(_ lhs: CompletionItem, _ rhs: CompletionItem) -> Bool { + let l = lhs.sortKey + let r = rhs.sortKey + if l != r { return l < r } + return lhs.filterLabel < rhs.filterLabel +} + +/// Filter `items` to those matching `query` (empty query matches all) and rank them. +/// +/// - Parameter query: what the user typed at the caret. Computed by the caller from +/// the document so this function stays a pure, side-effect-free ranking step that is +/// cheap enough to run on every cursor move. +/// - Returns: the surviving items in display order. +public func rankedCompletions(_ items: [CompletionItem], query: String) -> [CompletionItem] { + items + .filter { fuzzyMatch(query, $0.filterLabel) } + .sorted(by: completionOrdered) +} diff --git a/Sources/CodeEditorLSP/JSONRPCTransport.swift b/Sources/CodeEditorLSP/JSONRPCTransport.swift index 937f5df..2eb5b0c 100644 --- a/Sources/CodeEditorLSP/JSONRPCTransport.swift +++ b/Sources/CodeEditorLSP/JSONRPCTransport.swift @@ -1,7 +1,5 @@ import Foundation -// MARK: - Transport errors - public enum TransportError: Error, LocalizedError { case notConnected case processLaunchFailed(String) @@ -48,6 +46,11 @@ public actor JSONRPCTransport { private let encoder = JSONEncoder() private let decoder = JSONDecoder() + /// Bound a single request so a slow or wedged server can never park the editor + /// indefinitely. sourcekit-lsp answers `initialize`/`completion` in well under a + /// second in practice; 10s is generous headroom. + private static let requestTimeoutNanoseconds: UInt64 = 10 * 1_000_000_000 + public init() {} public var isConnected: Bool { isRunning } @@ -188,8 +191,42 @@ public actor JSONRPCTransport { return body } + /// Attempt to recover from a structurally-invalid sourcekit-lsp JSON message. + /// + /// sourcekit-lsp occasionally emits a `textEdit` (and, rarely, other) value as an + /// object wrapping a bare docstring, e.g. `"textEdit":{"ran at the \`start\`…"}`. + /// That is invalid JSON — an object key with no `:`/value — and makes `JSONDecoder` + /// reject the *entire* message, silently dropping the completion response. We rewrite + /// any `:{""}` value (with no key/value pair inside) to `:null`, which only + /// ever matches structurally-invalid JSON, so well-formed messages are untouched. + internal static func repairMalformedJSON(_ body: Data) -> Data? { + guard let json = String(data: body, encoding: .utf8) else { return nil } + let pattern = #":\{\s*"[^"\\]*(?:\\.[^"\\]*)*"\s*\}"# + let repaired = json.replacingOccurrences( + of: pattern, with: ":null", options: .regularExpression + ) + guard repaired != json else { return nil } + return repaired.data(using: .utf8) + } + + /// Decode an inbound message, repairing structurally-invalid JSON that + /// sourcekit-lsp occasionally emits (see `repairMalformedJSON`). + private func decodeMessage(_ body: Data) -> RawMessage? { + if let message = try? decoder.decode(RawMessage.self, from: body) { + return message + } + if let repaired = Self.repairMalformedJSON(body), + let message = try? decoder.decode(RawMessage.self, from: repaired) + { + return message + } + return nil + } + private func handle(_ body: Data) { - guard let message = try? decoder.decode(RawMessage.self, from: body) else { return } + guard let message = decodeMessage(body) else { + return + } if let id = message.id { // A response to a request we sent. @@ -205,14 +242,39 @@ public actor JSONRPCTransport { return } + // Server -> client: either a request (string id + method) or a notification (method only). if let method = message.method { - let params = message.result ?? .null + if let stringID = message.stringID { + // Acknowledge inbound requests we don't implement (e.g. + // `client/registerCapability`). Echoing `null` tells the server "handled"; + // it then relies on its statically-declared capabilities instead of blocking + // the session waiting for a reply we'd otherwise never send. + sendResponse(requestID: stringID, result: JSONValue.null) + } + let params = message.params ?? .null for handler in notificationHandlers { handler(method, params) } } } + /// Reply to a server-initiated request we don't implement, so the server doesn't block + /// waiting for a response. `requestID` is echoed verbatim from the inbound request + /// (sourcekit-lsp uses string ids like `"sk-0"`), and `result` is normally `.null`, + /// meaning "handled / no result". + private struct ClientResponse: Encodable { + let jsonrpc = "2.0" + let id: String + let result: JSONValue + } + + private func sendResponse(requestID: String, result: JSONValue) { + guard let payload = try? encoder.encode(ClientResponse(id: requestID, result: result)) else { + return + } + write(payload) + } + /// Decide what a parked request continuation resumes with for one decoded message. /// /// - A `result` resumes the caller with that value (JSON-encoded). @@ -283,10 +345,26 @@ public actor JSONRPCTransport { return try await withCheckedThrowingContinuation { continuation in pending[id] = continuation - Task { await self.write(payload) } + write(payload) + // Fail the request if the server never answers, so a wedged process can't + // park the caller forever. If a response arrives first, `handle` has already + // removed the entry and this becomes a no-op. + Task { + try? await Task.sleep(nanoseconds: Self.requestTimeoutNanoseconds) + await self.failRequest(id: id) + } } } + /// Resume and remove a request that exceeded the deadline. Actor-isolated so the + /// `pending` access can't race with `handle`/`stop`. `async` so a caller on a detached + /// `Task` can `await` the actor hop without tripping a "no async operations" warning. + private func failRequest(id: Int) async { + guard let continuation = pending[id] else { return } + pending.removeValue(forKey: id) + continuation.resume(throwing: TransportError.requestFailed("language server did not respond in time")) + } + /// Send a request and decode the `result` field. public func request(method: String, params: (any Encodable)?, as type: T.Type) async throws -> T { let data = try await request(method: method, params: params) diff --git a/Sources/CodeEditorLSP/Types/LSPTypes.swift b/Sources/CodeEditorLSP/Types/LSPTypes.swift index e99e880..4861ec0 100644 --- a/Sources/CodeEditorLSP/Types/LSPTypes.swift +++ b/Sources/CodeEditorLSP/Types/LSPTypes.swift @@ -95,6 +95,17 @@ public enum CompletionItemKind: Int, Codable, CaseIterable, Sendable { case reference = 19 case folderReference = 20 case constant = 21 + /// Fallback for kind values the server emits that aren't enumerated here yet + /// (sourcekit-lsp sends values up to ~26 for `struct`/`enumMember`/…). Mapping to + /// `.other` keeps decoding from failing so a single unknown item never drops the + /// whole completion list. Its icon falls back to the default. + case other = 22 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(Int.self) + self = Self(rawValue: raw) ?? .other + } } public struct MarkupContent: Codable, Equatable, Sendable { @@ -163,7 +174,10 @@ public struct TextEdit: Codable, Equatable, Sendable { } } -/// Servers may answer with either a bare array or a `CompletionList`. +/// Servers may answer with either a bare array of completion items or a +/// `CompletionList` (`{ isIncomplete, items }`). The object form may omit +/// `isIncomplete`, and sourcekit-lsp attaches fields we don't model (`deprecated`, +/// `data`, `insertTextFormat`, …) — those are simply ignored. public struct CompletionList: Codable, Sendable { public var isIncomplete: Bool public var items: [CompletionItem] @@ -174,11 +188,28 @@ public struct CompletionList: Codable, Sendable { } public init(from decoder: Decoder) throws { - if let list = try? decoder.singleValueContainer().decode(CompletionList.self) { - self = list + let container = try decoder.singleValueContainer() + // A CompletionList object (`{"isIncomplete":…,"items":[…]}`) decodes first. + if let object = try? container.decode(CompletionListObject.self) { + self.isIncomplete = object.isIncomplete + self.items = object.items return } - self.init(isIncomplete: false, items: try decoder.singleValueContainer().decode([CompletionItem].self)) + // Otherwise assume a bare `[CompletionItem]` array. + self.isIncomplete = false + self.items = try container.decode([CompletionItem].self) + } + + /// Internal shape for the object form; `isIncomplete` is optional on the wire. + private struct CompletionListObject: Decodable { + let isIncomplete: Bool + let items: [CompletionItem] + enum CodingKeys: String, CodingKey { case isIncomplete, items } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + isIncomplete = try c.decodeIfPresent(Bool.self, forKey: .isIncomplete) ?? false + items = try c.decode([CompletionItem].self, forKey: .items) + } } } @@ -314,7 +345,14 @@ public struct CompletionOptions: Codable, Sendable { } } -public struct ServerCapabilities: Codable, Sendable { +/// Server capabilities, decoded leniently. +/// +/// The LSP spec lets several providers be either a bare `Bool`/`Int` or a richer options +/// object (e.g. `textDocumentSync` is `TextDocumentSyncKind | TextDocumentSyncOptions`, +/// `signatureHelpProvider` is `Bool | SignatureHelpOptions`). Servers — including +/// sourcekit-lsp — send the object form. Decoding each field with `try?` means a shape we +/// don't model yet becomes `nil` instead of failing the whole `initialize` handshake. +public struct ServerCapabilities: Decodable, Sendable { public var textDocumentSync: Int? public var completionProvider: CompletionOptions? public var hoverProvider: Bool? @@ -350,6 +388,26 @@ public struct ServerCapabilities: Codable, Sendable { self.workspaceSymbolProvider = workspaceSymbolProvider } + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + textDocumentSync = try? container.decode(Int.self, forKey: .textDocumentSync) + completionProvider = try? container.decode(CompletionOptions.self, forKey: .completionProvider) + hoverProvider = try? container.decode(Bool.self, forKey: .hoverProvider) + signatureHelpProvider = try? container.decode([String].self, forKey: .signatureHelpProvider) + referencesProvider = try? container.decode(Bool.self, forKey: .referencesProvider) + documentSymbolProvider = try? container.decode(Bool.self, forKey: .documentSymbolProvider) + formattingProvider = try? container.decode(Bool.self, forKey: .formattingProvider) + definitionProvider = try? container.decode(Bool.self, forKey: .definitionProvider) + codeActionProvider = try? container.decode(Bool.self, forKey: .codeActionProvider) + workspaceSymbolProvider = try? container.decode(Bool.self, forKey: .workspaceSymbolProvider) + } + + enum CodingKeys: String, CodingKey { + case textDocumentSync, completionProvider, hoverProvider, signatureHelpProvider + case referencesProvider, documentSymbolProvider, formattingProvider, definitionProvider + case codeActionProvider, workspaceSymbolProvider + } + public var supportsCompletion: Bool { completionProvider != nil } public var supportsHover: Bool { hoverProvider ?? false } public var supportsDefinition: Bool { definitionProvider ?? false } @@ -476,7 +534,16 @@ public struct ClientCapabilities: Encodable, Sendable { self.workspace = workspace } - public static let `default` = ClientCapabilities() + /// Capabilities advertised during `initialize`. Completion is *static* + /// (`dynamicRegistration: false`): sourcekit-lsp's dynamically-registered completion + /// path emits structurally-invalid JSON for some items (e.g. a `textEdit` value that is + /// a bare `{}`, stray `"markdown"` tokens, `sortText` carrying a docstring), + /// which makes `JSONDecoder` reject the whole 200KB response. With a static + /// `completionProvider` capability sourcekit serves the same completions as compact, + /// strictly-valid JSON. See docs/MEMORY.md #20. + public static let `default` = ClientCapabilities( + textDocument: .init(completion: .init(dynamicRegistration: false)) + ) } public struct InitializeResult: Decodable, Sendable { @@ -515,11 +582,20 @@ struct AnyEncodable: Encodable { } } -/// Anything the transport can decode, to route responses and notifications. +/// Anything the transport can decode, to route responses, requests and notifications. +/// +/// LSP request ids can be a number or a string: outgoing requests use ints (`1`, `2`, …) +/// and some servers — sourcekit-lsp included — initiate their own requests with string +/// ids (`"sk-0"`, `"sk-1"`, …). Every field is decoded leniently so that a shape we don't +/// model yet can never cause a message to be silently dropped. struct RawMessage: Decodable { + /// Numeric id — present for responses to requests we sent. let id: Int? + /// String id — present for server-initiated requests (e.g. `client/registerCapability`). + let stringID: String? let method: String? let result: JSONValue? + let params: JSONValue? let error: ResponseError? struct ResponseError: Decodable, Error { @@ -530,15 +606,20 @@ struct RawMessage: Decodable { } private enum CodingKeys: String, CodingKey { - case id, method, result, error + case id, method, result, params, error } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeIfPresent(Int.self, forKey: .id) - method = try container.decodeIfPresent(String.self, forKey: .method) - result = try container.decodeIfPresent(JSONValue.self, forKey: .result) - error = try container.decodeIfPresent(ResponseError.self, forKey: .error) + // `try?` per field: a string id is not an `Int`, a numeric id is not a `String`, + // and an absent key decodes to `nil` rather than throwing. This keeps a server + // request like `{"id":"sk-0",...}` from failing the whole `RawMessage`. + id = try? container.decode(Int.self, forKey: .id) + stringID = try? container.decode(String.self, forKey: .id) + method = try? container.decodeIfPresent(String.self, forKey: .method) + result = try? container.decodeIfPresent(JSONValue.self, forKey: .result) + params = try? container.decodeIfPresent(JSONValue.self, forKey: .params) + error = try? container.decodeIfPresent(ResponseError.self, forKey: .error) } } diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 2d9b59f..bc07088 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -1,4 +1,5 @@ import CodeEditorCore +import CodeEditorLSP import CodeEditorTerminal import CodeEditorThemes import SwiftUI @@ -92,6 +93,7 @@ public final class AppState { public func openFolder(at url: URL) { fileSystemManager.setRoot(url) + disconnectLanguageServers() terminal.disconnect() terminal.workingDirectory = url terminal.clear() @@ -359,6 +361,74 @@ public final class AppState { public func setTheme(named name: String) { themeManager.setTheme(named: name) } + + // MARK: - Language servers + + /// One lazily-started server per language identifier for the current workspace. + /// Clients are `nil` once a crash has been observed; they are reconnected on the + /// next `languageServer(for:)` call, after the backoff cooldown. + @ObservationIgnored + private var languageServers: [String: LSPClient] = [:] + + /// Last time a server for a language failed to start, for the backoff cooldown. + @ObservationIgnored + private var serverFailedAt: [String: Date] = [:] + + /// The minimum cool-down between reconnect attempts after a failure, to avoid + /// respawning a crashing server on every keystroke. + private static let reconnectCooldown: TimeInterval = 5.0 + + /// The language server for `languageId`, starting it on first use for the current + /// workspace. Reconnects after a crash once the cooldown has elapsed. + /// + /// Returns `nil` when no server is registered for the language, the workspace has no + /// root, the server failed to initialize (its `lastError` is surfaced via + /// `errorMessage`), or a recent failure is still inside the cool-down window. + public func languageServer(for languageId: String) async -> LSPClient? { + if let existing = languageServers[languageId], existing.isReady { + return existing + } + + // In the cool-down window after a failure, don't respawn. + if let last = serverFailedAt[languageId], + Date().timeIntervalSince(last) < Self.reconnectCooldown + { + return nil + } + serverFailedAt[languageId] = nil + + // Drop a crashed/stale client so we can reconnect. + if let existing = languageServers[languageId], !existing.isReady { + Task { await existing.disconnect() } + languageServers[languageId] = nil + } + + guard let descriptor = LanguageServerRegistry.descriptor(for: languageId) else { + return nil + } + + let client = LSPClient(descriptor: descriptor, rootURL: fileSystemManager.rootURL) + await client.connect() + if client.isReady { + languageServers[languageId] = client + errorMessage = nil + return client + } else { + serverFailedAt[languageId] = Date() + errorMessage = client.lastError ?? "Could not start \(descriptor.displayName)." + return nil + } + } + + /// Tear down every language server. Call on workspace switch and on quit. + public func disconnectLanguageServers() { + let clients = Array(languageServers.values) + languageServers.removeAll() + serverFailedAt.removeAll() + for client in clients { + Task { await client.disconnect() } + } + } } // MARK: - DocumentManagerDelegate diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index 8757f19..b4109b2 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -11,14 +11,27 @@ public struct CodeEditorView: View { @Bindable var document: CodeEditorCore.Document let theme: Theme let editorState: EditorState + let appState: AppState @State private var sourceEditorState = SourceEditorState() @State private var textCoordinator = DocumentTextCoordinator() + @State private var lspCoordinator: LSPTextViewCoordinator - public init(document: CodeEditorCore.Document, theme: Theme, editorState: EditorState) { + public init( + document: CodeEditorCore.Document, + theme: Theme, + editorState: EditorState, + appState: AppState + ) { self.document = document self.theme = theme self.editorState = editorState + self.appState = appState + _lspCoordinator = State( + initialValue: LSPTextViewCoordinator( + document: document, theme: theme, appState: appState + ) + ) } public var body: some View { @@ -28,7 +41,8 @@ public struct CodeEditorView: View { configuration: configuration, state: $sourceEditorState, undoManager: undoManager, - coordinators: [textCoordinator] + coordinators: [textCoordinator, lspCoordinator], + completionDelegate: lspCoordinator ) .background(theme.background.color) .onChange(of: sourceEditorState.cursorPositions) { _, positions in @@ -39,10 +53,12 @@ public struct CodeEditorView: View { // and highlight providers. Without this, switching tabs keeps the previous // document on screen. textCoordinator.reload(text: document.content) + Task { await lspCoordinator.switchTo(document: document) } editorState.syncSelection(of: document) } .task { editorState.syncSelection(of: document) + await lspCoordinator.switchTo(document: document) } // The panel reports its own visibility; that is the authoritative signal, // so this writes back and the forward sync below becomes a no-op. diff --git a/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift new file mode 100644 index 0000000..fe0f8a1 --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Editor/LSPTextViewCoordinator.swift @@ -0,0 +1,294 @@ +import CodeEditSourceEditor +import CodeEditTextView +import CodeEditorCore +import CodeEditorLSP +import CodeEditorThemes +import SwiftUI + +/// A `CodeSuggestionEntry` backed by a single LSP `CompletionItem`, plus a sentinel +/// "error" entry that renders an inline crash/no-server message in the popup. +@MainActor +final class LSPCompletionItem: CodeSuggestionEntry { + /// The source `CompletionItem`. `nil` for the synthetic error entry. + let completionItem: CompletionItem? + let isError: Bool + private let fallbackLabel: String? + + init(_ item: CompletionItem) { + self.completionItem = item + self.isError = false + self.fallbackLabel = nil + } + + init(error message: String) { + self.completionItem = nil + self.isError = true + self.fallbackLabel = message + } + + // MARK: - CodeSuggestionEntry + + var label: String { completionItem?.label ?? fallbackLabel ?? "error" } + var detail: String? { + completionItem?.detail ?? (isError ? "SourceKit-LSP" : nil) + } + + var documentation: String? { completionItem?.documentation?.value } + var pathComponents: [String]? { nil } + var targetPosition: CursorPosition? { nil } + var sourcePreview: String? { nil } + + var image: Image { Image(systemName: iconSymbol) } + var imageColor: Color { isError ? .red : iconColor } + var deprecated: Bool { false } + + // MARK: - Icons + + private var iconSymbol: String { + guard let kind = completionItem?.kind else { return "curlybraces" } + switch kind { + case .method: return "chevron.left.slash.chevron.right" + case .function, .constructor: return "function" + case .class: return "cube" + case .interface: return "cube.transparent" + case .enum: return "cube.fill" + case .module: return "folder" + case .property: return "key.rhs" + case .field: return "tag" + case .variable, .constant: return "cube" + case .value: return "number" + case .keyword: return "textformat" + case .file: return "doc" + case .folder: return "folder" + case .reference: return "book" + case .unit: return "123" + default: return "curlybraces" + } + } + + private var iconColor: Color { + guard let kind = completionItem?.kind else { return .secondary } + switch kind { + case .function, .method, .constructor: return .orange + case .class, .interface, .enum: return .purple + case .keyword: return .blue + case .variable, .constant, .field, .property: return .green + case .value, .unit: return .red + case .module, .file, .folder, .reference: return .secondary + default: return .secondary + } + } +} + +// MARK: - Coordinator + +/// Wires a language server into a `TextViewController` so completions and edits flow to it. +/// +/// The upstream `SuggestionController` owns the popup chrome and keyboard handling +/// (Escape to close, ↑/↓/Tab/Enter to accept, double-click). It only calls into this +/// coordinator through `CodeSuggestionDelegate` for the items and the apply step — no +/// bespoke SwiftUI popup is needed, see [docs/MEMORY.md](../../../docs/MEMORY.md). +@MainActor +final class LSPTextViewCoordinator: TextViewCoordinator, TextViewDelegate, + CodeSuggestionDelegate +{ + private weak var controller: TextViewController? + private let theme: Theme + private weak var appState: AppState? + + private var document: CodeEditorCore.Document + private var didOpen = false + private var currentURI: String? + + /// Items from the most recent successful completion request, for prefix re-filtering + /// on cursor move without another round-trip. + private var lastCompletionItems: [CompletionItem]? + /// Increments with each in-flight request; responses from a previous generation are + /// discarded so a slow server can't paint a newer popup with stale items. + private var completionGeneration = 0 + private var crashShown = false + + /// Coalesces rapid edits into a single `textDocument/didChange` per burst. + private var didChangeTask: Task? + + init(document: CodeEditorCore.Document, theme: Theme, appState: AppState) { + self.document = document + self.theme = theme + self.appState = appState + } + + // MARK: - TextViewCoordinator + + func prepareCoordinator(controller: TextViewController) { + self.controller = controller + Task { await openIfNeeded() } + } + + func destroy() { + controller = nil + } + + // MARK: - Tab / workspace switch + + /// Switch the coordinator to a different document (tab switch): closes the previous + /// document with the server and opens the new one. + func switchTo(document: CodeEditorCore.Document) async { + guard self.document !== document else { return } + + if let uri = currentURI, + let client = await appState?.languageServer(for: document.language.identifier), + client.isReady + { + await client.closeDocument(uri: uri) + } + + self.document = document + didOpen = false + lastCompletionItems = nil + crashShown = false + currentURI = nil + await openIfNeeded() + } + + // MARK: - Document sync + + /// `didOpen` on first appearance, for on-disk documents only (untitled docs have no + /// URI; sourcekit-lsp can't anchor a position to them). + private func openIfNeeded() async { + guard !didOpen, let url = document.url else { return } + let uri = url.absoluteString + guard let client = await appState?.languageServer(for: document.language.identifier), + client.isReady + else { return } + currentURI = uri + await client.openDocument(uri: uri, languageId: document.language.identifier, content: document.content) + didOpen = true + } + + /// Forward a keystroke to the server, debounced so a fast typist sends one + /// `didChange` per burst instead of one per character. + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { + guard controller != nil else { return } + didChangeTask?.cancel() + didChangeTask = Task { + try? await Task.sleep(nanoseconds: 100_000_000) + guard !Task.isCancelled, + let uri = self.currentURI, + let client = await self.appState?.languageServer(for: self.document.language.identifier), + client.isReady + else { return } + await client.updateDocument(uri: uri, content: textView.string) + } + } + + // MARK: - CodeSuggestionDelegate + + func completionTriggerCharacters() -> Set { ["."] } + + /// Fetch completions at the caret, rank them, and present. A stale in-flight request + /// is cancelled first so only the latest trigger wins. + func completionSuggestionsRequested( + textView: TextViewController, + cursorPosition: CursorPosition + ) async -> (windowPosition: CursorPosition, items: [CodeSuggestionEntry])? { + completionGeneration += 1 + let generation = completionGeneration + didChangeTask?.cancel() + + // Only on-disk Swift documents have a server; anything else stays quiet. + guard LanguageServerRegistry.descriptor(for: document.language.identifier) != nil else { return nil } + guard let uri = document.url?.absoluteString else { return nil } + guard let client = await appState?.languageServer(for: document.language.identifier), + client.isReady + else { + // `languageServer(for:)` already set `errorMessage` to a user-facing reason. + let reason = appState?.errorMessage ?? "SourceKit-LSP could not start." + return (cursorPosition, [LSPCompletionItem(error: reason)]) + } + + guard let caret = offset(for: cursorPosition), + let pos = document.lspPosition(for: caret) + else { return nil } + + do { + let items = try await client.completion( + at: CodeEditorLSP.Position(line: pos.line, character: pos.character), uri: uri + ) + guard generation == self.completionGeneration else { return nil } + self.lastCompletionItems = items + self.crashShown = false + let query = document.completionPrefix(at: caret) + let ranked = rankedCompletions(items, query: query).map(LSPCompletionItem.init) + return (cursorPosition, ranked) + } catch { + self.lastCompletionItems = nil + guard generation == self.completionGeneration, !crashShown else { return nil } + crashShown = true + let message = (error as NSError).localizedDescription + let reason = message.isEmpty ? "SourceKit-LSP stopped responding." : message + return (cursorPosition, [LSPCompletionItem(error: reason)]) + } + } + + /// Re-filter the last results to the new caret prefix. Returning `nil` closes the popup. + func completionOnCursorMove( + textView: TextViewController, + cursorPosition: CursorPosition + ) -> [CodeSuggestionEntry]? { + guard let items = lastCompletionItems, !items.isEmpty else { return nil } + guard let caret = offset(for: cursorPosition) else { return nil } + let prefix = document.completionPrefix(at: caret) + guard !prefix.isEmpty else { return nil } + let ranked = rankedCompletions(items, query: prefix).map(LSPCompletionItem.init) + return ranked.isEmpty ? nil : ranked + } + + /// Replace the identifier prefix (or the `TextEdit` range) with the item's + /// `insertionText`. The text view advances the caret and the binding writes the + /// new content back into the `Document`, so the document stays the single source + /// of truth and no view reload is required. + func completionWindowApplyCompletion( + item: CodeSuggestionEntry, + textView: TextViewController, + cursorPosition: CursorPosition? + ) { + guard let entry = item as? LSPCompletionItem, !entry.isError, + let completionItem = entry.completionItem, + let tv = controller?.textView + else { return } + + let ns = tv.string as NSString + let caret = offset(for: cursorPosition) ?? ns.length + + let range: NSRange + if let textEdit = completionItem.textEdit { + let startPosition = textEdit.range.start + let endPosition = textEdit.range.end + let start = document.offset(forLine: startPosition.line, column: startPosition.character) ?? caret + let end = document.offset(forLine: endPosition.line, column: endPosition.character) ?? caret + range = NSRange(location: start, length: max(0, end - start)) + } else if let prefixRange = document.completionPrefixRange(at: caret) { + range = prefixRange + } else { + range = NSRange(location: caret, length: 0) + } + + tv.replaceCharacters(in: range, with: completionItem.insertionText) + } + + func completionWindowDidClose() { + lastCompletionItems = nil + crashShown = false + } + + // MARK: - Helpers + + /// 1-indexed `CursorPosition` → UTF-8 content offset (ASCII source: == UTF-16). + private func offset(for cursorPosition: CursorPosition?) -> Int? { + guard let cursorPosition else { return nil } + return document.offset( + forLine: cursorPosition.start.line - 1, + column: cursorPosition.start.column - 1 + ) + } +} diff --git a/Tests/CodeEditorTests/CompletionMatchingTests.swift b/Tests/CodeEditorTests/CompletionMatchingTests.swift new file mode 100644 index 0000000..ec9ab6c --- /dev/null +++ b/Tests/CodeEditorTests/CompletionMatchingTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing + +@testable import CodeEditorLSP + +private func item( + _ label: String, + filterText: String? = nil, + sortText: String? = nil, + kind: CompletionItemKind? = nil +) -> CompletionItem { + CompletionItem(label: label, kind: kind, sortText: sortText, filterText: filterText) +} + +@Suite("Completion matching") +struct CompletionMatchingTests { + @Suite("fuzzyMatch") + struct FuzzyMatch { + @Test("empty query matches anything") + func emptyQuery() { + #expect(fuzzyMatch("", "anything") == true) + #expect(fuzzyMatch("", "") == true) + } + + @Test("subsequence match is case-insensitive") + func subsequence() { + #expect(fuzzyMatch("fp", "forEachPrefix(_:)") == true) + #expect(fuzzyMatch("FP", "forEachPrefix(_:)") == true) + #expect(fuzzyMatch("fxp", "forEachPrefix(_:)") == false) + } + + @Test("missing characters fail") + func missingCharacter() { + #expect(fuzzyMatch("zzz", "forEachPrefix(_:)") == false) + #expect(fuzzyMatch("abc", "abc") == true) + #expect(fuzzyMatch("abcd", "abc") == false) + } + + @Test("filterText is matched, not label, when present") + func filterTextPreferred() { + // `filterText` differs from `label`; the label alone would fail this query. + #expect(fuzzyMatch("idx", item("Index", filterText: "idx").filterLabel)) + } + } + + @Suite("rankedCompletions") + struct RankedCompletions { + @Test("empty query keeps every item") + func emptyQueryKeepsAll() { + let ranked = rankedCompletions([item("a"), item("b"), item("c")], query: "") + #expect(ranked.map(\.label) == ["a", "b", "c"]) + } + + @Test("non-matching items are dropped") + func filtering() { + let ranked = rankedCompletions([item("map"), item("flatMap"), item("reduce")], query: "flat") + #expect(ranked.map(\.label) == ["flatMap"]) + } + + @Test("order by sortText then label") + func sortTextOrdering() { + let ranked = rankedCompletions( + [ + item("b", sortText: "2"), + item("a", sortText: "2"), + item("c", sortText: "1"), + ], + query: "" + ) + #expect(ranked.map(\.label) == ["c", "a", "b"]) + } + + @Test("sortText fallback to label when absent") + func sortTextFallback() { + let ranked = rankedCompletions([item("map"), item("flatMap")], query: "") + #expect(ranked.map(\.label) == ["flatMap", "map"]) + } + + @Test("filtering is a subsequence, not a prefix") + func subsequenceFilter() { + // "fp" matches "forEachPrefix" (f, then p in "Prefix") but not "map" (no f). + let ranked = rankedCompletions([item("forEachPrefix"), item("map")], query: "fp") + #expect(ranked.map(\.label) == ["forEachPrefix"]) + } + } + + @Suite("completionOrdered") + struct CompletionOrdered { + @Test("stable when sortText ties") + func tieBreakByLabel() { + let sorted = [item("b", sortText: "1"), item("a", sortText: "1")].sorted(by: completionOrdered) + #expect(sorted.map(\.label) == ["a", "b"]) + } + } + + @Suite("CompletionList decoding") + struct CompletionListDecoding { + private func decode(_ json: String) throws -> CompletionList { + try JSONDecoder().decode(CompletionList.self, from: Data(json.utf8)) + } + + @Test("bare array form") + func bareArray() throws { + let list = try decode(#"[{"label":"count"},{"label":"isEmpty"}]"#) + #expect(list.isIncomplete == false) + #expect(list.items.map(\.label) == ["count", "isEmpty"]) + } + + @Test("object form with isIncomplete") + func objectForm() throws { + let list = try decode(#"{"isIncomplete":true,"items":[{"label":"count"}]}"#) + #expect(list.isIncomplete == true) + #expect(list.items.map(\.label) == ["count"]) + } + + @Test("object form without isIncomplete") + func objectFormMissingIncomplete() throws { + let list = try decode(#"{"items":[{"label":"count"}]}"#) + #expect(list.isIncomplete == false) + #expect(list.items.map(\.label) == ["count"]) + } + + @Test("object form tolerates extra fields sourcekit attaches") + func extraFieldsTolerated() throws { + // sourcekit-lsp emits `deprecated`, `data`, `kind` etc. as extra fields. + let json = #"{"items":[{"label":"isEmpty","kind":22,"deprecated":false,"data":{"sessionId":0}}]}"# + let list = try decode(json) + #expect(list.items.map(\.label) == ["isEmpty"]) + } + } +} diff --git a/Tests/CodeEditorTests/DocumentCompletionTests.swift b/Tests/CodeEditorTests/DocumentCompletionTests.swift new file mode 100644 index 0000000..ad8f04b --- /dev/null +++ b/Tests/CodeEditorTests/DocumentCompletionTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import CodeEditorCore + +@MainActor +@Suite("Document completion positions") +struct DocumentCompletionTests { + @Suite("lspPosition") + @MainActor + struct LspPosition { + @Test("offset maps to zero-indexed line and UTF-16 character") + func singleLine() { + let doc = Document(content: "func foo()") + let p0 = doc.lspPosition(for: 0) + let p5 = doc.lspPosition(for: 5) + let p8 = doc.lspPosition(for: 8) + let pEnd = doc.lspPosition(for: doc.content.utf8.count) + #expect(p0?.line == 0 && p0?.character == 0) + #expect(p5?.line == 0 && p5?.character == 5) + #expect(p8?.line == 0 && p8?.character == 8) + #expect(pEnd?.line == 0 && pEnd?.character == 10) + } + + @Test("multi-line offsets land on the right line with a fresh column") + func multiLine() { + let doc = Document(content: "ab\ncd") + let p0 = doc.lspPosition(for: 0) + let p2 = doc.lspPosition(for: 2) + let p3 = doc.lspPosition(for: 3) + let p5 = doc.lspPosition(for: 5) + #expect(p0?.line == 0 && p0?.character == 0) + #expect(p2?.line == 0 && p2?.character == 2) + #expect(p3?.line == 1 && p3?.character == 0) + #expect(p5?.line == 1 && p5?.character == 2) + } + } + + @Suite("completionPrefix") + @MainActor + struct CompletionPrefix { + @Test("trailing identifier run before the caret") + func identifierRun() { + let doc = Document(content: "func foo()") + #expect(doc.completionPrefix(at: 0) == "") + #expect(doc.completionPrefix(at: 5) == "") + #expect(doc.completionPrefix(at: 8) == "foo") + #expect(doc.completionPrefix(at: 9) == "") + } + + @Test("crosses line boundaries is not expected: stops at end of line") + func stopsAtLineEnd() { + let doc = Document(content: "foo\nbar") + #expect(doc.completionPrefix(at: 3) == "foo") + #expect(doc.completionPrefix(at: 4) == "") + } + } +} diff --git a/Tests/CodeEditorTests/JSONRPCTransportTests.swift b/Tests/CodeEditorTests/JSONRPCTransportTests.swift index 7fd0af7..75cb5b9 100644 --- a/Tests/CodeEditorTests/JSONRPCTransportTests.swift +++ b/Tests/CodeEditorTests/JSONRPCTransportTests.swift @@ -154,6 +154,44 @@ private struct ResponseRoutingTests { Issue.record("a notification should not be routed as a response") } } + + @Test("a server-initiated request with a string id decodes instead of being dropped") + func stringIdServerRequestDecodes() throws { + let message = try decode( + #"{"id":"sk-0","method":"client/registerCapability","params":{"registrations":[]}}"# + ) + #expect(message.id == nil) + #expect(message.stringID == "sk-0") + #expect(message.method == "client/registerCapability") + guard case .object = message.params else { + Issue.record("expected object params") + return + } + } + + @Test("repairMalformedJSON fixes bare-string-object values and keeps the message routable") + func repairBareStringObject() throws { + let invalid = #"{"id":2,"result":{"items":[{"label":"a","textEdit":{"doc string"}}]}}"#.data(using: .utf8)! + // Strict decode of the malformed message fails. + #expect((try? JSONDecoder().decode(RawMessage.self, from: invalid)) == nil) + let repairedOpt = JSONRPCTransport.repairMalformedJSON(invalid) + #expect(repairedOpt != nil) + guard let repaired = repairedOpt else { + Issue.record("repair returned nil") + return + } + let message = try JSONDecoder().decode(RawMessage.self, from: repaired) + #expect(message.id == 2) + #expect(message.stringID == nil) + #expect(message.method == nil) + switch JSONRPCTransport.routeResponse(message) { + case .resume(let data): + let list = try JSONDecoder().decode(CompletionList.self, from: data) + #expect(list.items.map(\.label) == ["a"]) + default: + Issue.record("expected a resume action") + } + } } // MARK: - Round-trip and crash behaviour diff --git a/Tests/CodeEditorTests/LSPClientEndToEndTests.swift b/Tests/CodeEditorTests/LSPClientEndToEndTests.swift new file mode 100644 index 0000000..0a2f766 --- /dev/null +++ b/Tests/CodeEditorTests/LSPClientEndToEndTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing + +@testable import CodeEditorLSP + +/// End-to-end against a running `sourcekit-lsp`, using a throwaway single-file Swift +/// document as the workspace. Local validation only — not run in CI (CI has no Swift 6.4 +/// toolchain and can't launch `sourcekit-lsp`; see docs/MEMORY.md #1). +/// +/// `sourcekit-lsp` is a heavy, occasionally-flaky process in this sandbox: it can crash +/// during `initialize` or stall mid-`completion` under `swift test`. Each attempt spins up +/// a fresh client/process; we retry a few times so a single flaky attempt doesn't fail the +/// suite. A genuinely-broken transport (the bugs this batch fixes — string-id requests +/// silently dropped, `registerCapability` never ACKed, no crash/time-out handling) fails +/// every attempt and is still reported. +@MainActor +@Suite("LSPClient end-to-end (requires sourcekit-lsp)") +struct LSPClientEndToEndTests { + /// Transient diagnostics so a flaky run still leaves a trail. No-op unless `LSP_DEBUG=1`. + private func diag(_ msg: String) { + guard ProcessInfo.processInfo.environment["LSP_DEBUG"] == "1" else { return } + let url = URL(fileURLWithPath: "/tmp/e2e_diag.txt") + let data = (msg + "\n").data(using: .utf8)! + if !FileManager.default.fileExists(atPath: url.path) { + try? data.write(to: url) + } else if let fh = try? FileHandle(forUpdating: url) { + try? fh.seekToEnd() + try? fh.write(data) + try? fh.close() + } else { + try? data.write(to: url) + } + } + + private func makeTempFile() throws -> (file: URL, root: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lsp-e2e-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("Main.swift") + // A local struct with two members: sourcekit resolves `s` without a workspace index, + // and the completion response is tiny (~1KB) instead of the ~200KB Array-member list + // that starves sourcekit-lsp under `swift test`. See docs/MEMORY.md #20. + let source = "struct S { var name: String = \"\"; var id: Int = 0 }\nlet s = S()\ns.\n" + try source.write(to: file, atomically: true, encoding: .utf8) + return (file, root) + } + + /// One connect→open→completion pass against a fresh `sourcekit-lsp`. Throws on any + /// transport/decoding failure; returns the completion items on success. + private func attemptCompletion() async throws -> [CompletionItem] { + let (file, root) = try makeTempFile() + defer { try? FileManager.default.removeItem(at: root) } + + let content = try String(contentsOf: file) + let client = LSPClient( + descriptor: .init( + id: "sourcekit-lsp", displayName: "SourceKit-LSP", + command: [LanguageServerRegistry.sourceKitLSPPath!], + languageIdentifiers: ["swift"] + ), + rootURL: root + ) + defer { await client.disconnect() } + + diag("connecting") + await client.connect() + diag("ready=\(client.isReady) connected=\(client.isConnected) err=\(String(describing: client.lastError))") + guard client.isReady else { + throw NSError( + domain: "LSPClientEndToEndTests", code: -1, + userInfo: [NSLocalizedDescriptionKey: "sourcekit-lsp not ready: \(client.lastError ?? "unknown")"] + ) + } + + // Give the server a moment to finish dynamic registration before exercising it. + try await Task.sleep(nanoseconds: 1_000_000_000) + + let uri = file.absoluteString + diag("opening \(uri)") + await client.openDocument(uri: uri, languageId: "swift", content: content) + diag("opened") + + // Caret at the end of `s.` -> the struct's stored members (`name`, `id`). + let pos = Position(line: 2, character: 2) + diag("requesting completion \(pos)") + let items = try await client.completion(at: pos, uri: uri) + diag("got \(items.count) items: \(items.prefix(5).map(\.label))") + return items + } + + @Test("sourcekit-lsp starts, opens, and returns member completions") + func completionRoundTrip() async throws { + try? FileManager.default.removeItem(at: URL(fileURLWithPath: "/tmp/e2e_diag.txt")) + diag("starting") + guard LanguageServerRegistry.sourceKitLSPPath != nil else { return } + + let expected = ["name", "id"] + var lastError: Error? + for attempt in 1...3 { + diag("attempt \(attempt)") + do { + let items = try await attemptCompletion() + let labels = Set(items.map(\.label)) + if labels.isSuperset(of: Set(expected)) { + #expect(!items.isEmpty) + return + } + diag("attempt \(attempt): got \(items.count) items but missing \(expected); retrying") + } catch { + lastError = error + diag("attempt \(attempt) threw: \(error)") + } + try await Task.sleep(nanoseconds: 1_000_000_000) + } + let detail = String(describing: lastError) + Issue.record("sourcekit-lsp returned no completions after 3 attempts; last error: \(detail)") + } +} diff --git a/Tests/CodeEditorTests/ServerCapabilitiesTests.swift b/Tests/CodeEditorTests/ServerCapabilitiesTests.swift new file mode 100644 index 0000000..172d66a --- /dev/null +++ b/Tests/CodeEditorTests/ServerCapabilitiesTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import CodeEditorLSP + +@Suite("ServerCapabilities decoding") +struct ServerCapabilitiesTests { + /// Build JSON `Data` from a plist-like dictionary, so the fixtures stay free of + /// fragile string quoting. + private func data(for object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]) + } + + /// A capabilities blob shaped like sourcekit-lsp's real answer, including the + /// `Bool | options-object` and `Int | options-object` unions that previously failed + /// the whole `initialize` handshake. + @Test("object-form textDocumentSync and signatureHelpProvider decode instead of failing") + func objectFormsDecode() throws { + let blob: [String: Any] = [ + "textDocumentSync": ["openClose": true, "change": 2, "willSave": true], + "completionProvider": ["triggerCharacters": [".", "("], "resolveProvider": true], + "hoverProvider": true, + "signatureHelpProvider": ["triggerCharacters": [",", ":"]], + "definitionProvider": true, + "referencesProvider": true, + "workspaceSymbolProvider": true, + ] + let caps = try JSONDecoder().decode(ServerCapabilities.self, from: data(for: blob)) + + #expect(caps.supportsCompletion == true) + #expect(caps.completionProvider?.triggerCharacters == [".", "("]) + #expect(caps.supportsHover) + #expect(caps.supportsDefinition) + #expect(caps.textDocumentSync == nil) + #expect(caps.signatureHelpProvider == nil) + } + + @Test("minimal and bare-bool blobs decode too") + func minimalAndBoolForms() throws { + let minimal = ["completionProvider": ["triggerCharacters": ["."]]] + let caps = try JSONDecoder().decode( + ServerCapabilities.self, from: data(for: minimal) + ) + #expect(caps.supportsCompletion) + #expect(!caps.supportsHover) + + let bools: [String: Any] = [ + "completionProvider": [:] as [String: Any], + "hoverProvider": true, + ] + let caps2 = try JSONDecoder().decode(ServerCapabilities.self, from: data(for: bools)) + #expect(caps2.supportsCompletion) + #expect(caps2.supportsHover) + } +}