Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CodeEdit/CodeEditApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ struct CodeEditApp: App {
ServiceContainer.register(
LSPService()
)
ServiceContainer.register(
CopilotService.shared
)

_ = CodeEditDocumentController.shared
NSMenuItem.swizzle()
Expand Down
72 changes: 72 additions & 0 deletions CodeEdit/Features/Copilot/CopilotBinaryResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// CopilotBinaryResolver.swift
// CodeEdit
//
// Created by Anas Khan on 6/30/26.
//

import Foundation

/// Locates the `copilot-language-server` binary and builds a launch environment for it.
enum CopilotBinaryResolver {
private static let binaryName = "copilot-language-server"

/// Resolves the path to the `copilot-language-server` binary.
///
/// Prefers an explicit `configuredPath`, then searches `PATH` and common installation directories.
static func resolveBinaryPath(configuredPath: String) -> String? {
let fileManager = FileManager.default
let trimmed = configuredPath.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, fileManager.isExecutableFile(atPath: trimmed) {
return trimmed
}

let home = fileManager.homeDirectoryForCurrentUser.path
var searchDirectories: [String] = []
if let path = ProcessInfo.processInfo.environment["PATH"] {
searchDirectories.append(contentsOf: path.split(separator: ":").map(String.init))
}
searchDirectories.append(contentsOf: [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"\(home)/.npm-global/bin",
"\(home)/.local/bin"
])

for directory in searchDirectories {
let candidate = (directory as NSString).appendingPathComponent(binaryName)
if fileManager.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}

/// Builds an environment for the language-server process, ensuring `PATH` includes Node and the binary directory.
///
/// The server's `#!/usr/bin/env node` shebang requires `node` to be discoverable on `PATH`, which is not
/// guaranteed inside the sandboxed app, so nvm-managed and common Node directories are appended.
static func augmentedEnvironment(forBinaryAt binaryPath: String) -> [String: String] {
var environment = ProcessInfo.processInfo.environment
let home = FileManager.default.homeDirectoryForCurrentUser.path
let binaryDirectory = (binaryPath as NSString).deletingLastPathComponent
var extraPaths = [
binaryDirectory,
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin"
]

let nvmNode = "\(home)/.nvm/versions/node"
if let versions = try? FileManager.default.contentsOfDirectory(atPath: nvmNode) {
extraPaths.append(contentsOf: versions.map { "\(nvmNode)/\($0)/bin" })
}

let currentPath = environment["PATH"] ?? ""
let combined = ([currentPath] + extraPaths).filter { !$0.isEmpty }.joined(separator: ":")
environment["PATH"] = combined
return environment
}
}
249 changes: 249 additions & 0 deletions CodeEdit/Features/Copilot/CopilotClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
//
// CopilotClient.swift
// CodeEdit
//
// Created by Anas Khan on 6/30/26.
//

import Foundation
import JSONRPC
import LanguageClient
import LanguageServerProtocol
import OSLog

/// A raw JSON-RPC client for the GitHub Copilot language server.
///
/// `LanguageServerProtocol`'s typed enums do not model Copilot's custom requests (`signIn`, `checkStatus`,
/// `textDocument/inlineCompletion`, ...), so this client drives the server with hand-rolled `Codable` types over
/// a ``JSONRPCSession``. The session is created on top of `DataChannel.localProcessChannel(parameters:)`
/// wrapped with LSP header framing via `withMessageFraming()`.
///
/// The client also answers the server-to-client requests that would otherwise stall the server
/// (`workspace/configuration`, `window/workDoneProgress/create`, `window/showDocument`) and forwards the
/// `didChangeStatus/v2` notification to its owner.
final class CopilotClient {
/// A `{ "success": true }` reply for `window/showDocument`.
private struct ShowDocumentResult: Codable, Sendable {
let success: Bool
}

/// `{ "settings": {} }` payload for `workspace/didChangeConfiguration`.
private struct DidChangeConfigurationParams: Codable, Sendable {
let settings: CopilotEmptyObject
}

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CopilotClient")

/// The underlying JSON-RPC session, framed for LSP transport.
let session: JSONRPCSession
/// The language-server process.
let process: Process

private var eventTask: Task<Void, Never>?

/// Forwards `didChangeStatus/v2` notifications to the owner (the ``CopilotService``).
var onStatusChange: (@Sendable (CopilotDidChangeStatusParams) -> Void)?

init(session: JSONRPCSession, process: Process) {
self.session = session
self.process = process
}

deinit {
eventTask?.cancel()
}

// MARK: - Process Launch

/// Spawns the Copilot language server as a local subprocess and wires up a framed JSON-RPC session.
/// - Parameters:
/// - binaryPath: Absolute path to the `copilot-language-server` executable.
/// - arguments: Process arguments. Defaults to `["--stdio"]`.
/// - environment: Environment variables for the process.
/// - terminationHandler: Invoked when the process terminates unexpectedly.
/// - Returns: A connected, but not yet initialized, client.
static func launch(
binaryPath: String,
arguments: [String] = ["--stdio"],
environment: [String: String],
terminationHandler: @escaping @Sendable () -> Void
) throws -> CopilotClient {
let params = Process.ExecutionParameters(
path: binaryPath,
arguments: arguments,
environment: environment
)
let (channel, process) = try DataChannel.localProcessChannel(
parameters: params,
terminationHandler: terminationHandler
)
let session = JSONRPCSession(channel: channel.withMessageFraming())
let client = CopilotClient(session: session, process: process)
client.startListening()
return client
}

// MARK: - Server To Client Traffic

/// Begins consuming server-to-client requests and notifications so the server does not stall.
private func startListening() {
eventTask = Task { [weak self] in
guard let session = self?.session else { return }
let sequence = await session.eventSequence
for await event in sequence {
guard let self else { return }
switch event {
case let .request(request, handler, _):
await self.handleServerRequest(request, handler: handler)
case let .notification(notification, data):
self.handleServerNotification(notification, data: data)
case let .error(error):
self.logger.warning("JSON-RPC error: \(error)")
}
}
}
}

private func handleServerRequest(
_ request: AnyJSONRPCRequest,
handler: JSONRPCEvent.RequestHandler
) async {
switch request.method {
case CopilotMethod.configuration:
// Reply with one empty configuration object per requested item.
let count = configurationItemCount(request.params)
let reply = Array(repeating: CopilotEmptyObject(), count: max(count, 1))
await handler(.success(reply))
case CopilotMethod.workDoneProgressCreate:
await handler(.success(CopilotEmptyObject()))
case CopilotMethod.showDocument:
await handler(.success(ShowDocumentResult(success: true)))
default:
// Reply with an empty object so unmodeled requests never block the server.
await handler(.success(CopilotEmptyObject()))
}
}

private func configurationItemCount(_ params: JSONValue?) -> Int {
guard let params, case let .hash(hash) = params,
let itemsValue = hash["items"], case let .array(items) = itemsValue else {
return 1
}
return items.count
}

private func handleServerNotification(_ notification: AnyJSONRPCNotification, data: Data) {
guard notification.method == CopilotMethod.didChangeStatusV2 else { return }
guard let params: CopilotDidChangeStatusParams = decodeParams(notification.params) else { return }
onStatusChange?(params)
}

private func decodeParams<T: Decodable>(_ params: JSONValue?) -> T? {
guard let params else { return nil }
do {
let data = try JSONEncoder().encode(params)
return try JSONDecoder().decode(T.self, from: data)
} catch {
logger.warning("Failed to decode notification params: \(error)")
return nil
}
}

// MARK: - Lifecycle Requests

@discardableResult
func initialize(_ params: CopilotInitializeParams) async throws -> CopilotInitializeResult {
try await session.response(to: CopilotMethod.initialize, params: params)
}

func sendInitialized() async throws {
try await session.sendNotification(CopilotEmptyObject(), method: CopilotMethod.initialized)
try await session.sendNotification(
DidChangeConfigurationParams(settings: CopilotEmptyObject()),
method: CopilotMethod.didChangeConfiguration
)
}

// MARK: - Authentication Requests

func checkStatus(localChecksOnly: Bool) async throws -> CopilotStatusResult {
try await session.response(
to: CopilotMethod.checkStatus,
params: CopilotCheckStatusParams(options: .init(localChecksOnly: localChecksOnly))
)
}

func signIn() async throws -> CopilotSignInResult {
try await session.response(to: CopilotMethod.signIn, params: CopilotEmptyObject())
}

func signInWithGithubToken(_ token: String, user: String?) async throws -> CopilotStatusResult {
try await session.response(
to: CopilotMethod.signInWithGithubToken,
params: CopilotSignInWithTokenParams(githubToken: token, user: user)
)
}

func signOut() async throws -> CopilotStatusResult {
try await session.response(to: CopilotMethod.signOut, params: CopilotEmptyObject())
}

@discardableResult
func executeCommand(_ command: CopilotCommand) async throws -> JSONValue {
try await session.response(
to: CopilotMethod.executeCommand,
params: CopilotExecuteCommandParams(command: command.command, arguments: command.arguments)
)
}

// MARK: - Document Synchronization Notifications

func didOpen(uri: String, languageId: String, version: Int, text: String) async throws {
let params = CopilotDidOpenParams(
textDocument: .init(uri: uri, languageId: languageId, version: version, text: text)
)
try await session.sendNotification(params, method: CopilotMethod.didOpen)
}

func didChange(uri: String, version: Int, text: String) async throws {
let params = CopilotDidChangeParams(
textDocument: .init(uri: uri, version: version),
contentChanges: [.init(text: text)]
)
try await session.sendNotification(params, method: CopilotMethod.didChange)
}

func didClose(uri: String) async throws {
try await session.sendNotification(
CopilotDidCloseParams(textDocument: .init(uri: uri)),
method: CopilotMethod.didClose
)
}

func didFocus(uri: String) async throws {
try await session.sendNotification(
CopilotDidFocusParams(textDocument: .init(uri: uri)),
method: CopilotMethod.didFocus
)
}

// MARK: - Inline Completion

func inlineCompletion(_ params: CopilotInlineCompletionParams) async throws -> CopilotInlineCompletionResult {
try await session.response(to: CopilotMethod.inlineCompletion, params: params)
}

func didShowCompletion(item: CopilotCompletionItem) async throws {
try await session.sendNotification(
CopilotDidShowCompletionParams(item: item),
method: CopilotMethod.didShowCompletion
)
}

func didPartiallyAcceptCompletion(item: CopilotCompletionItem, acceptedLength: Int) async throws {
try await session.sendNotification(
CopilotDidPartiallyAcceptParams(item: item, acceptedLength: acceptedLength),
method: CopilotMethod.didPartiallyAcceptCompletion
)
}
}
23 changes: 23 additions & 0 deletions CodeEdit/Features/Copilot/CopilotDocumentObjects.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// CopilotDocumentObjects.swift
// CodeEdit
//
// Created by Anas Khan on 6/30/26.
//

import Foundation

/// A per-document bundle of GitHub Copilot objects, mirroring ``LanguageServerDocumentObjects``.
///
/// Stored on ``CodeFileDocument`` so it lives for the document's lifetime and keeps a strong reference to the
/// ``CopilotInlineCompletionProvider`` (the editor controller only holds the provider weakly).
struct CopilotDocumentObjects {
/// The inline completion provider passed to the editor as its `inlineCompletionDelegate`.
let provider = CopilotInlineCompletionProvider()

/// Associates the provider with its document.
@MainActor
func setUp(document: CodeFileDocument) {
provider.setUp(document: document)
}
}
Loading