PadXcode-Daemon/Daemon/LSPProxy.swift
2026-04-12 22:14:13 -07:00

104 lines
4.6 KiB
Swift

import Vapor
import Foundation
actor LSPProxy {
static let shared = LSPProxy()
private var process: Process?
private var stdinPipe: Pipe?
private var clients: [WebSocket] = []
func start(projectPath: String) async throws {
// If already running for a DIFFERENT project, restart for the new one.
// Keeping the old process would give wrong completions/diagnostics.
if process != nil {
stop()
}
// Resolve sourcekit-lsp via xcrun so we respect the active xcode-select toolchain.
// Run on a background thread because waitUntilExit() blocks.
let lspPath = await withCheckedContinuation { cont in
DispatchQueue.global(qos: .userInitiated).async {
cont.resume(returning: Self.resolveLSPPathSync())
}
}
guard FileManager.default.fileExists(atPath: lspPath) else {
throw NSError(domain: "LSPProxy", code: 1,
userInfo: [NSLocalizedDescriptionKey: "sourcekit-lsp not found at \(lspPath)"])
}
let p = Process()
p.executableURL = URL(fileURLWithPath: lspPath)
p.currentDirectoryURL = URL(fileURLWithPath: projectPath)
let inPipe = Pipe(); let outPipe = Pipe(); let errPipe = Pipe()
p.standardInput = inPipe; p.standardOutput = outPipe; p.standardError = errPipe
stdinPipe = inPipe
outPipe.fileHandleForReading.readabilityHandler = { [weak self] h in
let d = h.availableData; guard !d.isEmpty else { return }
Task { await self?.broadcastToClients(d) }
}
p.terminationHandler = { [weak self] _ in Task { await self?.handleTermination() } }
try p.run(); process = p
}
func stop() { process?.terminate(); process = nil; stdinPipe = nil; clients.removeAll() }
private func handleTermination() { process = nil; broadcastStatus("lsp_terminated"); clients.removeAll() }
func addClient(_ ws: WebSocket) { clients.append(ws) }
func removeClient(_ ws: WebSocket) { clients.removeAll { $0 === ws } }
func forwardToLSP(_ message: Data) {
guard let pipe = stdinPipe else { return }
let header = "Content-Length: \(message.count)\r\n\r\n"
if let hd = header.data(using: .utf8) {
pipe.fileHandleForWriting.write(hd)
pipe.fileHandleForWriting.write(message)
}
}
private func broadcastToClients(_ data: Data) {
let messages = extractJSONBodies(from: data)
for msg in messages {
guard let text = String(data: msg, encoding: .utf8) else { continue }
for ws in clients where !ws.isClosed { let w = ws; Task { try? await w.send(text) } }
}
}
private func broadcastStatus(_ s: String) {
for ws in clients where !ws.isClosed { let w = ws; let msg = #"{"padxcode_status":"\#(s)"}"#; Task { try? await w.send(msg) } }
}
private static func resolveLSPPathSync() -> String {
// Try xcrun first respects xcode-select.
let xcrun = Process()
xcrun.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
xcrun.arguments = ["--find", "sourcekit-lsp"]
let pipe = Pipe()
xcrun.standardOutput = pipe; xcrun.standardError = Pipe()
if let _ = try? xcrun.run() {
xcrun.waitUntilExit()
if xcrun.terminationStatus == 0,
let path = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty {
return path
}
}
// Fallback to known default location.
return "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp"
}
private func extractJSONBodies(from data: Data) -> [Data] {
var results: [Data] = []; var rem = data
let sep = Data([0x0D,0x0A,0x0D,0x0A])
while !rem.isEmpty {
guard let sr = rem.range(of: sep) else { break }
let hd = rem[rem.startIndex..<sr.lowerBound]
guard let hs = String(data: hd, encoding: .utf8),
let ls = hs.components(separatedBy: "\r\n").first(where: { $0.hasPrefix("Content-Length:") })?
.components(separatedBy: ":").last?.trimmingCharacters(in: .whitespaces),
let len = Int(ls) else { break }
let bs = sr.upperBound
let be = rem.index(bs, offsetBy: len, limitedBy: rem.endIndex) ?? rem.endIndex
results.append(Data(rem[bs..<be])); rem = rem[be...]
}
return results
}
}