2026-04-12 00:42:51 -07:00
|
|
|
import Vapor
|
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
actor LSPProxy {
|
|
|
|
|
static let shared = LSPProxy()
|
|
|
|
|
private var process: Process?
|
|
|
|
|
private var stdinPipe: Pipe?
|
|
|
|
|
private var clients: [WebSocket] = []
|
|
|
|
|
|
2026-04-12 22:14:13 -07:00
|
|
|
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)"])
|
|
|
|
|
}
|
2026-04-12 00:42:51 -07:00
|
|
|
let p = Process()
|
2026-04-12 22:14:13 -07:00
|
|
|
p.executableURL = URL(fileURLWithPath: lspPath)
|
2026-04-12 00:42:51 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:13 -07:00
|
|
|
func stop() { process?.terminate(); process = nil; stdinPipe = nil; clients.removeAll() }
|
|
|
|
|
private func handleTermination() { process = nil; broadcastStatus("lsp_terminated"); clients.removeAll() }
|
2026-04-12 00:42:51 -07:00
|
|
|
|
|
|
|
|
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 }
|
2026-04-12 10:16:21 -07:00
|
|
|
for ws in clients where !ws.isClosed { let w = ws; Task { try? await w.send(text) } }
|
2026-04-12 00:42:51 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func broadcastStatus(_ s: String) {
|
2026-04-12 10:16:21 -07:00
|
|
|
for ws in clients where !ws.isClosed { let w = ws; let msg = #"{"padxcode_status":"\#(s)"}"#; Task { try? await w.send(msg) } }
|
2026-04-12 00:42:51 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:13 -07:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 00:42:51 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|