Skip to content

Commit 2c7ecff

Browse files
authored
Merge pull request #2143 from ahoppen/resolve-swiftly
Resolve swiftly when referenced in compile commands
2 parents 0951f1d + a26b0b7 commit 2c7ecff

File tree

7 files changed

+272
-23
lines changed

7 files changed

+272
-23
lines changed

Sources/BuildSystemIntegration/BuildTargetIdentifierExtensions.swift

-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ extension BuildTargetIdentifier {
9999
// MARK: BuildTargetIdentifier for CompileCommands
100100

101101
extension BuildTargetIdentifier {
102-
/// - Important: *For testing only*
103102
package static func createCompileCommands(compiler: String) throws -> BuildTargetIdentifier {
104103
var components = URLComponents()
105104
components.scheme = "compilecommands"

Sources/BuildSystemIntegration/CMakeLists.txt

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ add_library(BuildSystemIntegration STATIC
1919
LegacyBuildServerBuildSystem.swift
2020
MainFilesProvider.swift
2121
SplitShellCommand.swift
22+
SwiftlyResolver.swift
2223
SwiftPMBuildSystem.swift)
2324
set_target_properties(BuildSystemIntegration PROPERTIES
2425
INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY})

Sources/BuildSystemIntegration/JSONCompilationDatabaseBuildSystem.swift

+36-10
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,23 @@ fileprivate extension CompilationDatabaseCompileCommand {
2525
/// without specifying a path.
2626
///
2727
/// The absence of a compiler means we have an empty command line, which should never happen.
28-
var compiler: String? {
29-
return commandLine.first
28+
///
29+
/// If the compiler is a symlink to `swiftly`, it uses `swiftlyResolver` to find the corresponding executable in a
30+
/// real toolchain and returns that executable.
31+
func compiler(swiftlyResolver: SwiftlyResolver) async -> String? {
32+
guard let compiler = commandLine.first else {
33+
return nil
34+
}
35+
let swiftlyResolved = await orLog("Resolving swiftly") {
36+
try await swiftlyResolver.resolve(
37+
compiler: URL(fileURLWithPath: compiler),
38+
workingDirectory: URL(fileURLWithPath: directory)
39+
)?.filePath
40+
}
41+
if let swiftlyResolved {
42+
return swiftlyResolved
43+
}
44+
return compiler
3045
}
3146
}
3247

@@ -49,14 +64,17 @@ package actor JSONCompilationDatabaseBuildSystem: BuiltInBuildSystem {
4964

5065
package let configPath: URL
5166

67+
private let swiftlyResolver = SwiftlyResolver()
68+
5269
// Watch for all all changes to `compile_commands.json` and `compile_flags.txt` instead of just the one at
5370
// `configPath` so that we cover the following semi-common scenario:
5471
// The user has a build that stores `compile_commands.json` in `mybuild`. In order to pick it up, they create a
5572
// symlink from `<project root>/compile_commands.json` to `mybuild/compile_commands.json`. We want to get notified
5673
// about the change to `mybuild/compile_commands.json` because it effectively changes the contents of
5774
// `<project root>/compile_commands.json`.
5875
package let fileWatchers: [FileSystemWatcher] = [
59-
FileSystemWatcher(globPattern: "**/compile_commands.json", kind: [.create, .change, .delete])
76+
FileSystemWatcher(globPattern: "**/compile_commands.json", kind: [.create, .change, .delete]),
77+
FileSystemWatcher(globPattern: "**/.swift-version", kind: [.create, .change, .delete]),
6078
]
6179

6280
private var _indexStorePath: LazyValue<URL?> = .uninitialized
@@ -92,7 +110,11 @@ package actor JSONCompilationDatabaseBuildSystem: BuiltInBuildSystem {
92110
}
93111

94112
package func buildTargets(request: WorkspaceBuildTargetsRequest) async throws -> WorkspaceBuildTargetsResponse {
95-
let compilers = Set(compdb.commands.compactMap(\.compiler)).sorted { $0 < $1 }
113+
let compilers = Set(
114+
await compdb.commands.asyncCompactMap { (command) -> String? in
115+
await command.compiler(swiftlyResolver: swiftlyResolver)
116+
}
117+
).sorted { $0 < $1 }
96118
let targets = try await compilers.asyncMap { compiler in
97119
let toolchainUri: URI? =
98120
if let toolchainPath = await toolchainRegistry.toolchain(withCompiler: URL(fileURLWithPath: compiler))?.path {
@@ -115,12 +137,12 @@ package actor JSONCompilationDatabaseBuildSystem: BuiltInBuildSystem {
115137
}
116138

117139
package func buildTargetSources(request: BuildTargetSourcesRequest) async throws -> BuildTargetSourcesResponse {
118-
let items = request.targets.compactMap { (target) -> SourcesItem? in
140+
let items = await request.targets.asyncCompactMap { (target) -> SourcesItem? in
119141
guard let targetCompiler = orLog("Compiler for target", { try target.compileCommandsCompiler }) else {
120142
return nil
121143
}
122-
let commandsWithRequestedCompilers = compdb.commands.lazy.filter { command in
123-
return targetCompiler == command.compiler
144+
let commandsWithRequestedCompilers = await compdb.commands.lazy.asyncFilter { command in
145+
return await targetCompiler == command.compiler(swiftlyResolver: swiftlyResolver)
124146
}
125147
let sources = commandsWithRequestedCompilers.map {
126148
SourceItem(uri: $0.uri, kind: .file, generated: false)
@@ -131,10 +153,14 @@ package actor JSONCompilationDatabaseBuildSystem: BuiltInBuildSystem {
131153
return BuildTargetSourcesResponse(items: items)
132154
}
133155

134-
package func didChangeWatchedFiles(notification: OnWatchedFilesDidChangeNotification) {
156+
package func didChangeWatchedFiles(notification: OnWatchedFilesDidChangeNotification) async {
135157
if notification.changes.contains(where: { $0.uri.fileURL?.lastPathComponent == Self.dbName }) {
136158
self.reloadCompilationDatabase()
137159
}
160+
if notification.changes.contains(where: { $0.uri.fileURL?.lastPathComponent == ".swift-version" }) {
161+
await swiftlyResolver.clearCache()
162+
connectionToSourceKitLSP.send(OnBuildTargetDidChangeNotification(changes: nil))
163+
}
138164
}
139165

140166
package func prepare(request: BuildTargetPrepareRequest) async throws -> VoidResponse {
@@ -145,8 +171,8 @@ package actor JSONCompilationDatabaseBuildSystem: BuiltInBuildSystem {
145171
request: TextDocumentSourceKitOptionsRequest
146172
) async throws -> TextDocumentSourceKitOptionsResponse? {
147173
let targetCompiler = try request.target.compileCommandsCompiler
148-
let command = compdb[request.textDocument.uri].filter {
149-
$0.compiler == targetCompiler
174+
let command = await compdb[request.textDocument.uri].asyncFilter {
175+
return await $0.compiler(swiftlyResolver: swiftlyResolver) == targetCompiler
150176
}.first
151177
guard let command else {
152178
return nil
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
import SKUtilities
15+
import SwiftExtensions
16+
import TSCExtensions
17+
18+
import struct TSCBasic.AbsolutePath
19+
import class TSCBasic.Process
20+
21+
/// Given a path to a compiler, which might be a symlink to `swiftly`, this type determines the compiler executable in
22+
/// an actual toolchain. It also caches the results. The client needs to invalidate the cache if the path that swiftly
23+
/// might resolve to has changed, eg. because `.swift-version` has been updated.
24+
actor SwiftlyResolver {
25+
private struct CacheKey: Hashable {
26+
let compiler: URL
27+
let workingDirectory: URL?
28+
}
29+
30+
private var cache: LRUCache<CacheKey, Result<URL?, Error>> = LRUCache(capacity: 100)
31+
32+
/// Check if `compiler` is a symlink to `swiftly`. If so, find the executable in the toolchain that swiftly resolves
33+
/// to within the given working directory and return the URL of the corresponding compiler in that toolchain.
34+
/// If `compiler` does not resolve to `swiftly`, return `nil`.
35+
func resolve(compiler: URL, workingDirectory: URL?) async throws -> URL? {
36+
let cacheKey = CacheKey(compiler: compiler, workingDirectory: workingDirectory)
37+
if let cached = cache[cacheKey] {
38+
return try cached.get()
39+
}
40+
let computed: Result<URL?, Error>
41+
do {
42+
computed = .success(
43+
try await resolveSwiftlyTrampolineImpl(compiler: compiler, workingDirectory: workingDirectory)
44+
)
45+
} catch {
46+
computed = .failure(error)
47+
}
48+
cache[cacheKey] = computed
49+
return try computed.get()
50+
}
51+
52+
private func resolveSwiftlyTrampolineImpl(compiler: URL, workingDirectory: URL?) async throws -> URL? {
53+
let realpath = try compiler.realpath
54+
guard realpath.lastPathComponent == "swiftly" else {
55+
return nil
56+
}
57+
let swiftlyResult = try await Process.run(
58+
arguments: [realpath.filePath, "use", "-p"],
59+
workingDirectory: try AbsolutePath(validatingOrNil: workingDirectory?.filePath)
60+
)
61+
let swiftlyToolchain = URL(
62+
fileURLWithPath: try swiftlyResult.utf8Output().trimmingCharacters(in: .whitespacesAndNewlines)
63+
)
64+
let resolvedCompiler = swiftlyToolchain.appending(components: "usr", "bin", compiler.lastPathComponent)
65+
if FileManager.default.fileExists(at: resolvedCompiler) {
66+
return resolvedCompiler
67+
}
68+
return nil
69+
}
70+
71+
func clearCache() {
72+
cache.removeAll()
73+
}
74+
}
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
package import Foundation
14+
import SwiftExtensions
15+
import ToolchainRegistry
16+
import XCTest
17+
18+
import class TSCBasic.Process
19+
20+
/// Compiles the given Swift source code into a binary at `executablePath`.
21+
package func createBinary(_ sourceCode: String, at executablePath: URL) async throws {
22+
try await withTestScratchDir { scratchDir in
23+
let sourceFile = scratchDir.appending(component: "source.swift")
24+
try await sourceCode.writeWithRetry(to: sourceFile)
25+
26+
var compilerArguments = try [
27+
sourceFile.filePath,
28+
"-o",
29+
executablePath.filePath,
30+
]
31+
if let defaultSDKPath {
32+
compilerArguments += ["-sdk", defaultSDKPath]
33+
}
34+
try await Process.checkNonZeroExit(
35+
arguments: [unwrap(ToolchainRegistry.forTesting.default?.swiftc?.filePath)] + compilerArguments
36+
)
37+
}
38+
}

Tests/SourceKitLSPTests/CompilationDatabaseTests.swift

+117-1
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import BuildSystemIntegration
14-
import Foundation
1514
import LanguageServerProtocol
1615
import SKTestSupport
16+
import SwiftExtensions
1717
import TSCBasic
18+
import TSCExtensions
1819
import ToolchainRegistry
1920
import XCTest
2021

@@ -238,6 +239,121 @@ final class CompilationDatabaseTests: XCTestCase {
238239
assertContains(error.message, "No language service")
239240
}
240241
}
242+
243+
func testLookThroughSwiftly() async throws {
244+
try await withTestScratchDir { scratchDirectory in
245+
let defaultToolchain = try await unwrap(ToolchainRegistry.forTesting.default)
246+
247+
// We create a toolchain registry with the default toolchain, which is able to provide semantic functionality and
248+
// a dummy toolchain that can't provide semantic functionality.
249+
let fakeToolchainURL = scratchDirectory.appending(components: "fakeToolchain")
250+
let fakeToolchain = Toolchain(
251+
identifier: "fake",
252+
displayName: "fake",
253+
path: fakeToolchainURL,
254+
clang: nil,
255+
swift: fakeToolchainURL.appending(components: "usr", "bin", "swift"),
256+
swiftc: fakeToolchainURL.appending(components: "usr", "bin", "swiftc"),
257+
swiftFormat: nil,
258+
clangd: nil,
259+
sourcekitd: fakeToolchainURL.appending(components: "usr", "lib", "sourcekitd.framework", "sourcekitd"),
260+
libIndexStore: nil
261+
)
262+
let toolchainRegistry = ToolchainRegistry(toolchains: [
263+
try await unwrap(ToolchainRegistry.forTesting.default), fakeToolchain,
264+
])
265+
266+
// We need to create a file for the swift executable because `SwiftlyResolver` checks for its presence.
267+
try FileManager.default.createDirectory(
268+
at: XCTUnwrap(fakeToolchain.swift).deletingLastPathComponent(),
269+
withIntermediateDirectories: true
270+
)
271+
try await "".writeWithRetry(to: XCTUnwrap(fakeToolchain.swift))
272+
273+
// Create a dummy swiftly executable that picks the default toolchain for all file unless `fakeToolchain` is in
274+
// the source file's path.
275+
let dummySwiftlyExecutableUrl = scratchDirectory.appendingPathComponent("swiftly")
276+
let dummySwiftExecutableUrl = scratchDirectory.appendingPathComponent("swift")
277+
try FileManager.default.createSymbolicLink(
278+
at: dummySwiftExecutableUrl,
279+
withDestinationURL: dummySwiftlyExecutableUrl
280+
)
281+
try await createBinary(
282+
"""
283+
import Foundation
284+
285+
if FileManager.default.currentDirectoryPath.contains("fakeToolchain") {
286+
print(#"\(fakeToolchain.path.filePath)"#)
287+
} else {
288+
print(#"\(defaultToolchain.path.filePath)"#)
289+
}
290+
""",
291+
at: dummySwiftlyExecutableUrl
292+
)
293+
294+
// Now create a project in which we have one file in a `realToolchain` directory for which our fake swiftly will
295+
// pick the default toolchain and one in `fakeToolchain` for which swiftly will pick the fake toolchain. We should
296+
// be able to get semantic functionality for the file in `realToolchain` but not for `fakeToolchain` because
297+
// sourcekitd can't be launched for that toolchain (since it doesn't exist).
298+
let dummySwiftExecutablePathForJSON = try dummySwiftExecutableUrl.filePath.replacing(#"\"#, with: #"\\"#)
299+
300+
let project = try await MultiFileTestProject(
301+
files: [
302+
"realToolchain/realToolchain.swift": """
303+
#warning("Test warning")
304+
""",
305+
"fakeToolchain/fakeToolchain.swift": """
306+
#warning("Test warning")
307+
""",
308+
"compile_commands.json": """
309+
[
310+
{
311+
"directory": "$TEST_DIR_BACKSLASH_ESCAPED/realToolchain",
312+
"arguments": [
313+
"\(dummySwiftExecutablePathForJSON)",
314+
"$TEST_DIR_BACKSLASH_ESCAPED/realToolchain/realToolchain.swift",
315+
\(defaultSDKArgs)
316+
],
317+
"file": "realToolchain.swift",
318+
"output": "$TEST_DIR_BACKSLASH_ESCAPED/realToolchain/test.swift.o"
319+
},
320+
{
321+
"directory": "$TEST_DIR_BACKSLASH_ESCAPED/fakeToolchain",
322+
"arguments": [
323+
"\(dummySwiftExecutablePathForJSON)",
324+
"$TEST_DIR_BACKSLASH_ESCAPED/fakeToolchain/fakeToolchain.swift",
325+
\(defaultSDKArgs)
326+
],
327+
"file": "fakeToolchain.swift",
328+
"output": "$TEST_DIR_BACKSLASH_ESCAPED/fakeToolchain/test.swift.o"
329+
}
330+
]
331+
""",
332+
],
333+
toolchainRegistry: toolchainRegistry
334+
)
335+
336+
let (forRealToolchainUri, _) = try project.openDocument("realToolchain.swift")
337+
let diagnostics = try await project.testClient.send(
338+
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(forRealToolchainUri))
339+
)
340+
XCTAssertEqual(diagnostics.fullReport?.items.map(\.message), ["Test warning"])
341+
342+
let (forDummyToolchainUri, _) = try project.openDocument("fakeToolchain.swift")
343+
await assertThrowsError(
344+
try await project.testClient.send(
345+
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(forDummyToolchainUri))
346+
)
347+
) { error in
348+
guard let error = error as? ResponseError else {
349+
XCTFail("Expected ResponseError, got \(error)")
350+
return
351+
}
352+
// The actual error message here doesn't matter too much, we just need to check that we don't get diagnostics.
353+
assertContains(error.message, "No language service")
354+
}
355+
}
356+
}
241357
}
242358

243359
fileprivate let defaultSDKArgs: String = {

Tests/SourceKitLSPTests/FormattingTests.swift

+6-11
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,13 @@ final class FormattingTests: XCTestCase {
299299
try await withTestScratchDir { scratchDir in
300300
let toolchain = try await unwrap(ToolchainRegistry.forTesting.default)
301301

302-
let crashingSwiftFilePath = scratchDir.appendingPathComponent("crashing-executable.swift")
303302
let crashingExecutablePath = scratchDir.appendingPathComponent("crashing-executable")
304-
try await "fatalError()".writeWithRetry(to: crashingSwiftFilePath)
305-
var compilerArguments = try [
306-
crashingSwiftFilePath.filePath,
307-
"-o",
308-
crashingExecutablePath.filePath,
309-
]
310-
if let defaultSDKPath {
311-
compilerArguments += ["-sdk", defaultSDKPath]
312-
}
313-
try await Process.checkNonZeroExit(arguments: [XCTUnwrap(toolchain.swiftc?.filePath)] + compilerArguments)
303+
try await createBinary(
304+
"""
305+
fatalError()
306+
""",
307+
at: crashingExecutablePath
308+
)
314309

315310
let toolchainRegistry = ToolchainRegistry(toolchains: [
316311
Toolchain(

0 commit comments

Comments
 (0)