diff --git a/CHANGELOG.md b/CHANGELOG.md index bee9538..851b0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [Upcoming] + +### Added +- **Compilation database workspace support**: Monocle now detects and supports `compile_commands.json` and `compile_flags.txt` workspaces, routing them to sourcekit-lsp with `--default-workspace-type compilationDatabase`. + +### Fixed +- **SourceKit-LSP launch arguments**: Removed deprecated `--build-path` that overrode `--scratch-path`, and stopped overriding the `HOME` environment variable for SwiftPM workspaces. +- **SourceKit-LSP version detection**: `monocle --version` now queries `swift --version` instead of the removed `sourcekit-lsp --version` flag. + ## [1.6.0] ### Added diff --git a/Sources/MonocleCLI/MonocleCommand.swift b/Sources/MonocleCLI/MonocleCommand.swift index 4188652..37d716b 100644 --- a/Sources/MonocleCLI/MonocleCommand.swift +++ b/Sources/MonocleCLI/MonocleCommand.swift @@ -7,7 +7,7 @@ import MonocleCore struct MonocleCommand: AsyncParsableCommand { /// Version string shown when invoking `monocle --version`. private static let versionDescription: String = { - let sourceKitVersion = (try? SourceKitService.detectSourceKitVersion()) ?? "unknown" + let sourceKitVersion = SourceKitService.detectSourceKitVersion() return "monocle \(toolVersion)\nSourceKit-LSP: \(sourceKitVersion)" }() diff --git a/Sources/MonocleCore/LspSession.swift b/Sources/MonocleCore/LspSession.swift index d221405..26224a8 100644 --- a/Sources/MonocleCore/LspSession.swift +++ b/Sources/MonocleCore/LspSession.swift @@ -352,7 +352,7 @@ public actor LspSession { switch workspace.kind { case .swiftPackage: (maxAttempts: 15, delayNanoseconds: 800_000_000) // SwiftPM often needs extra time to surface build settings - case .xcodeProject, .xcodeWorkspace: + case .xcodeProject, .xcodeWorkspace, .compilationDatabase: (maxAttempts: 5, delayNanoseconds: 350_000_000) } } diff --git a/Sources/MonocleCore/PackageCheckoutLocator.swift b/Sources/MonocleCore/PackageCheckoutLocator.swift index 9af9679..21b99e9 100644 --- a/Sources/MonocleCore/PackageCheckoutLocator.swift +++ b/Sources/MonocleCore/PackageCheckoutLocator.swift @@ -38,6 +38,8 @@ public enum PackageCheckoutLocator { /// - Throws: `MonocleError.buildServerConfigurationMissing` when an Xcode workspace lacks `buildServer.json`. /// - Throws: `MonocleError.ioError` when the build server configuration cannot be read or decoded. public static func checkedOutPackages(in workspace: Workspace) throws -> [PackageCheckout] { + guard workspace.kind != .compilationDatabase else { return [] } + let checkoutsRootURL = try checkoutsRootURL(for: workspace) let packageDirectories = listChildDirectories(at: checkoutsRootURL) @@ -63,6 +65,8 @@ public enum PackageCheckoutLocator { } let buildRootPath = try buildRootPath(fromWorkspaceRootPath: workspace.rootPath) return try xcodeCheckoutsRootURL(fromBuildRootPath: buildRootPath) + case .compilationDatabase: + throw MonocleError.ioError("Compilation database workspaces do not have Swift package checkouts.") } } diff --git a/Sources/MonocleCore/SourceKitService.swift b/Sources/MonocleCore/SourceKitService.swift index 47d3df0..5adda1f 100644 --- a/Sources/MonocleCore/SourceKitService.swift +++ b/Sources/MonocleCore/SourceKitService.swift @@ -198,6 +198,8 @@ public actor SourceKitService { } else { throw MonocleError.buildServerConfigurationMissing(workspaceRootPath: workspace.rootPath) } + case .compilationDatabase: + sourceKitArguments.append(contentsOf: ["--default-workspace-type", "compilationDatabase"]) } var environment = ProcessInfo.processInfo.environment @@ -205,7 +207,6 @@ public actor SourceKitService { environment["DEVELOPER_DIR"] = developerDirectory } if shouldUseSwiftPMBuildSystem { - environment["HOME"] = workspace.rootPath environment["SWIFTPM_CACHE_PATH"] = URL(fileURLWithPath: workspace.rootPath) .appendingPathComponent(".swiftpm-cache").path } @@ -222,8 +223,6 @@ public actor SourceKitService { arguments.append(contentsOf: ["--default-workspace-type", "swiftPM"]) let scratchPath = URL(fileURLWithPath: workspaceRootPath).appendingPathComponent(".sourcekit-lsp-scratch").path arguments.append(contentsOf: ["--scratch-path", scratchPath]) - let buildPath = URL(fileURLWithPath: workspaceRootPath).appendingPathComponent(".build").path - arguments.append(contentsOf: ["--build-path", buildPath]) arguments.append(contentsOf: ["--configuration", "debug"]) } @@ -244,30 +243,50 @@ public actor SourceKitService { /// Determines whether a non-SwiftPM workspace should prefer the build server protocol. private func shouldPreferBuildServer(for workspace: Workspace) -> Bool { - guard workspace.kind != .swiftPackage else { return false } + if workspace.kind == .swiftPackage || workspace.kind == .compilationDatabase { + return false + } let buildServerPath = URL(fileURLWithPath: workspace.rootPath).appendingPathComponent("buildServer.json") return FileManager.default.fileExists(atPath: buildServerPath.path) } - /// Attempts to query the SourceKit-LSP version by running `sourcekit-lsp --version`. + /// Attempts to detect the SourceKit-LSP version by querying the Swift toolchain version. + /// + /// Since `sourcekit-lsp --version` is no longer supported, this runs `swift --version` + /// and extracts a concise version string from the output. /// - /// - Returns: Version string reported by the `sourcekit-lsp --version` invocation. - /// - Throws: `MonocleError.lspLaunchFailed` when the process exits with a non-zero status. - public static func detectSourceKitVersion() throws -> String { + /// - Returns: A version string such as "Apple Swift version 6.3.1", or "unknown". + public static func detectSourceKitVersion() -> String { let process = Process() let pipe = Pipe() process.standardOutput = pipe process.standardError = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - process.arguments = ["sourcekit-lsp", "--version"] - try process.run() - process.waitUntilExit() + process.arguments = ["swift", "--version"] + do { + try process.run() + process.waitUntilExit() + } catch { + return "unknown" + } guard process.terminationStatus == 0 else { - throw MonocleError.lspLaunchFailed("sourcekit-lsp --version returned non-zero exit code") + return "unknown" } let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown" + let output = String(data: data, encoding: .utf8) ?? "" + let firstLine = output.split(separator: "\n").first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + // Try to extract a concise version like "Apple Swift version 6.3.1" + if let range = firstLine.range(of: "Swift version ") { + let suffix = String(firstLine[range.upperBound...]) + let components = suffix.split(separator: " ") + if let version = components.first { + return "Swift version \(version)" + } + } + + return firstLine.isEmpty ? "unknown" : firstLine } } diff --git a/Sources/MonocleCore/Workspace.swift b/Sources/MonocleCore/Workspace.swift index 98620f2..fc74bb1 100644 --- a/Sources/MonocleCore/Workspace.swift +++ b/Sources/MonocleCore/Workspace.swift @@ -12,6 +12,8 @@ public struct Workspace: Equatable, Hashable, Sendable, Codable { case xcodeProject /// An Xcode workspace contained in an `.xcworkspace` bundle. case xcodeWorkspace + /// A workspace using a compilation database (`compile_commands.json` or `compile_flags.txt`). + case compilationDatabase } /// Absolute path to the workspace root directory. diff --git a/Sources/MonocleCore/WorkspaceLocator.swift b/Sources/MonocleCore/WorkspaceLocator.swift index 3f4a39b..616818e 100644 --- a/Sources/MonocleCore/WorkspaceLocator.swift +++ b/Sources/MonocleCore/WorkspaceLocator.swift @@ -31,6 +31,10 @@ public enum WorkspaceLocator { return Workspace(rootPath: currentURL.path, kind: .swiftPackage) } + if hasCompilationDatabase(in: currentURL, fileManager: fileManager) { + return Workspace(rootPath: currentURL.path, kind: .compilationDatabase) + } + let parent = currentURL.deletingLastPathComponent() if parent.path == currentURL.path { throw MonocleError.workspaceNotFound @@ -73,12 +77,19 @@ public enum WorkspaceLocator { if fileManager.fileExists(atPath: url.appendingPathComponent("Package.swift").path) { return Workspace(rootPath: url.path, kind: .swiftPackage) } + if hasCompilationDatabase(in: url, fileManager: fileManager) { + return Workspace(rootPath: url.path, kind: .compilationDatabase) + } } if fileManager.fileExists(atPath: url.appendingPathComponent("Package.swift").path) { return Workspace(rootPath: url.path, kind: .swiftPackage) } + if hasCompilationDatabase(in: url, fileManager: fileManager) { + return Workspace(rootPath: url.path, kind: .compilationDatabase) + } + throw MonocleError.workspaceNotFound } @@ -167,4 +178,15 @@ public enum WorkspaceLocator { return isDirectory.boolValue } + + /// Checks whether a directory contains a compilation database or compile flags file. + /// + /// - Parameters: + /// - directory: Directory to inspect. + /// - fileManager: File manager used for the query. + /// - Returns: `true` when `compile_commands.json` or `compile_flags.txt` exists. + private static func hasCompilationDatabase(in directory: URL, fileManager: FileManager) -> Bool { + let candidates = ["compile_commands.json", "compile_flags.txt"] + return candidates.contains { fileManager.fileExists(atPath: directory.appendingPathComponent($0).path) } + } } diff --git a/Sources/MonocleCore/XcodeSwiftPackageDependencyLocator.swift b/Sources/MonocleCore/XcodeSwiftPackageDependencyLocator.swift index 71bb555..68ece25 100644 --- a/Sources/MonocleCore/XcodeSwiftPackageDependencyLocator.swift +++ b/Sources/MonocleCore/XcodeSwiftPackageDependencyLocator.swift @@ -62,7 +62,7 @@ public enum XcodeSwiftPackageDependencyLocator { } return nil - case .swiftPackage: + case .swiftPackage, .compilationDatabase: return nil } } diff --git a/Tests/MonocleCoreTests/WorkspaceLocatorTests.swift b/Tests/MonocleCoreTests/WorkspaceLocatorTests.swift index 4ae461f..41e4296 100644 --- a/Tests/MonocleCoreTests/WorkspaceLocatorTests.swift +++ b/Tests/MonocleCoreTests/WorkspaceLocatorTests.swift @@ -57,4 +57,90 @@ final class WorkspaceLocatorTests { #expect(workspace.kind == .xcodeProject) #expect(resolvedWorkspaceRootPath == resolvedTemporaryRootPath) } + + @Test func autoDetectionFindsCompilationDatabaseWorkspace() throws { + let fileManager = FileManager.default + let temporaryDirectory = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryDirectory) } + + try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + + let compileCommandsURL = temporaryDirectory.appendingPathComponent("compile_commands.json") + try "[]".write(to: compileCommandsURL, atomically: true, encoding: .utf8) + + let sourcesDirectory = temporaryDirectory.appendingPathComponent("Sources", isDirectory: true) + try fileManager.createDirectory(at: sourcesDirectory, withIntermediateDirectories: true) + + let sampleSwiftFileURL = sourcesDirectory.appendingPathComponent("Sample.swift") + try "public struct Sample {}".write(to: sampleSwiftFileURL, atomically: true, encoding: .utf8) + + let workspace = try WorkspaceLocator.locate(explicitWorkspacePath: nil, filePath: sampleSwiftFileURL.path) + let resolvedWorkspaceRootPath = URL(fileURLWithPath: workspace.rootPath).resolvingSymlinksInPath().path + let resolvedTemporaryRootPath = temporaryDirectory.resolvingSymlinksInPath().path + #expect(workspace.kind == .compilationDatabase) + #expect(resolvedWorkspaceRootPath == resolvedTemporaryRootPath) + } + + @Test func autoDetectionPrefersPackageManifestOverCompilationDatabase() throws { + let fileManager = FileManager.default + let temporaryDirectory = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryDirectory) } + + try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + + let manifestURL = temporaryDirectory.appendingPathComponent("Package.swift") + try """ + // swift-tools-version: 6.2 + import PackageDescription + let package = Package(name: "Example", products: [], targets: []) + """.write(to: manifestURL, atomically: true, encoding: .utf8) + + let compileCommandsURL = temporaryDirectory.appendingPathComponent("compile_commands.json") + try "[]".write(to: compileCommandsURL, atomically: true, encoding: .utf8) + + let workspace = try WorkspaceLocator.locate(explicitWorkspacePath: nil, filePath: temporaryDirectory.path) + #expect(workspace.kind == .swiftPackage) + } + + @Test func explicitCompilationDatabasePathResolvesCorrectly() throws { + let fileManager = FileManager.default + let temporaryDirectory = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryDirectory) } + + try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + + let compileCommandsURL = temporaryDirectory.appendingPathComponent("compile_commands.json") + try "[]".write(to: compileCommandsURL, atomically: true, encoding: .utf8) + + let workspace = try WorkspaceLocator.locate( + explicitWorkspacePath: temporaryDirectory.path, + filePath: temporaryDirectory.path, + ) + let resolvedWorkspaceRootPath = URL(fileURLWithPath: workspace.rootPath).resolvingSymlinksInPath().path + let resolvedTemporaryRootPath = temporaryDirectory.resolvingSymlinksInPath().path + #expect(workspace.kind == .compilationDatabase) + #expect(resolvedWorkspaceRootPath == resolvedTemporaryRootPath) + } + + @Test func explicitCompilationDatabasePathViaFilePathResolver() throws { + let fileManager = FileManager.default + let temporaryDirectory = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryDirectory) } + + try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + + let compileCommandsURL = temporaryDirectory.appendingPathComponent("compile_commands.json") + try "[]".write(to: compileCommandsURL, atomically: true, encoding: .utf8) + + let resolvedPath = FilePathResolver.absolutePath(for: temporaryDirectory.path) + let workspace = try WorkspaceLocator.locate( + explicitWorkspacePath: resolvedPath, + filePath: resolvedPath, + ) + #expect(workspace.kind == .compilationDatabase) + } }