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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/MonocleCLI/MonocleCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}()

Expand Down
2 changes: 1 addition & 1 deletion Sources/MonocleCore/LspSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/MonocleCore/PackageCheckoutLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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.")
}
}

Expand Down
45 changes: 32 additions & 13 deletions Sources/MonocleCore/SourceKitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,15 @@ public actor SourceKitService {
} else {
throw MonocleError.buildServerConfigurationMissing(workspaceRootPath: workspace.rootPath)
}
case .compilationDatabase:
sourceKitArguments.append(contentsOf: ["--default-workspace-type", "compilationDatabase"])
}

var environment = ProcessInfo.processInfo.environment
if let developerDirectory = toolchain?.developerDirectory {
environment["DEVELOPER_DIR"] = developerDirectory
}
if shouldUseSwiftPMBuildSystem {
environment["HOME"] = workspace.rootPath
environment["SWIFTPM_CACHE_PATH"] = URL(fileURLWithPath: workspace.rootPath)
.appendingPathComponent(".swiftpm-cache").path
}
Expand All @@ -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"])
}

Expand All @@ -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
}
}
2 changes: 2 additions & 0 deletions Sources/MonocleCore/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions Sources/MonocleCore/WorkspaceLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public enum XcodeSwiftPackageDependencyLocator {
}
return nil

case .swiftPackage:
case .swiftPackage, .compilationDatabase:
return nil
}
}
Expand Down
86 changes: 86 additions & 0 deletions Tests/MonocleCoreTests/WorkspaceLocatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}