PadXcode-iPad/Network/LSPClient.swift

338 lines
13 KiB
Swift
Raw Normal View History

2026-04-12 00:46:30 -07:00
// LSPClient.swift
// CompletionItem, DefinitionLocation, DiagnosticRange all live in
// Shared/SharedModels.swift not redeclared here.
import Foundation
// MARK: - LSP Request/Response types
struct LSPRequest: Encodable {
let jsonrpc = "2.0"
let id: Int?
let method: String
let params: JSONValue?
}
struct LSPResponse: Decodable {
let jsonrpc: String
let id: Int?
let result: JSONValue?
let error: LSPError?
let method: String?
let params: JSONValue?
}
struct LSPError: Decodable {
let code: Int
let message: String
}
// MARK: - JSONValue (generic JSON tree for LSP params)
indirect enum JSONValue: Codable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let v = try? c.decode(String.self) { self = .string(v); return }
if let v = try? c.decode(Int.self) { self = .int(v); return }
if let v = try? c.decode(Double.self) { self = .double(v); return }
if let v = try? c.decode(Bool.self) { self = .bool(v); return }
if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return }
if let v = try? c.decode([JSONValue].self) { self = .array(v); return }
self = .null
}
func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .string(let v): try c.encode(v)
case .int(let v): try c.encode(v)
case .double(let v): try c.encode(v)
case .bool(let v): try c.encode(v)
case .object(let v): try c.encode(v)
case .array(let v): try c.encode(v)
case .null: try c.encodeNil()
}
}
}
// MARK: - LSP Client
@MainActor
final class LSPClient: ObservableObject {
@Published var isConnected: Bool = false
@Published var completions: [CompletionItem] = []
@Published var diagnostics: [String: [DiagnosticRange]] = [:]
@Published var hoverText: String?
@Published var definitionLocation: DefinitionLocation?
private var webSocketTask: URLSessionWebSocketTask?
private var messageId = 0
private var pendingRequests: [Int: CheckedContinuation<LSPResponse, Error>] = [:]
2026-04-12 22:07:35 -07:00
var config: DaemonConfiguration
2026-04-12 00:46:30 -07:00
init(config: DaemonConfiguration) { self.config = config }
// MARK: - Connection
func connect(projectPath: String) async {
guard let startURL = URL(string: "\(config.baseURL)/lsp/start") else { return }
var req = URLRequest(url: startURL)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONEncoder().encode(["projectPath": projectPath])
_ = try? await URLSession.shared.data(for: req)
guard let wsURL = URL(string: config.lspWSURL) else { return }
webSocketTask?.cancel()
webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
webSocketTask?.resume()
isConnected = true
config.lspConnected = true
await sendInitialize(projectPath: projectPath)
Task { await receiveLoop() }
}
func disconnect() {
webSocketTask?.cancel()
webSocketTask = nil
isConnected = false
config.lspConnected = false
}
// MARK: - LSP Lifecycle
private func sendInitialize(projectPath: String) async {
let params = JSONValue.object([
"processId": .int(Int(ProcessInfo.processInfo.processIdentifier)),
"rootUri": .string("file://\(projectPath)"),
"capabilities": .object([
"textDocument": .object([
"completion": .object(["completionItem": .object(["snippetSupport": .bool(false)])]),
"hover": .object(["contentFormat": .array([.string("plaintext")])]),
"publishDiagnostics": .object([:])
])
]),
"initializationOptions": .object([:])
])
_ = try? await sendRequest(method: "initialize", params: params)
sendNotification(method: "initialized", params: .object([:]))
}
// MARK: - Document Sync
func didOpen(filePath: String, content: String) {
sendNotification(method: "textDocument/didOpen", params: .object([
"textDocument": .object([
"uri": .string("file://\(filePath)"),
"languageId": .string(languageIdentifier(for: filePath)),
"version": .int(1),
"text": .string(content)
])
]))
}
func didChange(filePath: String, content: String, version: Int) {
sendNotification(method: "textDocument/didChange", params: .object([
"textDocument": .object(["uri": .string("file://\(filePath)"), "version": .int(version)]),
"contentChanges": .array([.object(["text": .string(content)])])
]))
}
func didSave(filePath: String) {
sendNotification(method: "textDocument/didSave", params: .object([
"textDocument": .object(["uri": .string("file://\(filePath)")])
]))
}
func didClose(filePath: String) {
sendNotification(method: "textDocument/didClose", params: .object([
"textDocument": .object(["uri": .string("file://\(filePath)")])
]))
}
// MARK: - Completions
func requestCompletions(filePath: String, line: Int, column: Int) async -> [CompletionItem] {
guard let response = try? await sendRequest(
method: "textDocument/completion",
params: textDocumentPositionParams(filePath: filePath, line: line - 1, character: column - 1)
) else { return [] }
return parseCompletions(from: response)
}
// MARK: - Hover
func requestHover(filePath: String, line: Int, column: Int) async -> String? {
guard let response = try? await sendRequest(
method: "textDocument/hover",
params: textDocumentPositionParams(filePath: filePath, line: line - 1, character: column - 1)
) else { return nil }
if case .object(let obj) = response.result,
case .object(let contents) = obj["contents"],
case .string(let value) = contents["value"] { return value }
return nil
}
// MARK: - Definition
func requestDefinition(filePath: String, line: Int, column: Int) async -> DefinitionLocation? {
guard let response = try? await sendRequest(
method: "textDocument/definition",
params: textDocumentPositionParams(filePath: filePath, line: line - 1, character: column - 1)
) else { return nil }
return parseDefinition(from: response)
}
// MARK: - WebSocket Send/Receive
@discardableResult
private func sendRequest(method: String, params: JSONValue?) async throws -> LSPResponse {
messageId += 1
let id = messageId
let data = try JSONEncoder().encode(LSPRequest(id: id, method: method, params: params))
guard let text = String(data: data, encoding: .utf8) else { throw URLError(.badServerResponse) }
return try await withCheckedThrowingContinuation { cont in
pendingRequests[id] = cont
webSocketTask?.send(.string(text)) { error in
if let error {
Task { @MainActor in
2026-04-12 22:07:35 -07:00
// Only resume if this request is still pending handleMessage
// may have already received a response and resumed it.
if self.pendingRequests.removeValue(forKey: id) != nil {
cont.resume(throwing: error)
}
2026-04-12 00:46:30 -07:00
}
}
}
}
}
private func sendNotification(method: String, params: JSONValue?) {
guard let data = try? JSONEncoder().encode(LSPRequest(id: nil, method: method, params: params)),
let text = String(data: data, encoding: .utf8) else { return }
webSocketTask?.send(.string(text)) { _ in }
}
private func receiveLoop() async {
2026-04-12 22:07:35 -07:00
guard let task = webSocketTask else {
2026-04-12 00:46:30 -07:00
isConnected = false; return
}
2026-04-12 22:07:35 -07:00
while task.state == .running {
do {
let message = try await task.receive()
switch message {
case .string(let text): handleMessage(text)
case .data(let data):
if let text = String(data: data, encoding: .utf8) { handleMessage(text) }
@unknown default: break
}
} catch {
isConnected = false
config.lspConnected = false
// Drain pending continuations so callers don't hang forever
for (_, cont) in pendingRequests {
cont.resume(throwing: error)
}
pendingRequests.removeAll()
return
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
isConnected = false
2026-04-12 00:46:30 -07:00
}
private func handleMessage(_ text: String) {
guard let data = text.data(using: .utf8),
let response = try? JSONDecoder().decode(LSPResponse.self, from: data) else { return }
if let id = response.id, let cont = pendingRequests.removeValue(forKey: id) {
cont.resume(returning: response); return
}
if let method = response.method { handleNotification(method: method, params: response.params) }
}
private func handleNotification(method: String, params: JSONValue?) {
if method == "textDocument/publishDiagnostics" { handlePublishDiagnostics(params) }
}
private func handlePublishDiagnostics(_ params: JSONValue?) {
guard case .object(let obj) = params,
case .string(let uri) = obj["uri"],
case .array(let diags) = obj["diagnostics"] else { return }
let filePath = uri.replacingOccurrences(of: "file://", with: "")
diagnostics[filePath] = diags.compactMap { diag -> DiagnosticRange? in
guard case .object(let d) = diag,
case .object(let rng) = d["range"],
case .object(let start) = rng["start"],
case .int(let line) = start["line"],
case .int(let col) = start["character"],
case .int(let sev) = d["severity"] else { return nil }
let msg: String
if case .string(let m) = d["message"] { msg = m } else { msg = "" }
return DiagnosticRange(
line: line + 1, column: col + 1, length: 30,
severity: sev == 1 ? .error : .warning, message: msg
)
}
}
// MARK: - Parsing
private func parseCompletions(from response: LSPResponse) -> [CompletionItem] {
var items: [JSONValue] = []
if case .array(let a) = response.result { items = a }
else if case .object(let o) = response.result,
case .array(let a) = o["items"] { items = a }
return items.compactMap { item -> CompletionItem? in
guard case .object(let obj) = item,
case .string(let label) = obj["label"] else { return nil }
let detail: String; if case .string(let d) = obj["detail"] { detail = d } else { detail = "" }
let insertText: String?; if case .string(let t) = obj["insertText"] { insertText = t } else { insertText = nil }
let filterText: String?; if case .string(let f) = obj["filterText"] { filterText = f } else { filterText = nil }
let kind: Int; if case .int(let k) = obj["kind"] { kind = k } else { kind = 0 }
return CompletionItem(label: label, detail: detail, kind: kind, insertText: insertText, filterText: filterText)
}
}
private func parseDefinition(from response: LSPResponse) -> DefinitionLocation? {
guard case .object(let loc) = response.result,
case .string(let uri) = loc["uri"],
case .object(let rng) = loc["range"],
case .object(let start) = rng["start"],
case .int(let line) = start["line"],
case .int(let col) = start["character"] else { return nil }
return DefinitionLocation(
filePath: uri.replacingOccurrences(of: "file://", with: ""),
line: line + 1, column: col + 1
)
}
private func textDocumentPositionParams(filePath: String, line: Int, character: Int) -> JSONValue {
.object([
"textDocument": .object(["uri": .string("file://\(filePath)")]),
"position": .object(["line": .int(line), "character": .int(character)])
])
}
private func languageIdentifier(for path: String) -> String {
switch (path as NSString).pathExtension.lowercased() {
case "swift": return "swift"
case "c": return "c"
case "cpp": return "cpp"
case "m": return "objective-c"
default: return "plaintext"
}
}
}