diff --git a/Sources/BuildServerIntegration/CMakeLists.txt b/Sources/BuildServerIntegration/CMakeLists.txt index b3ab347d4..68fe95c50 100644 --- a/Sources/BuildServerIntegration/CMakeLists.txt +++ b/Sources/BuildServerIntegration/CMakeLists.txt @@ -20,8 +20,8 @@ add_library(BuildServerIntegration STATIC LegacyBuildServer.swift MainFilesProvider.swift SplitShellCommand.swift - SwiftlyResolver.swift - SwiftPMBuildServer.swift) + SwiftPMBuildServer.swift + SwiftToolchainResolver.swift) set_target_properties(BuildServerIntegration PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) target_link_libraries(BuildServerIntegration PUBLIC diff --git a/Sources/BuildServerIntegration/JSONCompilationDatabaseBuildServer.swift b/Sources/BuildServerIntegration/JSONCompilationDatabaseBuildServer.swift index d43fe6f0a..8000b73e4 100644 --- a/Sources/BuildServerIntegration/JSONCompilationDatabaseBuildServer.swift +++ b/Sources/BuildServerIntegration/JSONCompilationDatabaseBuildServer.swift @@ -26,20 +26,20 @@ fileprivate extension CompilationDatabaseCompileCommand { /// /// The absence of a compiler means we have an empty command line, which should never happen. /// - /// If the compiler is a symlink to `swiftly`, it uses `swiftlyResolver` to find the corresponding executable in a - /// real toolchain and returns that executable. - func compiler(swiftlyResolver: SwiftlyResolver, compileCommandsDirectory: URL) async -> String? { + /// If the compiler is a symlink to `swiftly` or in `/usr/bin` on macOS, it uses `toolchainResolver` to find the + /// corresponding executable in a real toolchain and returns that executable. + func compiler(toolchainResolver: SwiftToolchainResolver, compileCommandsDirectory: URL) async -> String? { guard let compiler = commandLine.first else { return nil } - let swiftlyResolved = await orLog("Resolving swiftly") { - try await swiftlyResolver.resolve( + let resolved = await orLog("Resolving compiler") { + try await toolchainResolver.resolve( compiler: URL(fileURLWithPath: compiler), workingDirectory: directoryURL(compileCommandsDirectory: compileCommandsDirectory) )?.filePath } - if let swiftlyResolved { - return swiftlyResolved + if let resolved { + return resolved } return compiler } @@ -74,7 +74,7 @@ package actor JSONCompilationDatabaseBuildServer: BuiltInBuildServer { /// finds the compilation database in a build directory. private var configDirectory: URL - private let swiftlyResolver = SwiftlyResolver() + private let toolchainResolver = SwiftToolchainResolver() // Watch for all all changes to `compile_commands.json` and `compile_flags.txt` instead of just the one at // `configPath` so that we cover the following semi-common scenario: @@ -124,7 +124,7 @@ package actor JSONCompilationDatabaseBuildServer: BuiltInBuildServer { package func buildTargets(request: WorkspaceBuildTargetsRequest) async throws -> WorkspaceBuildTargetsResponse { let compilers = Set( await compdb.commands.asyncCompactMap { (command) -> String? in - await command.compiler(swiftlyResolver: swiftlyResolver, compileCommandsDirectory: configDirectory) + await command.compiler(toolchainResolver: toolchainResolver, compileCommandsDirectory: configDirectory) } ).sorted { $0 < $1 } let targets = try await compilers.asyncMap { compiler in @@ -155,7 +155,7 @@ package actor JSONCompilationDatabaseBuildServer: BuiltInBuildServer { } let commandsWithRequestedCompilers = await compdb.commands.lazy.asyncFilter { command in return await targetCompiler - == command.compiler(swiftlyResolver: swiftlyResolver, compileCommandsDirectory: configDirectory) + == command.compiler(toolchainResolver: toolchainResolver, compileCommandsDirectory: configDirectory) } let sources = commandsWithRequestedCompilers.map { SourceItem(uri: $0.uri(compileCommandsDirectory: configDirectory), kind: .file, generated: false) @@ -171,7 +171,7 @@ package actor JSONCompilationDatabaseBuildServer: BuiltInBuildServer { self.reloadCompilationDatabase() } if notification.changes.contains(where: { $0.uri.fileURL?.lastPathComponent == ".swift-version" }) { - await swiftlyResolver.clearCache() + await toolchainResolver.clearCache() connectionToSourceKitLSP.send(OnBuildTargetDidChangeNotification(changes: nil)) } } @@ -185,7 +185,7 @@ package actor JSONCompilationDatabaseBuildServer: BuiltInBuildServer { ) async throws -> TextDocumentSourceKitOptionsResponse? { let targetCompiler = try request.target.compileCommandsCompiler let command = await compdb[request.textDocument.uri].asyncFilter { - return await $0.compiler(swiftlyResolver: swiftlyResolver, compileCommandsDirectory: configDirectory) + return await $0.compiler(toolchainResolver: toolchainResolver, compileCommandsDirectory: configDirectory) == targetCompiler }.first guard let command else { diff --git a/Sources/BuildServerIntegration/SwiftlyResolver.swift b/Sources/BuildServerIntegration/SwiftToolchainResolver.swift similarity index 53% rename from Sources/BuildServerIntegration/SwiftlyResolver.swift rename to Sources/BuildServerIntegration/SwiftToolchainResolver.swift index 52372a5d1..e199e4ca0 100644 --- a/Sources/BuildServerIntegration/SwiftlyResolver.swift +++ b/Sources/BuildServerIntegration/SwiftToolchainResolver.swift @@ -18,10 +18,10 @@ import TSCExtensions import struct TSCBasic.AbsolutePath import class TSCBasic.Process -/// Given a path to a compiler, which might be a symlink to `swiftly`, this type determines the compiler executable in -/// an actual toolchain. It also caches the results. The client needs to invalidate the cache if the path that swiftly -/// might resolve to has changed, eg. because `.swift-version` has been updated. -actor SwiftlyResolver { +/// Given a path to a compiler, which might be a symlink to `swiftly` or `/usr/bin` on macOS, this type determines the +/// compiler executable in an actual toolchain and caches the result. The client needs to invalidate the cache if the +/// path that this may resolve to has changed, eg. because `.swift-version` or `SDKROOT` has been updated. +actor SwiftToolchainResolver { private struct CacheKey: Hashable { let compiler: URL let workingDirectory: URL? @@ -29,31 +29,37 @@ actor SwiftlyResolver { private var cache: LRUCache> = LRUCache(capacity: 100) - /// Check if `compiler` is a symlink to `swiftly`. If so, find the executable in the toolchain that swiftly resolves - /// to within the given working directory and return the URL of the corresponding compiler in that toolchain. - /// If `compiler` does not resolve to `swiftly`, return `nil`. + /// Check if `compiler` is a symlink to `swiftly` or in `/usr/bin` on macOS. If so, find the executable in the + /// toolchain that would be resolved to within the given working directory and return the URL of the corresponding + /// compiler in that toolchain. If `compiler` does not resolve to `swiftly` or `/usr/bin` on macOS, return `nil`. func resolve(compiler: URL, workingDirectory: URL?) async throws -> URL? { let cacheKey = CacheKey(compiler: compiler, workingDirectory: workingDirectory) if let cached = cache[cacheKey] { return try cached.get() } + let computed: Result do { - computed = .success( - try await resolveSwiftlyTrampolineImpl(compiler: compiler, workingDirectory: workingDirectory) - ) + var resolved = try await resolveSwiftlyTrampoline(compiler: compiler, workingDirectory: workingDirectory) + if resolved == nil { + resolved = try await resolveXcrunTrampoline(compiler: compiler, workingDirectory: workingDirectory) + } + + computed = .success(resolved) } catch { computed = .failure(error) } + cache[cacheKey] = computed return try computed.get() } - private func resolveSwiftlyTrampolineImpl(compiler: URL, workingDirectory: URL?) async throws -> URL? { + private func resolveSwiftlyTrampoline(compiler: URL, workingDirectory: URL?) async throws -> URL? { let realpath = try compiler.realpath guard realpath.lastPathComponent == "swiftly" else { return nil } + let swiftlyResult = try await Process.run( arguments: [realpath.filePath, "use", "-p"], workingDirectory: try AbsolutePath(validatingOrNil: workingDirectory?.filePath) @@ -61,6 +67,7 @@ actor SwiftlyResolver { let swiftlyToolchain = URL( fileURLWithPath: try swiftlyResult.utf8Output().trimmingCharacters(in: .whitespacesAndNewlines) ) + let resolvedCompiler = swiftlyToolchain.appending(components: "usr", "bin", compiler.lastPathComponent) if FileManager.default.fileExists(at: resolvedCompiler) { return resolvedCompiler @@ -68,6 +75,25 @@ actor SwiftlyResolver { return nil } + private func resolveXcrunTrampoline(compiler: URL, workingDirectory: URL?) async throws -> URL? { + guard Platform.current == .darwin, compiler.deletingLastPathComponent() == URL(filePath: "/usr/bin/") else { + return nil + } + + let xcrunResult = try await Process.run( + arguments: ["xcrun", "-f", compiler.lastPathComponent], + workingDirectory: try AbsolutePath(validatingOrNil: workingDirectory?.filePath) + ) + + let resolvedCompiler = URL( + fileURLWithPath: try xcrunResult.utf8Output().trimmingCharacters(in: .whitespacesAndNewlines) + ) + if FileManager.default.fileExists(at: resolvedCompiler) { + return resolvedCompiler + } + return nil + } + func clearCache() { cache.removeAll() } diff --git a/Tests/SourceKitLSPTests/CompilationDatabaseTests.swift b/Tests/SourceKitLSPTests/CompilationDatabaseTests.swift index d061c66c2..eb3966157 100644 --- a/Tests/SourceKitLSPTests/CompilationDatabaseTests.swift +++ b/Tests/SourceKitLSPTests/CompilationDatabaseTests.swift @@ -261,10 +261,10 @@ final class CompilationDatabaseTests: SourceKitLSPTestCase { libIndexStore: nil ) let toolchainRegistry = ToolchainRegistry(toolchains: [ - try await unwrap(ToolchainRegistry.forTesting.default), fakeToolchain, + defaultToolchain, fakeToolchain, ]) - // We need to create a file for the swift executable because `SwiftlyResolver` checks for its presence. + // We need to create a file for the swift executable because `SwiftToolchainResolver` checks for its presence. try FileManager.default.createDirectory( at: XCTUnwrap(fakeToolchain.swift).deletingLastPathComponent(), withIntermediateDirectories: true @@ -389,6 +389,43 @@ final class CompilationDatabaseTests: SourceKitLSPTestCase { ) XCTAssertEqual(definition?.locations, [try project.location(from: "1️⃣", to: "2️⃣", in: "header.h")]) } + + func testLookThroughXcrun() async throws { + try SkipUnless.platformIsDarwin("xcrun is macOS only") + + try await withTestScratchDir { scratchDirectory in + let toolchainRegistry = try XCTUnwrap(ToolchainRegistry.forTesting) + + let project = try await MultiFileTestProject( + files: [ + "test.swift": """ + #warning("Test warning") + """, + "compile_commands.json": """ + [ + { + "directory": "$TEST_DIR_BACKSLASH_ESCAPED", + "arguments": [ + "/usr/bin/swiftc", + "$TEST_DIR_BACKSLASH_ESCAPED/test.swift", + \(defaultSDKArgs) + ], + "file": "test.swift", + "output": "$TEST_DIR_BACKSLASH_ESCAPED/test.swift.o" + } + ] + """, + ], + toolchainRegistry: toolchainRegistry + ) + + let (uri, _) = try project.openDocument("test.swift") + let diagnostics = try await project.testClient.send( + DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri)) + ) + XCTAssertEqual(diagnostics.fullReport?.items.map(\.message), ["Test warning"]) + } + } } private let defaultSDKArgs: String = {