diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 56acfca16..081385cdc 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 283BDCBD2972EEBD002AFF81 /* Package.resolved in Resources */ = {isa = PBXBuildFile; fileRef = 283BDCBC2972EEBD002AFF81 /* Package.resolved */; }; 284DC8512978BA2600BF2770 /* .all-contributorsrc in Resources */ = {isa = PBXBuildFile; fileRef = 284DC8502978BA2600BF2770 /* .all-contributorsrc */; }; 2BE487F428245162003F3F64 /* OpenWithCodeEdit.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2BE487EC28245162003F3F64 /* OpenWithCodeEdit.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 302AD7FF2D8054D500231E16 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 30818CB42D4E563900967860 /* ZIPFoundation */; }; 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64902C16CA8100CC8A9E /* LanguageServerProtocol */; }; 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64932C16CA9100CC8A9E /* LanguageClient */; }; 583E529C29361BAB001AB554 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 583E529B29361BAB001AB554 /* SnapshotTesting */; }; @@ -28,6 +29,7 @@ 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; }; 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */; }; 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */; }; + 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */; }; 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CAAF69229BCC71C00A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; 6CAAF69429BCD78600A1F48A /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -163,6 +165,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 302AD7FF2D8054D500231E16 /* ZIPFoundation in Frameworks */, 6C85BB402C2105ED00EB5DEF /* CodeEditKit in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, @@ -184,6 +187,7 @@ 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */, + 6C9DB9E42D55656300ACD86E /* CodeEditSourceEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,7 +320,7 @@ 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, 6CC00A8A2CBEF150004E8134 /* CodeEditSourceEditor */, - 6C73A6D22D4F1E550012D95C /* CodeEditSourceEditor */, + 30818CB42D4E563900967860 /* ZIPFoundation */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -420,6 +424,7 @@ 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, 6CF368562DBBD274006A77FD /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -1649,6 +1654,14 @@ minimumVersion = 0.13.2; }; }; + 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.19; + }; + }; 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ChimeHQ/LanguageServerProtocol"; @@ -1665,6 +1678,14 @@ minimumVersion = 0.8.0; }; }; + 30ED7B722DD299E600ACC922 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.19; + }; + }; 583E529A29361BAB001AB554 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; @@ -1737,6 +1758,14 @@ minimumVersion = 1.2.0; }; }; + 6C9DB9E22D55656300ACD86E /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.10.0; + }; + }; 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; @@ -1761,6 +1790,11 @@ package = 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */; productName = CodeEditSymbols; }; + 30818CB42D4E563900967860 /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = 30818CB32D4E563900967860 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; 30CB64902C16CA8100CC8A9E /* LanguageServerProtocol */ = { isa = XCSwiftPackageProductDependency; package = 30CB648F2C16CA8100CC8A9E /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */; @@ -1838,6 +1872,11 @@ package = 6C85BB422C210EFD00EB5DEF /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = SwiftUIIntrospect; }; + 6C9DB9E32D55656300ACD86E /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + package = 6C9DB9E22D55656300ACD86E /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; + productName = CodeEditSourceEditor; + }; 6CB4463F2B6DFF3A00539ED0 /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 465ebbcf1..5d53d71e3 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -31,7 +31,7 @@ { "identity" : "codeeditsourceeditor", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", + "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor.git", "state" : { "revision" : "412b0a26cbeb3f3148a1933dd598c976defe92a6", "version" : "0.12.0" @@ -278,6 +278,15 @@ "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", "version" : "0.23.2" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation", + "state" : { + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" + } } ], "version" : 3 diff --git a/CodeEdit/Features/LSP/Registry/InstallationMethod.swift b/CodeEdit/Features/LSP/Registry/InstallationMethod.swift new file mode 100644 index 000000000..532a92c1b --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/InstallationMethod.swift @@ -0,0 +1,54 @@ +// +// InstallationMethod.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +import Foundation + +/// Installation method enum with all supported types +enum InstallationMethod: Equatable { + /// For standard package manager installations + case standardPackage(source: PackageSource) + /// For packages that need to be built from source with custom build steps + case sourceBuild(source: PackageSource, command: String) + /// For direct binary downloads + case binaryDownload(source: PackageSource, url: URL) + /// For installations that aren't recognized + case unknown + + var packageName: String? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.pkgName + case .unknown: + return nil + } + } + + var version: String? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.version + case .unknown: + return nil + } + } + + var packageManagerType: PackageManagerType? { + switch self { + case .standardPackage(let source), + .sourceBuild(let source, _), + .binaryDownload(let source, _): + return source.type + case .unknown: + return nil + } + } +} + diff --git a/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift new file mode 100644 index 000000000..b1f690960 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/InstallationQueueManager.swift @@ -0,0 +1,185 @@ +// +// InstallationQueueManager.swift +// CodeEdit +// +// Created by Abe Malla on 3/13/25. +// + +import Foundation + +/// A class to manage queued installations of language servers +final class InstallationQueueManager { + static let shared: InstallationQueueManager = .init() + + /// The maximum number of concurrent installations allowed + private let maxConcurrentInstallations: Int = 2 + /// Queue of pending installations + private var installationQueue: [(RegistryItem, (Result) -> Void)] = [] + /// Currently running installations + private var runningInstallations: Set = [] + /// Installation status dictionary + private var installationStatus: [String: PackageInstallationStatus] = [:] + + /// Add a package to the installation queue + func queueInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { + // If we're already at max capacity and this isn't already running, mark as queued + if runningInstallations.count >= maxConcurrentInstallations && !runningInstallations.contains(package.name) { + installationStatus[package.name] = .queued + installationQueue.append((package, completion)) + + // Notify UI that package is queued + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.queued] + ) + } + } else { + startInstallation(package: package, completion: completion) + } + } + + /// Starts the actual installation process for a package + private func startInstallation(package: RegistryItem, completion: @escaping (Result) -> Void) { + installationStatus[package.name] = .installing + runningInstallations.insert(package.name) + + // Notify UI that installation is now in progress + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.installing] + ) + } + + Task { + do { + try await RegistryManager.shared.installPackage(package: package) + + // Notify UI that installation is complete + installationStatus[package.name] = .installed + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.installed] + ) + completion(.success(())) + } + } catch { + // Notify UI that installation failed + installationStatus[package.name] = .failed(error) + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": package.name, "status": PackageInstallationStatus.failed(error)] + ) + completion(.failure(error)) + } + } + + runningInstallations.remove(package.name) + processNextInstallations() + } + } + + /// Process next installations from the queue if possible + private func processNextInstallations() { + while runningInstallations.count < maxConcurrentInstallations && !installationQueue.isEmpty { + let (package, completion) = installationQueue.removeFirst() + if runningInstallations.contains(package.name) { + continue + } + + startInstallation(package: package, completion: completion) + } + } + + /// Cancel an installation if it's in the queue + func cancelInstallation(packageName: String) { + installationQueue.removeAll { $0.0.name == packageName } + installationStatus[packageName] = .cancelled + runningInstallations.remove(packageName) + + // Notify UI that installation was cancelled + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .installationStatusChanged, + object: nil, + userInfo: ["packageName": packageName, "status": PackageInstallationStatus.cancelled] + ) + } + processNextInstallations() + } + + /// Get the current status of an installation + func getInstallationStatus(packageName: String) -> PackageInstallationStatus { + return installationStatus[packageName] ?? .notQueued + } + + /// Cleans up installation status by removing completed or failed installations + func cleanUpInstallationStatus() { + let statusKeys = installationStatus.keys.map { $0 } + for packageName in statusKeys { + if let status = installationStatus[packageName] { + switch status { + case .installed, .failed, .cancelled: + installationStatus.removeValue(forKey: packageName) + case .queued, .installing, .notQueued: + break + } + } + } + + // If an item is in runningInstallations but not in an active state in the status dictionary, + // it might be a stale reference + let currentRunning = runningInstallations.map { $0 } + for packageName in currentRunning { + let status = installationStatus[packageName] + if status != .installing { + runningInstallations.remove(packageName) + } + } + + // Check for orphaned queue items + installationQueue = installationQueue.filter { item, _ in + return installationStatus[item.name] == .queued + } + } +} + +/// Status of a package installation +enum PackageInstallationStatus: Equatable { + case notQueued + case queued + case installing + case installed + case failed(Error) + case cancelled + + static func == (lhs: PackageInstallationStatus, rhs: PackageInstallationStatus) -> Bool { + switch (lhs, rhs) { + case (.notQueued, .notQueued): + return true + case (.queued, .queued): + return true + case (.installing, .installing): + return true + case (.installed, .installed): + return true + case (.cancelled, .cancelled): + return true + case (.failed, .failed): + return true + default: + return false + } + } +} + +extension Notification.Name { + static let installationStatusChanged = Notification.Name("installationStatusChanged") +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerError.swift b/CodeEdit/Features/LSP/Registry/PackageManagerError.swift new file mode 100644 index 000000000..fd92c2630 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagerError.swift @@ -0,0 +1,13 @@ +// +// PackageManagerError.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +enum PackageManagerError: Error { + case packageManagerNotInstalled + case initializationFailed(String) + case installationFailed(String) + case invalidConfiguration +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift new file mode 100644 index 000000000..1077cff1b --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagerProtocol.swift @@ -0,0 +1,96 @@ +// +// PackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +protocol PackageManagerProtocol { + var shellClient: ShellClient { get } + + /// Performs any initialization steps for installing a package, such as creating the directory + /// and virtual environments. + func initialize(in packagePath: URL) async throws + /// Calls the shell commands to install a package + func install(method installationMethod: InstallationMethod) async throws + /// Gets the location of the binary that was installed + func getBinaryPath(for package: String) -> String + /// Checks if the shell commands for the package manager are available or not + func isInstalled() async -> Bool +} + +extension PackageManagerProtocol { + /// Creates the directory for the language server to be installed in + func createDirectoryStructure(for packagePath: URL) throws { + let decodedPath = packagePath.path.removingPercentEncoding ?? packagePath.path + if !FileManager.default.fileExists(atPath: decodedPath) { + try FileManager.default.createDirectory( + at: packagePath, + withIntermediateDirectories: true, + attributes: nil + ) + } + } + + /// Executes commands in the specified directory + func executeInDirectory(in packagePath: String, _ args: [String]) async throws -> [String] { + return try await runCommand("cd \"\(packagePath)\" && \(args.joined(separator: " "))") + } + + /// Runs a shell command and returns output + func runCommand(_ command: String) async throws -> [String] { + var output: [String] = [] + for try await line in shellClient.runAsync(command) { + output.append(line) + } + return output + } +} + +/// Generic package source information that applies to all installation methods. +/// Takes all the necessary information from `RegistryItem`. +struct PackageSource: Equatable, Codable { + /// The raw source ID string from the registry + let sourceId: String + /// The type of the package manager + let type: PackageManagerType + /// Package name + let pkgName: String + /// The name in the registry.json file. Used for the folder name when saved. + let entryName: String + /// Package version + let version: String + /// URL for repository or download link + let repositoryUrl: String? + /// Git reference type if this is a git based package + let gitReference: GitReference? + /// Additional possible options + var options: [String: String] + + init( + sourceId: String, + type: PackageManagerType, + pkgName: String, + entryName: String, + version: String, + repositoryUrl: String? = nil, + gitReference: GitReference? = nil, + options: [String: String] = [:] + ) { + self.sourceId = sourceId + self.type = type + self.pkgName = pkgName + self.entryName = entryName + self.version = version + self.repositoryUrl = repositoryUrl + self.gitReference = gitReference + self.options = options + } + + enum GitReference: Equatable, Codable { + case tag(String) + case revision(String) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagerType.swift b/CodeEdit/Features/LSP/Registry/PackageManagerType.swift new file mode 100644 index 000000000..2a3982f12 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagerType.swift @@ -0,0 +1,30 @@ +// +// PackageManagerType.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +/// Package manager types supported by the system +enum PackageManagerType: String, Codable { + /// JavaScript + case npm + /// Rust + case cargo + /// Go + case golang + /// Python + case pip + /// Ruby + case gem + /// C# + case nuget + /// OCaml + case opam + /// PHP + case composer + /// Building from source + case sourceBuild + /// Binary download + case github +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift new file mode 100644 index 000000000..f03ee6a2d --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/CargoPackageManager.swift @@ -0,0 +1,84 @@ +// +// CargoPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +final class CargoPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + do { + try createDirectoryStructure(for: packagePath) + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + } + + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + var cargoArgs = ["cargo", "install", "--root", "."] + + // If this is a git-based package + if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { + cargoArgs.append(contentsOf: ["--git", repoUrl]) + switch gitRef { + case .tag(let tag): + cargoArgs.append(contentsOf: ["--tag", tag]) + case .revision(let rev): + cargoArgs.append(contentsOf: ["--rev", rev]) + } + } else { + cargoArgs.append("\(source.pkgName)@\(source.version)") + } + + if let features = source.options["features"] { + cargoArgs.append(contentsOf: ["--features", features]) + } + if source.options["locked"] == "true" { + cargoArgs.append("--locked") + } + + _ = try await executeInDirectory(in: packagePath.path, cargoArgs) + } catch { + throw error + } + } + + func getBinaryPath(for package: String) -> String { + return installationDirectory.appending(path: package).appending(path: "bin").path + } + + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("cargo --version") + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.starts(with: "cargo") + } catch { + return false + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift new file mode 100644 index 000000000..c91a8d1a7 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GithubPackageManager.swift @@ -0,0 +1,115 @@ +// +// GithubPackageManager.swift +// LSPInstallTest +// +// Created by Abe Malla on 3/10/25. +// + +import Foundation + +final class GithubPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + try createDirectoryStructure(for: packagePath) + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + func install(method: InstallationMethod) async throws { + switch method { + case let .binaryDownload(source, url): + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + try await downloadBinary(source, url) + + case let .sourceBuild(source, command): + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + try await installFromSource(source, command) + + case .standardPackage, .unknown: + throw PackageManagerError.invalidConfiguration + } + } + + func getBinaryPath(for package: String) -> String { + return installationDirectory.appending(path: package).appending(path: "bin").path + } + + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("git --version") + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.contains("git version") + } catch { + return false + } + } + + private func downloadBinary(_ source: PackageSource, _ url: URL) async throws { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw RegistryManagerError.downloadFailed( + url: url, + error: NSError(domain: "HTTP error", code: (response as? HTTPURLResponse)?.statusCode ?? -1) + ) + } + + let fileName = url.lastPathComponent + let downloadPath = installationDirectory.appending(path: source.entryName) + let packagePath = downloadPath.appending(path: fileName) + + do { + try data.write(to: packagePath, options: .atomic) + } catch { + throw RegistryManagerError.downloadFailed( + url: url, + error: error + ) + } + + if !FileManager.default.fileExists(atPath: packagePath.path) { + throw RegistryManagerError.downloadFailed( + url: url, + error: NSError(domain: "Could not download package", code: -1) + ) + } + + if fileName.hasSuffix(".tar") || fileName.hasSuffix(".zip") { + try FileManager.default.unzipItem(at: packagePath, to: downloadPath) + } + } + + private func installFromSource(_ source: PackageSource, _ command: String) async throws { + let installPath = installationDirectory.appending(path: source.entryName, directoryHint: .isDirectory) + do { + guard let repoURL = source.repositoryUrl else { + throw PackageManagerError.invalidConfiguration + } + + _ = try await executeInDirectory(in: installPath.path, ["git", "clone", repoURL]) + let repoPath = installPath.appending(path: source.pkgName, directoryHint: .isDirectory) + _ = try await executeInDirectory(in: repoPath.path, [command]) + } catch { + throw PackageManagerError.installationFailed("Source build failed.") + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift new file mode 100644 index 000000000..e30e02c3d --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/GolangPackageManager.swift @@ -0,0 +1,154 @@ +// +// GolangPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +final class GolangPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + try createDirectoryStructure(for: packagePath) + + // For Go, we need to set up a proper module structure + let goModPath = packagePath.appending(path: "go.mod") + if !FileManager.default.fileExists(atPath: goModPath.path) { + let moduleName = "codeedit.temp/placeholder" + _ = try await executeInDirectory( + in: packagePath.path, ["go mod init \(moduleName)"] + ) + } + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + let gobinPath = packagePath.appending(path: "bin", directoryHint: .isDirectory).path + var goInstallCommand = ["env", "GOBIN=\(gobinPath)", "go", "install"] + + goInstallCommand.append(getGoInstallCommand(source)) + _ = try await executeInDirectory(in: packagePath.path, goInstallCommand) + + // If there's a subpath, build the binary + if let subpath = source.options["subpath"] { + let binPath = packagePath.appending(path: "bin") + if !FileManager.default.fileExists(atPath: binPath.path) { + try FileManager.default.createDirectory(at: binPath, withIntermediateDirectories: true) + } + + let binaryName = subpath.components(separatedBy: "/").last ?? + source.pkgName.components(separatedBy: "/").last ?? source.pkgName + let buildArgs = ["go", "build", "-o", "bin/\(binaryName)"] + + // If source.pkgName includes the full import path (like github.com/owner/repo) + if source.pkgName.contains("/") { + _ = try await executeInDirectory( + in: packagePath.path, buildArgs + ["\(source.pkgName)/\(subpath)"] + ) + } else { + _ = try await executeInDirectory( + in: packagePath.path, buildArgs + [subpath] + ) + } + let execPath = packagePath.appending(path: "bin").appending(path: binaryName).path + _ = try await runCommand("chmod +x \"\(execPath)\"") + } + } catch { + try? cleanupFailedInstallation(packagePath: packagePath) + throw PackageManagerError.installationFailed(error.localizedDescription) + } + } + + /// Get the binary path for a Go package + func getBinaryPath(for package: String) -> String { + let binPath = installationDirectory.appending(path: package).appending(path: "bin") + let binaryName = package.components(separatedBy: "/").last ?? package + let specificBinPath = binPath.appending(path: binaryName).path + if FileManager.default.fileExists(atPath: specificBinPath) { + return specificBinPath + } + return binPath.path + } + + /// Check if go is installed + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("go version") + let versionPattern = #"go version go\d+\.\d+"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.range(of: versionPattern, options: .regularExpression) != nil + } catch { + return false + } + } + + // MARK: - Helper methods + + /// Clean up after a failed installation + private func cleanupFailedInstallation(packagePath: URL) throws { + let goSumPath = packagePath.appending(path: "go.sum") + if FileManager.default.fileExists(atPath: goSumPath.path) { + try FileManager.default.removeItem(at: goSumPath) + } + } + + /// Verify the go.mod file has the expected dependencies + private func verifyGoModDependencies(packagePath: URL, dependencyPath: String) async throws -> Bool { + let output = try await executeInDirectory( + in: packagePath.path, ["go list -m all"] + ) + + // Check if the dependency appears in the module list + return output.contains { line in + line.contains(dependencyPath) + } + } + + private func getGoInstallCommand(_ source: PackageSource) -> String { + if let gitRef = source.gitReference, let repoUrl = source.repositoryUrl { + // Check if this is a Git-based package + var packageName = source.pkgName + if !packageName.contains("github.com") && !packageName.contains("golang.org") { + packageName = repoUrl.replacingOccurrences(of: "https://", with: "") + } + + var gitVersion: String + switch gitRef { + case .tag(let tag): + gitVersion = tag + case .revision(let rev): + gitVersion = rev + } + + return "\(packageName)@\(gitVersion)" + } else { + return "\(source.pkgName)@\(source.version)" + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift new file mode 100644 index 000000000..e74470bb2 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/NPMPackageManager.swift @@ -0,0 +1,136 @@ +// +// NPMPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +final class NPMPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + /// Initializes the npm project if not already initialized + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + // Clean existing files + let pkgJson = packagePath.appending(path: "package.json") + if FileManager.default.fileExists(atPath: pkgJson.path) { + try FileManager.default.removeItem(at: pkgJson) + } + let pkgLockJson = packagePath.appending(path: "package-lock.json") + if FileManager.default.fileExists(atPath: pkgLockJson.path) { + try FileManager.default.removeItem(at: pkgLockJson) + } + + // Init npm directory with .npmrc file + try createDirectoryStructure(for: packagePath) + _ = try await executeInDirectory( + in: packagePath.path, ["npm init --yes --scope=codeedit"] + ) + + let npmrcPath = packagePath.appending(path: ".npmrc") + if !FileManager.default.fileExists(atPath: npmrcPath.path) { + try "install-strategy=shallow".write(to: npmrcPath, atomically: true, encoding: .utf8) + } + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + /// Install a package using the new installation method + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + var installArgs = ["npm", "install", "\(source.pkgName)@\(source.version)"] + if let dev = source.options["dev"], dev.lowercased() == "true" { + installArgs.append("--save-dev") + } + if let extraPackages = source.options["extraPackages"]?.split(separator: ",") { + for pkg in extraPackages { + installArgs.append(String(pkg).trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + _ = try await executeInDirectory(in: packagePath.path, installArgs) + try verifyInstallation(folderName: source.entryName, package: source.pkgName, version: source.version) + } catch { + let nodeModulesPath = packagePath.appending(path: "node_modules").path + try? FileManager.default.removeItem(atPath: nodeModulesPath) + throw error + } + } + + /// Get the path to the binary + func getBinaryPath(for package: String) -> String { + let binDirectory = installationDirectory + .appending(path: package) + .appending(path: "node_modules") + .appending(path: ".bin") + return binDirectory.appending(path: package).path + } + + /// Checks if npm is installed + func isInstalled() async -> Bool { + do { + let versionOutput = try await runCommand("npm --version") + let versionPattern = #"^\d+\.\d+\.\d+$"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + return output.range(of: versionPattern, options: .regularExpression) != nil + } catch { + return false + } + } + + /// Verify the installation was successful + private func verifyInstallation(folderName: String, package: String, version: String) throws { + let packagePath = installationDirectory.appending(path: folderName) + let packageJsonPath = packagePath.appending(path: "package.json").path + + // Verify package.json contains the installed package + guard let packageJsonData = FileManager.default.contents(atPath: packageJsonPath), + let packageJson = try? JSONSerialization.jsonObject(with: packageJsonData, options: []), + let packageDict = packageJson as? [String: Any], + let dependencies = packageDict["dependencies"] as? [String: String], + let installedVersion = dependencies[package] else { + throw PackageManagerError.installationFailed("Package not found in package.json") + } + + // Verify installed version matches requested version + let normalizedInstalledVersion = installedVersion.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) + let normalizedRequestedVersion = version.trimmingCharacters(in: CharacterSet(charactersIn: "^~")) + if normalizedInstalledVersion != normalizedRequestedVersion && + !installedVersion.contains(normalizedRequestedVersion) { + throw PackageManagerError.installationFailed( + "Version mismatch: Expected \(version), but found \(installedVersion)" + ) + } + + // Verify the package exists in node_modules + let packageDirectory = packagePath + .appending(path: "node_modules") + .appending(path: package) + guard FileManager.default.fileExists(atPath: packageDirectory.path) else { + throw PackageManagerError.installationFailed("Package not found in node_modules") + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift new file mode 100644 index 000000000..4eb5ff262 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageManagers/PipPackageManager.swift @@ -0,0 +1,147 @@ +// +// PipPackageManager.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +final class PipPackageManager: PackageManagerProtocol { + private let installationDirectory: URL + + let shellClient: ShellClient + + init(installationDirectory: URL) { + self.installationDirectory = installationDirectory + self.shellClient = .live() + } + + func initialize(in packagePath: URL) async throws { + guard await isInstalled() else { + throw PackageManagerError.packageManagerNotInstalled + } + + do { + try createDirectoryStructure(for: packagePath) + _ = try await executeInDirectory( + in: packagePath.path, ["python -m venv venv"] + ) + + let requirementsPath = packagePath.appending(path: "requirements.txt") + if !FileManager.default.fileExists(atPath: requirementsPath.path) { + try "# Package requirements\n".write(to: requirementsPath, atomically: true, encoding: .utf8) + } + } catch { + throw PackageManagerError.initializationFailed(error.localizedDescription) + } + } + + func install(method: InstallationMethod) async throws { + guard case .standardPackage(let source) = method else { + throw PackageManagerError.invalidConfiguration + } + + let packagePath = installationDirectory.appending(path: source.entryName) + try await initialize(in: packagePath) + + do { + let pipCommand = getPipCommand(in: packagePath) + var installArgs = [pipCommand, "install"] + + if source.version.lowercased() != "latest" { + installArgs.append("\(source.pkgName)==\(source.version)") + } else { + installArgs.append(source.pkgName) + } + + let extras = source.options["extra"] + if let extras { + if let lastIndex = installArgs.indices.last { + installArgs[lastIndex] += "[\(extras)]" + } + } + + _ = try await executeInDirectory(in: packagePath.path, installArgs) + try await updateRequirements(packagePath: packagePath) + try await verifyInstallation(packagePath: packagePath, package: source.pkgName) + } catch { + throw error + } + } + + /// Get the binary path for a Python package + func getBinaryPath(for package: String) -> String { + let packagePath = installationDirectory.appending(path: package) + let customBinPath = packagePath.appending(path: "bin").appending(path: package).path + if FileManager.default.fileExists(atPath: customBinPath) { + return customBinPath + } + return packagePath.appending(path: "venv").appending(path: "bin").appending(path: package).path + } + + func isInstalled() async -> Bool { + let pipCommands = ["pip3 --version", "python3 -m pip --version"] + for command in pipCommands { + do { + let versionOutput = try await runCommand(command) + let versionPattern = #"pip \d+\.\d+"# + let output = versionOutput.reduce(into: "") { + $0 += $1.trimmingCharacters(in: .whitespacesAndNewlines) + } + if output.range(of: versionPattern, options: .regularExpression) != nil { + return true + } + } catch { + continue + } + } + return false + } + + // MARK: - Helper methods + + private func getPipCommand(in packagePath: URL) -> String { + let venvPip = "venv/bin/pip" + return FileManager.default.fileExists(atPath: packagePath.appending(path: venvPip).path) + ? venvPip + : "python -m pip" + } + + /// Update the requirements.txt file with the installed package and extras + private func updateRequirements(packagePath: URL) async throws { + let pipCommand = getPipCommand(in: packagePath) + let requirementsPath = packagePath.appending(path: "requirements.txt") + + let freezeOutput = try await executeInDirectory( + in: packagePath.path, + ["\(pipCommand)", "freeze"] + ) + + let requirementsContent = freezeOutput.joined(separator: "\n") + "\n" + try requirementsContent.write(to: requirementsPath, atomically: true, encoding: .utf8) + } + + private func verifyInstallation(packagePath: URL, package: String) async throws { + let pipCommand = getPipCommand(in: packagePath) + let output = try await executeInDirectory( + in: packagePath.path, ["\(pipCommand)", "list", "--format=freeze"] + ) + + // Normalize package names for comparison + let normalizedPackageHyphen = package.replacingOccurrences(of: "_", with: "-").lowercased() + let normalizedPackageUnderscore = package.replacingOccurrences(of: "-", with: "_").lowercased() + + // Check if the package name appears in requirements.txt + let installedPackages = output.map { line in + line.lowercased().split(separator: "=").first?.trimmingCharacters(in: .whitespacesAndNewlines) + } + let packageFound = installedPackages.contains { installedPackage in + installedPackage == normalizedPackageHyphen || installedPackage == normalizedPackageUnderscore + } + + guard packageFound else { + throw PackageManagerError.installationFailed("Package \(package) not found in pip list") + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift new file mode 100644 index 000000000..0b81d6a97 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Cargo.swift @@ -0,0 +1,73 @@ +// +// PackageSourceParser+Cargo.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseCargoPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:cargo/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:cargo/" + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "cargo"] + var repositoryUrl: String? + var gitReference: PackageSource.GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + // If we have a repository URL but no git reference specified, + // default to tag for versions and revision for commit hashes + if repositoryUrl != nil, gitReference == nil { + if version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil { + gitReference = .revision(version) + } else { + gitReference = .tag(version) + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .cargo, + pkgName: packageName, + entryName: entry.name, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift new file mode 100644 index 000000000..1c2c7734a --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Gem.swift @@ -0,0 +1,63 @@ +// +// PackageSourceParser+Gem.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseRubyGem(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:gem/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:gem/" + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "gem"] + var repositoryUrl: String? + var gitReference: PackageSource.GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .gem, + pkgName: packageName, + entryName: entry.name, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift new file mode 100644 index 000000000..d75bf4970 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+Golang.swift @@ -0,0 +1,75 @@ +// +// PackageSourceParser+Golang.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseGolangPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:golang/PACKAGE@VERSION#SUBPATH?PARAMS + let pkgPrefix = "pkg:golang/" + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + // Extract subpath first if present + let subpathComponents = pkgString.split(separator: "#", maxSplits: 1) + let packageVersionParam = String(subpathComponents[0]) + let subpath = subpathComponents.count > 1 ? String(subpathComponents[1]) : nil + + // Then split into package@version and parameters + let components = packageVersionParam.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "golang"] + options["subpath"] = subpath + var repositoryUrl: String? + var gitReference: PackageSource.GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + // For Go packages, the package name is often also the repository URL + if repositoryUrl == nil { + repositoryUrl = "https://\(packageName)" + } + + let source = PackageSource( + sourceId: sourceId, + type: .golang, + pkgName: packageName, + entryName: entry.name, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift new file mode 100644 index 000000000..b5a63bb9e --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser+NPM.swift @@ -0,0 +1,88 @@ +// +// PackageSourceParser+NPM.swift +// CodeEdit +// +// Created by Abe Malla on 3/12/25. +// + +extension PackageSourceParser { + static func parseNpmPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:npm/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:npm/" + let sourceId = entry.source.id.removingPercentEncoding ?? entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + // Split into package@version and parameters + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let (packageName, version) = parseNPMPackageNameAndVersion(packageVersion) + + // Parse parameters as options + var options: [String: String] = ["buildTool": "npm"] + var repositoryUrl: String? + var gitReference: PackageSource.GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .npm, + pkgName: packageName, + entryName: entry.name, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } + + private static func parseNPMPackageNameAndVersion(_ packageVersion: String) -> (String, String) { + var packageName: String + var version: String = "latest" + + if packageVersion.contains("@") && !packageVersion.hasPrefix("@") { + // Regular package with version: package@1.0.0 + let parts = packageVersion.split(separator: "@", maxSplits: 1) + packageName = String(parts[0]) + if parts.count > 1 { + version = String(parts[1]) + } + } else if packageVersion.hasPrefix("@") { + // Scoped package: @org/package@1.0.0 + if let atIndex = packageVersion[ + packageVersion.index(after: packageVersion.startIndex)... + ].firstIndex(of: "@") { + packageName = String(packageVersion[.. InstallationMethod { + // Format: pkg:pypi/PACKAGE@VERSION?PARAMS + let pkgPrefix = "pkg:pypi/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + + let components = pkgString.split(separator: "?", maxSplits: 1) + let packageVersion = String(components[0]) + let parameters = components.count > 1 ? String(components[1]) : "" + + let packageVersionParts = packageVersion.split(separator: "@", maxSplits: 1) + guard packageVersionParts.count >= 1 else { return .unknown } + + let packageName = String(packageVersionParts[0]) + let version = packageVersionParts.count > 1 ? String(packageVersionParts[1]) : "latest" + + // Parse parameters as options + var options: [String: String] = ["buildTool": "pip"] + var repositoryUrl: String? + var gitReference: PackageSource.GitReference? + + let paramPairs = parameters.split(separator: "&") + for pair in paramPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + guard keyValue.count == 2 else { continue } + + let key = String(keyValue[0]) + let value = String(keyValue[1]) + + if key == "repository_url" { + repositoryUrl = value + } else if key == "rev" && value.lowercased() == "true" { + gitReference = .revision(version) + } else if key == "tag" && value.lowercased() == "true" { + gitReference = .tag(version) + } else { + options[key] = value + } + } + + let source = PackageSource( + sourceId: sourceId, + type: .pip, + pkgName: packageName, + entryName: entry.name, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: options + ) + return .standardPackage(source: source) + } +} diff --git a/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift new file mode 100644 index 000000000..666f05a73 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/PackageSourceParser/PackageSourceParser.swift @@ -0,0 +1,98 @@ +// +// PackageSourceParser.swift +// CodeEdit +// +// Created by Abe Malla on 2/3/25. +// + +import Foundation + +/// Parser for package source IDs +enum PackageSourceParser { + static func parseGithubPackage(_ entry: RegistryItem) -> InstallationMethod { + // Format: pkg:github/OWNER/REPO@COMMIT_HASH + let pkgPrefix = "pkg:github/" + let sourceId = entry.source.id + guard sourceId.hasPrefix(pkgPrefix) else { return .unknown } + + let pkgString = sourceId.dropFirst(pkgPrefix.count) + let packagePathVersion = pkgString.split(separator: "@", maxSplits: 1) + guard packagePathVersion.count >= 1 else { return .unknown } + + let packagePath = String(packagePathVersion[0]) + let version = packagePathVersion.count > 1 ? String(packagePathVersion[1]) : "main" + + let pathComponents = packagePath.split(separator: "/") + guard pathComponents.count >= 2 else { return .unknown } + + let owner = String(pathComponents[0]) + let repo = String(pathComponents[1]) + let packageName = repo + let repositoryUrl = "https://github.com/\(owner)/\(repo)" + + let isCommitHash = version.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil + let gitReference: PackageSource.GitReference = isCommitHash ? .revision(version) : .tag(version) + + // Is this going to be built from source or downloaded + let isSourceBuild = if entry.source.asset == nil { + true + } else { + false + } + + var source = PackageSource( + sourceId: sourceId, + type: isSourceBuild ? .sourceBuild : .github, + pkgName: packageName, + entryName: entry.name, + version: version, + repositoryUrl: repositoryUrl, + gitReference: gitReference, + options: [:] + ) + if isSourceBuild { + return parseGithubSourceBuild(source, entry) + } else { + return parseGithubBinaryDownload(source, entry) + } + } + + private static func parseGithubBinaryDownload( + _ pkgSource: PackageSource, + _ entry: RegistryItem + ) -> InstallationMethod { + guard let assetContainer = entry.source.asset, + let repoURL = pkgSource.repositoryUrl, + case .tag(let gitTag) = pkgSource.gitReference, + var fileName = assetContainer.getDarwinFileName(), + !fileName.isEmpty + else { + return .unknown + } + + do { + var registryInfo = try entry.toDictionary() + registryInfo["version"] = pkgSource.version + fileName = try RegistryItemTemplateParser.process( + template: fileName, with: registryInfo + ) + } catch { + return .unknown + } + + let downloadURL = URL(string: "\(repoURL)/releases/download/\(gitTag)/\(fileName)")! + return .binaryDownload(source: pkgSource, url: downloadURL) + } + + private static func parseGithubSourceBuild( + _ pkgSource: PackageSource, + _ entry: RegistryItem + ) -> InstallationMethod { + guard let build = entry.source.build, + let command = build.getUnixBuildCommand() + else { + return .unknown + } + return .sourceBuild(source: pkgSource, command: command) + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift new file mode 100644 index 000000000..16d171b62 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryItemTemplateParser.swift @@ -0,0 +1,133 @@ +// +// RegistryItemTemplateParser.swift +// CodeEdit +// +// Created by Abe Malla on 3/9/25. +// + +import Foundation + +/// This parser is used to parse expressions that may be included in a field of a registry item. +/// +/// Example: +/// "protolint_{{ version | strip_prefix \"v\" }}_darwin_arm64.tar.gz" will be parsed into: +/// protolint_0.53.0_darwin_arm64.tar.gz +enum RegistryItemTemplateParser { + + enum TemplateError: Error { + case invalidFilter(String) + case missingVariable(String) + case invalidPath(String) + case missingKey(String) + } + + private enum Filter { + case stripPrefix(String) + + static func parse(_ filterString: String) throws -> Filter { + let components = filterString.trimmingCharacters(in: .whitespaces).components(separatedBy: " ") + if components.count >= 2 && components[0] == "strip_prefix" { + // Extract the quoted string value + let prefixRaw = components[1] + if prefixRaw.hasPrefix("\"") && prefixRaw.hasSuffix("\"") { + let prefix = String(prefixRaw.dropFirst().dropLast()) + return .stripPrefix(prefix) + } + } + throw TemplateError.invalidFilter(filterString) + } + + func apply(to value: String) -> String { + switch self { + case .stripPrefix(let prefix): + if value.hasPrefix(prefix) { + return String(value.dropFirst(prefix.count)) + } + return value + } + } + } + + static func process(template: String, with context: [String: Any]) throws -> String { + var result = template + + // Find all {{ ... }} patterns + let pattern = "\\{\\{([^\\}]+)\\}\\}" + let regex = try NSRegularExpression(pattern: pattern, options: []) + let matches = regex.matches( + in: template, + options: [], + range: NSRange(location: 0, length: template.utf16.count) + ) + + // Process matches in reverse order to not invalidate ranges + for match in matches.reversed() { + guard Range(match.range, in: template) != nil else { continue } + + // Extract the content between {{ and }} + let expressionRange = Range(match.range(at: 1), in: template)! + let expression = String(template[expressionRange]) + + // Split by pipe to separate variable path from filters + let components = expression.components(separatedBy: "|").filter { !$0.isEmpty } + let pathExpression = components[0].trimmingCharacters(in: .whitespaces) + let value = try getValueFromPath(pathExpression, in: context) + + // Apply filters + var processedValue = value + if components.count > 1 { + for item in 1.. String { + let pathComponents = path.components(separatedBy: ".") + var currentValue: Any = context + + for component in pathComponents { + if let dict = currentValue as? [String: Any] { + if let value = dict[component] { + currentValue = value + } else { + throw TemplateError.missingKey(component) + } + } else if let array = currentValue as? [Any], let index = Int(component) { + if index >= 0 && index < array.count { + currentValue = array[index] + } else { + throw TemplateError.invalidPath("Array index out of bounds: \(component)") + } + } else { + throw TemplateError.invalidPath("Cannot access component: \(component)") + } + } + + // Convert the final value to a string + if let stringValue = currentValue as? String { + return stringValue + } else if let intValue = currentValue as? Int { + return String(intValue) + } else if let doubleValue = currentValue as? Double { + return String(doubleValue) + } else if let boolValue = currentValue as? Bool { + return String(boolValue) + } else if currentValue is [Any] || currentValue is [String: Any] { + throw TemplateError.invalidPath("Path resolves to a complex object, not a simple value") + } else { + return String(describing: currentValue) + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift new file mode 100644 index 000000000..9a3b5621d --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+HandleRegistryFile.swift @@ -0,0 +1,139 @@ +// +// RegistryManager+HandleRegistryFile.swift +// CodeEdit +// +// Created by Abe Malla on 3/14/25. +// + +import Foundation + +extension RegistryManager { + /// Downloads the latest registry + func update() async { + // swiftlint:disable:next large_tuple + let result = await Task.detached(priority: .userInitiated) { () -> ( + registryData: Data?, checksumData: Data?, error: Error? + ) in + do { + async let zipDataTask = Self.download(from: self.registryURL) + async let checksumsTask = Self.download(from: self.checksumURL) + + let (registryData, checksumData) = try await (zipDataTask, checksumsTask) + return (registryData, checksumData, nil) + } catch { + return (nil, nil, error) + } + }.value + + if let error = result.error { + handleUpdateError(error) + return + } + + guard let registryData = result.registryData, let checksumData = result.checksumData else { + return + } + + do { + // Make sure the extensions folder exists first + try FileManager.default.createDirectory(at: installPath, withIntermediateDirectories: true) + + let tempZipURL = installPath.appending(path: "temp.zip") + let checksumDestination = installPath.appending(path: "checksums.txt") + + // Delete existing zip data if it exists + if FileManager.default.fileExists(atPath: tempZipURL.path) { + try FileManager.default.removeItem(at: tempZipURL) + } + let registryJsonPath = installPath.appending(path: "registry.json").path + if FileManager.default.fileExists(atPath: registryJsonPath) { + try FileManager.default.removeItem(atPath: registryJsonPath) + } + + // Write the zip data to a temporary file, then unzip + try registryData.write(to: tempZipURL) + try FileManager.default.unzipItem(at: tempZipURL, to: installPath) + try FileManager.default.removeItem(at: tempZipURL) + + try checksumData.write(to: checksumDestination) + + NotificationCenter.default.post(name: .RegistryUpdatedNotification, object: nil) + } catch { + logger.error("Error updating: \(error)") + handleUpdateError(RegistryManagerError.writeFailed(error: error)) + } + } + + func handleUpdateError(_ error: Error) { + if let regError = error as? RegistryManagerError { + switch regError { + case .invalidResponse(let statusCode): + logger.error("Invalid response received: \(statusCode)") + case let .downloadFailed(url, error): + logger.error("Download failed for \(url.absoluteString): \(error.localizedDescription)") + case let .maxRetriesExceeded(url, error): + logger.error("Max retries exceeded for \(url.absoluteString): \(error.localizedDescription)") + case let .writeFailed(error): + logger.error("Failed to write files to disk: \(error.localizedDescription)") + } + } else { + logger.error("Unexpected registry error: \(error.localizedDescription)") + } + } + + /// Attempts downloading from `url`, with error handling and a retry policy + static func download(from url: URL, attempt: Int = 1) async throws -> Data { + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw RegistryManagerError.downloadFailed( + url: url, error: NSError(domain: "Invalid response type", code: -1) + ) + } + guard (200...299).contains(httpResponse.statusCode) else { + throw RegistryManagerError.invalidResponse(statusCode: httpResponse.statusCode) + } + + return data + } catch { + if attempt <= 3 { + let delay = pow(2.0, Double(attempt)) + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + return try await download(from: url, attempt: attempt + 1) + } else { + throw RegistryManagerError.maxRetriesExceeded(url: url, lastError: error) + } + } + } + + /// Loads registry items from disk + func loadItemsFromDisk() -> [RegistryItem]? { + let registryPath = installPath.appending(path: "registry.json") + let fileManager = FileManager.default + + // Update the file every 24 hours + let needsUpdate = !fileManager.fileExists(atPath: registryPath.path) || { + guard let attributes = try? fileManager.attributesOfItem(atPath: registryPath.path), + let modificationDate = attributes[.modificationDate] as? Date else { + return true + } + let hoursSinceLastUpdate = Date().timeIntervalSince(modificationDate) / 3600 + return hoursSinceLastUpdate >= 24 + }() + + if needsUpdate { + Task { await update() } + return nil + } + + do { + let registryData = try Data(contentsOf: registryPath) + let items = try JSONDecoder().decode([RegistryItem].self, from: registryData) + return items.filter { $0.categories.contains("LSP") } + } catch { + Task { await update() } + return nil + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift b/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift new file mode 100644 index 000000000..06bddba5a --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager+Parsing.swift @@ -0,0 +1,30 @@ +// +// RegistryManager+Parsing.swift +// CodeEdit +// +// Created by Abe Malla on 3/14/25. +// + +import Foundation + +extension RegistryManager { + /// Parse a registry entry and create the appropriate installation method + internal static func parseRegistryEntry(_ entry: RegistryItem) -> InstallationMethod { + let sourceId = entry.source.id + if sourceId.hasPrefix("pkg:cargo/") { + return PackageSourceParser.parseCargoPackage(entry) + } else if sourceId.hasPrefix("pkg:npm/") { + return PackageSourceParser.parseNpmPackage(entry) + } else if sourceId.hasPrefix("pkg:pypi/") { + return PackageSourceParser.parsePythonPackage(entry) + } else if sourceId.hasPrefix("pkg:gem/") { + return PackageSourceParser.parseRubyGem(entry) + } else if sourceId.hasPrefix("pkg:golang/") { + return PackageSourceParser.parseGolangPackage(entry) + } else if sourceId.hasPrefix("pkg:github/") { + return PackageSourceParser.parseGithubPackage(entry) + } else { + return .unknown + } + } +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManager.swift b/CodeEdit/Features/LSP/Registry/RegistryManager.swift new file mode 100644 index 000000000..a2d0945c5 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManager.swift @@ -0,0 +1,223 @@ +// +// Registry.swift +// CodeEdit +// +// Created by Abe Malla on 1/29/25. +// + +import OSLog +import Foundation +import ZIPFoundation + +@MainActor +final class RegistryManager { + static let shared: RegistryManager = .init() + + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "RegistryManager") + + let installPath = Settings.shared.baseURL.appending(path: "Language Servers") + + /// The URL of where the registry.json file will be downloaded from + let registryURL = URL( + string: "https://github.com/mason-org/mason-registry/releases/latest/download/registry.json.zip" + )! + /// The URL of where the checksums.txt file will be downloaded from + let checksumURL = URL( + string: "https://github.com/mason-org/mason-registry/releases/latest/download/checksums.txt" + )! + + /// Reference to cached registry data. Will be removed from memory after a certain amount of time. + private var cachedRegistry: CachedRegistry? + /// Timer to clear expired cache + private var cleanupTimer: Timer? + /// Public access to registry items with cache management + public var registryItems: [RegistryItem] { + if let cache = cachedRegistry, !cache.isExpired { + return cache.items + } + + // Load the registry items from disk again after cache expires + if let items = loadItemsFromDisk() { + cachedRegistry = CachedRegistry(items: items) + + // Set up timer to clear the cache after expiration + cleanupTimer?.invalidate() + cleanupTimer = Timer.scheduledTimer( + withTimeInterval: CachedRegistry.expirationInterval, repeats: false + ) { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + self.cachedRegistry = nil + self.cleanupTimer = nil + } + } + return items + } + + return [] + } + + @AppSettings(\.languageServers.installedLanguageServers) + var installedLanguageServers: [String: SettingsData.InstalledLanguageServer] + + deinit { + cleanupTimer?.invalidate() + } + + func installPackage(package entry: RegistryItem) async throws { + return try await Task.detached(priority: .userInitiated) { () in + let method = await Self.parseRegistryEntry(entry) + guard let manager = await self.createPackageManager(for: method) else { + throw PackageManagerError.invalidConfiguration + } + + // Add to activity viewer + let activityTitle = "\(entry.name)\("@" + (method.version ?? "latest"))" + await MainActor.run { + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": entry.name, + "action": "create", + "title": "Installing \(activityTitle)" + ] + ) + } + + do { + try await manager.install(method: method) + } catch { + await MainActor.run { + Self.updateActivityViewer(entry.name, activityTitle, fail: true) + } + // Throw error again so the UI can catch it + throw error + } + + // Update settings on the main thread + await MainActor.run { + self.installedLanguageServers[entry.name] = .init( + packageName: entry.name, + isEnabled: true, + version: method.version ?? "" + ) + Self.updateActivityViewer(entry.name, activityTitle, fail: false) + } + }.value + } + + @MainActor + func removeLanguageServer(packageName: String) async throws { + let packageName = packageName.removingPercentEncoding ?? packageName + let packageDirectory = installPath.appending(path: packageName) + + guard FileManager.default.fileExists(atPath: packageDirectory.path) else { + installedLanguageServers.removeValue(forKey: packageName) + return + } + + // Add to activity viewer + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": packageName, + "action": "create", + "title": "Removing \(packageName)" + ] + ) + + do { + try await Task.detached(priority: .userInitiated) { + try FileManager.default.removeItem(at: packageDirectory) + }.value + installedLanguageServers.removeValue(forKey: packageName) + } catch { + throw error + } + } + + /// Updates the activity viewer with the status of the language server installation + @MainActor + private static func updateActivityViewer( + _ id: String, + _ activityName: String, + fail failed: Bool + ) { + if failed { + NotificationManager.shared.post( + iconSymbol: "xmark.circle", + iconColor: .clear, + title: "Could not install \(activityName)", + description: "There was a problem during installation.", + actionButtonTitle: "Done", + action: {}, + ) + } else { + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": id, + "action": "update", + "title": "Successfully installed \(activityName)", + "isLoading": false + ] + ) + NotificationCenter.default.post( + name: .taskNotification, + object: nil, + userInfo: [ + "id": id, + "action": "deleteWithDelay", + "delay": 5.0, + ] + ) + } + } + + /// Create the appropriate package manager for the given installation method + func createPackageManager(for method: InstallationMethod) -> PackageManagerProtocol? { + switch method.packageManagerType { + case .npm: + return NPMPackageManager(installationDirectory: installPath) + case .cargo: + return CargoPackageManager(installationDirectory: installPath) + case .pip: + return PipPackageManager(installationDirectory: installPath) + case .golang: + return GolangPackageManager(installationDirectory: installPath) + case .github, .sourceBuild: + return GithubPackageManager(installationDirectory: installPath) + case .nuget, .opam, .gem, .composer: + // TODO: IMPLEMENT OTHER PACKAGE MANAGERS + return nil + case .none: + return nil + } + } +} + +/// `CachedRegistry` is a timer based cache that will remove the registry items from memory +/// after a certain amount of time. This is because this memory is not needed for the majority of the +/// lifetime of the application and can be freed when no longer used. +private final class CachedRegistry { + let items: [RegistryItem] + let timestamp: Date + + static let expirationInterval: TimeInterval = 300 // 5 minutes + + init(items: [RegistryItem]) { + self.items = items + self.timestamp = Date() + } + + var isExpired: Bool { + Date().timeIntervalSince(timestamp) > Self.expirationInterval + } +} + +extension Notification.Name { + static let RegistryUpdatedNotification = Notification.Name("registryUpdatedNotification") +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift b/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift new file mode 100644 index 000000000..ac0da00b3 --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryManagerError.swift @@ -0,0 +1,15 @@ +// +// RegistryManagerError.swift +// CodeEdit +// +// Created by Abe Malla on 5/12/25. +// + +import Foundation + +enum RegistryManagerError: Error { + case invalidResponse(statusCode: Int) + case downloadFailed(url: URL, error: Error) + case maxRetriesExceeded(url: URL, lastError: Error) + case writeFailed(error: Error) +} diff --git a/CodeEdit/Features/LSP/Registry/RegistryPackage.swift b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift new file mode 100644 index 000000000..f610d349f --- /dev/null +++ b/CodeEdit/Features/LSP/Registry/RegistryPackage.swift @@ -0,0 +1,257 @@ +// +// RegistryPackage.swift +// CodeEdit +// +// Created by Abe Malla on 1/29/25. +// + +import Foundation + +/// A `RegistryItem` represents an entry in the Registry that saves language servers, DAPs, linters and formatters. +struct RegistryItem: Codable { + let name: String + let description: String + let homepage: String + let licenses: [String] + let languages: [String] + let categories: [String] + let source: Source + let bin: [String: String]? + + struct Source: Codable { + let id: String + let asset: AssetContainer? + let build: BuildContainer? + let versionOverrides: [VersionOverride]? + + enum AssetContainer: Codable { + case single(Asset) + case multiple([Asset]) + case simpleFile(String) + case none + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer() { + if let singleValue = try? container.decode(Asset.self) { + self = .single(singleValue) + return + } else if let multipleValues = try? container.decode([Asset].self) { + self = .multiple(multipleValues) + return + } else if let simpleFile = try? container.decode([String: String].self), + simpleFile.count == 1, + simpleFile.keys.contains("file"), + let file = simpleFile["file"] { + self = .simpleFile(file) + return + } + } + self = .none + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + case .simpleFile(let file): + try container.encode(["file": file]) + case .none: + try container.encodeNil() + } + } + + func getDarwinFileName() -> String? { + switch self { + case .single(let asset): + if asset.target.isDarwinTarget() { + return asset.file + } + + case .multiple(let assets): + for asset in assets where asset.target.isDarwinTarget() { + return asset.file + } + + case .simpleFile(let fileName): + return fileName + + case .none: + return nil + } + return nil + } + } + + enum BuildContainer: Codable { + case single(Build) + case multiple([Build]) + case none + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer() { + if let singleValue = try? container.decode(Build.self) { + self = .single(singleValue) + return + } else if let multipleValues = try? container.decode([Build].self) { + self = .multiple(multipleValues) + return + } + } + self = .none + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + case .none: + try container.encodeNil() + } + } + + func getUnixBuildCommand() -> String? { + switch self { + case .single(let build): + return build.run + case .multiple(let builds): + for build in builds { + guard let target = build.target else { continue } + if target.isDarwinTarget() { + return build.run + } + } + case .none: + return nil + } + return nil + } + } + + struct Build: Codable { + let target: Target? + let run: String + let env: [String: String]? + let bin: BinContainer? + } + + struct Asset: Codable { + let target: Target + let file: String? + let bin: BinContainer? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.target = try container.decode(Target.self, forKey: .target) + self.file = try container.decodeIfPresent(String.self, forKey: .file) + self.bin = try container.decodeIfPresent(BinContainer.self, forKey: .bin) + } + } + + enum Target: Codable { + case single(String) + case multiple([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let singleValue = try? container.decode(String.self) { + self = .single(singleValue) + } else if let multipleValues = try? container.decode([String].self) { + self = .multiple(multipleValues) + } else { + throw DecodingError.typeMismatch( + Target.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid target format" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + } + } + + func isDarwinTarget() -> Bool { + switch self { + case .single(let value): +#if arch(arm64) + return value == "darwin" || value == "darwin_arm64" || value == "unix" +#else + return value == "darwin" || value == "darwin_x64" || value == "unix" +#endif + case .multiple(let values): +#if arch(arm64) + return values.contains("darwin") || + values.contains("darwin_arm64") || + values.contains("unix") +#else + return values.contains("darwin") || + values.contains("darwin_x64") || + values.contains("unix") +#endif + } + } + } + + enum BinContainer: Codable { + case single(String) + case multiple([String: String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let singleValue = try? container.decode(String.self) { + self = .single(singleValue) + } else if let dictValue = try? container.decode([String: String].self) { + self = .multiple(dictValue) + } else { + throw DecodingError.typeMismatch( + BinContainer.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid bin format" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .single(let value): + try container.encode(value) + case .multiple(let values): + try container.encode(values) + } + } + } + + struct VersionOverride: Codable { + let constraint: String + let id: String + let asset: AssetContainer? + } + } + + /// Serializes back to JSON format + func toDictionary() throws -> [String: Any] { + let data = try JSONEncoder().encode(self) + let jsonObject = try JSONSerialization.jsonObject(with: data) + guard let dictionary = jsonObject as? [String: Any] else { + throw NSError(domain: "ConversionError", code: 1) + } + return dictionary + } +} diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index df74fb139..d14d1a70a 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -7,6 +7,7 @@ import os.log import JSONRPC +import SwiftUI import Foundation import LanguageClient import LanguageServerProtocol @@ -123,6 +124,9 @@ final class LSPService: ObservableObject { @AppSettings(\.developerSettings.lspBinaries) var lspBinaries + @Environment(\.openWindow) + private var openWindow + init() { // Load the LSP binaries from the developer menu for binary in lspBinaries { @@ -221,6 +225,7 @@ final class LSPService: ObservableObject { languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) } } catch { + notifyToInstallLanguageServer(language: lspLanguage) // swiftlint:disable:next line_length self.logger.error("Failed to find/start server for language: \(lspLanguage.rawValue), workspace: \(workspacePath, privacy: .private)") return @@ -319,3 +324,36 @@ final class LSPService: ObservableObject { eventListeningTasks.removeAll() } } + +extension LSPService { + private func notifyToInstallLanguageServer(language lspLanguage: LanguageIdentifier) { + let lspLanguageTitle = lspLanguage.rawValue.capitalized + let notificationTitle = "Install \(lspLanguageTitle) Language Server" + // Make sure the user doesn't have the same existing notification + guard !NotificationManager.shared.notifications.contains(where: { $0.title == notificationTitle }) else { + return + } + + NotificationManager.shared.post( + iconSymbol: "arrow.down.circle", + iconColor: .clear, + title: notificationTitle, + description: "Install the \(lspLanguageTitle) language server to enable code intelligence features.", + actionButtonTitle: "Install" + ) { [weak self] in + // TODO: Warning: + // Accessing Environment's value outside of being installed on a View. + // This will always read the default value and will not update + self?.openWindow(sceneID: .settings) + } + } +} + +// MARK: - Errors + +enum ServerManagerError: Error { + case serverNotFound + case serverStartFailed + case serverStopFailed + case languageClientNotFound +} diff --git a/CodeEdit/Features/Settings/Models/SettingsData.swift b/CodeEdit/Features/Settings/Models/SettingsData.swift index 7ba65b699..cd860c7e4 100644 --- a/CodeEdit/Features/Settings/Models/SettingsData.swift +++ b/CodeEdit/Features/Settings/Models/SettingsData.swift @@ -50,6 +50,9 @@ struct SettingsData: Codable, Hashable { /// Search Settings var search: SearchSettings = .init() + /// Language Server Settings + var languageServers: LanguageServerSettings = .init() + /// Developer settings for CodeEdit developers var developerSettings: DeveloperSettings = .init() @@ -74,6 +77,9 @@ struct SettingsData: Codable, Hashable { KeybindingsSettings.self, forKey: .keybindings ) ?? .init() + self.languageServers = try container.decodeIfPresent( + LanguageServerSettings.self, forKey: .languageServers + ) ?? .init() self.developerSettings = try container.decodeIfPresent( DeveloperSettings.self, forKey: .developerSettings ) ?? .init() @@ -102,6 +108,10 @@ struct SettingsData: Codable, Hashable { sourceControl.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .location: LocationsSettings().searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } + case .languageServers: + LanguageServerSettings().searchKeys.forEach { + settings.append(.init(name, isSetting: true, settingName: $0)) + } case .developer: developerSettings.searchKeys.forEach { settings.append(.init(name, isSetting: true, settingName: $0)) } case .behavior: return [.init(name, settingName: "Error")] diff --git a/CodeEdit/Features/Settings/Models/SettingsPage.swift b/CodeEdit/Features/Settings/Models/SettingsPage.swift index ff45c21a0..597532b55 100644 --- a/CodeEdit/Features/Settings/Models/SettingsPage.swift +++ b/CodeEdit/Features/Settings/Models/SettingsPage.swift @@ -32,6 +32,7 @@ struct SettingsPage: Hashable, Equatable, Identifiable { case components = "Components" case location = "Locations" case advanced = "Advanced" + case languageServers = "Language Servers" case developer = "Developer" } diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift new file mode 100644 index 000000000..b1cdb7c6f --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServerRowView.swift @@ -0,0 +1,255 @@ +// +// LanguageServerRowView.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import SwiftUI + +private let iconSize: CGFloat = 26 + +struct LanguageServerRowView: View, Equatable { + let packageName: String + let subtitle: String + let onCancel: (() -> Void) + let onInstall: (() async -> Void) + + private let cleanedTitle: String + private let cleanedSubtitle: String + + @State private var isHovering: Bool = false + @State private var installationStatus: PackageInstallationStatus = .notQueued + @State private var isInstalled: Bool = false + @State private var isEnabled = false + @State private var showingRemovalConfirmation = false + @State private var isRemoving = false + @State private var removalError: Error? + @State private var showingRemovalError = false + + init( + packageName: String, + subtitle: String, + isInstalled: Bool = false, + isEnabled: Bool = false, + onCancel: @escaping (() -> Void), + onInstall: @escaping () async -> Void + ) { + self.packageName = packageName + self.subtitle = subtitle + self.isInstalled = isInstalled + self.isEnabled = isEnabled + self.onCancel = onCancel + self.onInstall = onInstall + + self.cleanedTitle = packageName + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word -> String in + let str = String(word).lowercased() + // Check for special cases + if str == "ls" || str == "lsp" || str == "ci" || str == "cli" { + return str.uppercased() + } + return str.capitalized + } + .joined(separator: " ") + self.cleanedSubtitle = subtitle.replacingOccurrences(of: "\n", with: " ") + } + + var body: some View { + HStack { + Label { + VStack(alignment: .leading) { + Text(cleanedTitle) + Text(cleanedSubtitle) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } icon: { + letterIcon() + } + .opacity(isInstalled && !isEnabled ? 0.5 : 1.0) + + Spacer() + + installationButton() + } + .onHover { hovering in + isHovering = hovering + } + .onAppear { + // Check if this package is already in the installation queue + installationStatus = InstallationQueueManager.shared.getInstallationStatus(packageName: packageName) + } + .onReceive(NotificationCenter.default.publisher(for: .installationStatusChanged)) { notification in + if let notificationPackageName = notification.userInfo?["packageName"] as? String, + notificationPackageName == packageName, + let status = notification.userInfo?["status"] as? PackageInstallationStatus { + installationStatus = status + if case .installed = status { + isInstalled = true + isEnabled = true + } + } + } + .alert("Remove \(cleanedTitle)?", isPresented: $showingRemovalConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Remove", role: .destructive) { + removeLanguageServer() + } + } message: { + Text("Are you sure you want to remove this language server? This action cannot be undone.") + } + .alert("Removal Failed", isPresented: $showingRemovalError) { + Button("OK", role: .cancel) { } + } message: { + Text(removalError?.localizedDescription ?? "An unknown error occurred") + } + } + + @ViewBuilder + private func installationButton() -> some View { + if isInstalled { + installedRow() + } else { + switch installationStatus { + case .installing, .queued: + isInstallingRow() + case .failed: + failedRow() + default: + if isHovering { + isHoveringRow() + } + } + } + } + + @ViewBuilder + private func installedRow() -> some View { + HStack { + if isRemoving { + ProgressView() + .controlSize(.small) + } else if isHovering { + Button { + showingRemovalConfirmation = true + } label: { + Text("Remove") + } + } + Toggle("", isOn: $isEnabled) + .onChange(of: isEnabled) { newValue in + RegistryManager.shared.installedLanguageServers[packageName]?.isEnabled = newValue + } + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + } + + @ViewBuilder + private func isInstallingRow() -> some View { + HStack { + if case .queued = installationStatus { + Text("Queued") + .font(.caption) + .foregroundColor(.secondary) + } + + ZStack { + CECircularProgressView() + .frame(width: 20, height: 20) + Button { + InstallationQueueManager.shared.cancelInstallation(packageName: packageName) + onCancel() + } label: { + Image(systemName: "stop.fill") + .font(.system(size: 8)) + .foregroundColor(.blue) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + } + } + + @ViewBuilder + private func failedRow() -> some View { + Button { + // Reset status and retry installation + installationStatus = .notQueued + Task { + await onInstall() + } + } label: { + Text("Retry") + .foregroundColor(.red) + } + } + + @ViewBuilder + private func isHoveringRow() -> some View { + Button { + Task { + await onInstall() + } + } label: { + Text("Install") + } + } + + @ViewBuilder + private func letterIcon() -> some View { + RoundedRectangle(cornerRadius: iconSize / 4, style: .continuous) + .fill(background) + .overlay { + Text(String(cleanedTitle.first ?? Character(""))) + .font(.system(size: iconSize * 0.65)) + .foregroundColor(.primary) + } + .clipShape(RoundedRectangle(cornerRadius: iconSize / 4, style: .continuous)) + .shadow( + color: Color(NSColor.black).opacity(0.25), + radius: iconSize / 40, + y: iconSize / 40 + ) + .frame(width: iconSize, height: iconSize) + } + + private func removeLanguageServer() { + isRemoving = true + Task { + do { + try await RegistryManager.shared.removeLanguageServer(packageName: packageName) + await MainActor.run { + isRemoving = false + isInstalled = false + isEnabled = false + } + } catch { + await MainActor.run { + isRemoving = false + removalError = error + showingRemovalError = true + } + } + } + } + + private var background: AnyShapeStyle { + let colors: [Color] = [ + .blue, .green, .orange, .red, .purple, .pink, .teal, .yellow, .indigo, .cyan + ] + let hashValue = abs(cleanedTitle.hash) % colors.count + return AnyShapeStyle(colors[hashValue].gradient) + } + + static func == (lhs: LanguageServerRowView, rhs: LanguageServerRowView) -> Bool { + lhs.packageName == rhs.packageName && lhs.subtitle == rhs.subtitle + } +} diff --git a/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift new file mode 100644 index 000000000..09c977a9e --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/LanguageServersView.swift @@ -0,0 +1,86 @@ +// +// ExtensionsSettingsView.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import SwiftUI + +struct LanguageServersView: View { + @State private var didError = false + @State private var installationFailure: InstallationFailure? + @State private var registryItems: [RegistryItem] = [] + @State private var isLoading = true + + var body: some View { + SettingsForm { + if isLoading { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + } else { + Section { + List(registryItems, id: \.name) { item in + LanguageServerRowView( + packageName: item.name, + subtitle: item.description, + isInstalled: RegistryManager.shared.installedLanguageServers[item.name] != nil, + isEnabled: RegistryManager.shared.installedLanguageServers[item.name]?.isEnabled ?? false, + onCancel: { + InstallationQueueManager.shared.cancelInstallation(packageName: item.name) + }, + onInstall: { + let item = item // Capture for closure + InstallationQueueManager.shared.queueInstallation(package: item) { result in + switch result { + case .success: + break + case .failure(let error): + didError = true + installationFailure = InstallationFailure(error: error.localizedDescription) + } + } + } + ) + .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) + } + } + } + } + .onAppear { + loadRegistryItems() + } + .onReceive(NotificationCenter.default.publisher(for: .RegistryUpdatedNotification)) { _ in + loadRegistryItems() + } + .onDisappear { + InstallationQueueManager.shared.cleanUpInstallationStatus() + } + .alert( + "Installation Failed", + isPresented: $didError, + presenting: installationFailure + ) { _ in + Button("Dismiss") { } + } message: { details in + Text(details.error) + } + } + + private func loadRegistryItems() { + isLoading = true + registryItems = RegistryManager.shared.registryItems + if !registryItems.isEmpty { + isLoading = false + } + } +} + +private struct InstallationFailure: Identifiable { + let error: String + let id = UUID() +} diff --git a/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift b/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift new file mode 100644 index 000000000..9e65691a9 --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/Extensions/Models/LanguageServerSettings.swift @@ -0,0 +1,49 @@ +// +// LanguageServerSettings.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import Foundation + +extension SettingsData { + struct LanguageServerSettings: Codable, Hashable, SearchableSettingsPage { + + /// The search keys + var searchKeys: [String] { + [ + "Language Servers", + "LSP Binaries", + "Linters", + "Formatters", + "Debug Protocol", + "DAP", + ] + .map { NSLocalizedString($0, comment: "") } + } + + /// Stores the currently installed language servers. The key is the name of the language server. + var installedLanguageServers: [String: InstalledLanguageServer] = [:] + + /// Default initializer + init() { + self.installedLanguageServers = [:] + } + + /// Explicit decoder init for setting default values when key is not present in `JSON` + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.installedLanguageServers = try container.decodeIfPresent( + [String: InstalledLanguageServer].self, + forKey: .installedLanguageServers + ) ?? [:] + } + } + + struct InstalledLanguageServer: Codable, Hashable { + let packageName: String + var isEnabled: Bool + let version: String + } +} diff --git a/CodeEdit/Features/Settings/SettingsView.swift b/CodeEdit/Features/Settings/SettingsView.swift index a9e501d20..d7786ffc7 100644 --- a/CodeEdit/Features/Settings/SettingsView.swift +++ b/CodeEdit/Features/Settings/SettingsView.swift @@ -85,6 +85,13 @@ struct SettingsView: View { icon: .system("externaldrive.fill") ) ), + .init( + SettingsPage( + .languageServers, + baseColor: Color(hex: "#6A69DC"), // Purple + icon: .system("cube.box.fill") + ) + ), .init( SettingsPage( .developer, @@ -177,6 +184,8 @@ struct SettingsView: View { SourceControlSettingsView() case .location: LocationsSettingsView() + case .languageServers: + LanguageServersView() case .developer: DeveloperSettingsView() default: diff --git a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift index de6249313..baf99e7cf 100644 --- a/CodeEdit/Utils/ShellClient/Models/ShellClient.swift +++ b/CodeEdit/Utils/ShellClient/Models/ShellClient.swift @@ -21,7 +21,7 @@ class ShellClient { /// - Parameter args: commands to run /// - Returns: command output func generateProcessAndPipe(_ args: [String]) -> (Process, Pipe) { - var arguments = ["-c"] + var arguments = ["-l", "-c"] arguments.append(contentsOf: args) let task = Process() let pipe = Pipe() diff --git a/CodeEditTests/Features/LSP/Registry.swift b/CodeEditTests/Features/LSP/Registry.swift new file mode 100644 index 000000000..5d04f9ad6 --- /dev/null +++ b/CodeEditTests/Features/LSP/Registry.swift @@ -0,0 +1,68 @@ +// +// Registry.swift +// CodeEdit +// +// Created by Abe Malla on 2/2/25. +// + +import XCTest + +@testable import CodeEdit + +final class RegistryTests: XCTestCase { + var registry: RegistryManager = RegistryManager.shared + + // MARK: - Download Tests + + func testRegistryDownload() async throws { + await registry.update() + + let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") + let checksumPath = Settings.shared.baseURL.appending(path: "extensions/checksums.txt") + + XCTAssertTrue(FileManager.default.fileExists(atPath: registryJsonPath.path), "Registry JSON file should exist.") + XCTAssertTrue(FileManager.default.fileExists(atPath: checksumPath.path), "Checksum file should exist.") + } + + // MARK: - Decoding Tests + + func testRegistryDecoding() async throws { + await registry.update() + + let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") + let jsonData = try Data(contentsOf: registryJsonPath) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let entries = try decoder.decode([RegistryItem].self, from: jsonData) + + XCTAssertFalse(entries.isEmpty, "Registry should not be empty after decoding.") + + if let actionlint = entries.first(where: { $0.name == "actionlint" }) { + XCTAssertEqual(actionlint.description, "Static checker for GitHub Actions workflow files.") + XCTAssertEqual(actionlint.licenses, ["MIT"]) + XCTAssertEqual(actionlint.languages, ["YAML"]) + XCTAssertEqual(actionlint.categories, ["Linter"]) + } else { + XCTFail("Could not find actionlint in registry") + } + } + + func testHandlesVersionOverrides() async throws { + await registry.update() + + let registryJsonPath = Settings.shared.baseURL.appending(path: "extensions/registry.json") + let jsonData = try Data(contentsOf: registryJsonPath) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let entries = try decoder.decode([RegistryItem].self, from: jsonData) + + if let adaServer = entries.first(where: { $0.name == "ada-language-server" }) { + XCTAssertNotNil(adaServer.source.versionOverrides, "Version overrides should be present.") + XCTAssertFalse(adaServer.source.versionOverrides!.isEmpty, "Version overrides should not be empty.") + } else { + XCTFail("Could not find ada-language-server to test version overrides") + } + } +}