Skip to content

Commit 7d6555c

Browse files
authored
Merge pull request #1081 from ahoppen/ahoppen/implicit-workspace
Provide semantic functionality for packages that are subdirectories of a workspace folder
2 parents 884b1f5 + d76e905 commit 7d6555c

File tree

11 files changed

+366
-48
lines changed

11 files changed

+366
-48
lines changed

Sources/LSPLogging/CustomLogStringConvertible.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,21 @@ extension String {
6767
return Insecure.MD5.hash(data: Data(self.utf8)).description
6868
}
6969
}
70+
71+
private struct OptionalWrapper<Wrapped>: CustomLogStringConvertible where Wrapped: CustomLogStringConvertible {
72+
let optional: Optional<Wrapped>
73+
74+
public var description: String {
75+
return optional?.description ?? "<nil>"
76+
}
77+
78+
public var redactedDescription: String {
79+
return optional?.redactedDescription ?? "<nil>"
80+
}
81+
}
82+
83+
extension Optional where Wrapped: CustomLogStringConvertible {
84+
public var forLogging: CustomLogStringConvertibleWrapper {
85+
return CustomLogStringConvertibleWrapper(OptionalWrapper(optional: self))
86+
}
87+
}

Sources/SKCore/BuildServerBuildSystem.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func executable(_ name: String) -> String {
4343
/// Provides build settings from a build server launched based on a
4444
/// `buildServer.json` configuration file provided in the repo root.
4545
public actor BuildServerBuildSystem: MessageHandler {
46-
let projectRoot: AbsolutePath
46+
public let projectRoot: AbsolutePath
4747
let buildFolder: AbsolutePath?
4848
let serverConfig: BuildServerConfig
4949

Sources/SKCore/BuildSystem.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ public enum FileHandlingCapability: Comparable {
3838
/// contained in a SwiftPM package root directory.
3939
public protocol BuildSystem: AnyObject {
4040

41+
/// The root of the project that this build system manages. For example, for SwiftPM packages, this is the folder
42+
/// containing Package.swift. For compilation databases it is the root folder based on which the compilation database
43+
/// was found.
44+
var projectRoot: AbsolutePath { get async }
45+
4146
/// The path to the raw index store data, if any.
4247
var indexStorePath: AbsolutePath? { get async }
4348

Sources/SKCore/BuildSystemManager.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ public actor BuildSystemManager {
5050
/// Build system delegate that will receive notifications about setting changes, etc.
5151
var _delegate: BuildSystemDelegate?
5252

53+
/// The root of the project that this build system manages. For example, for SwiftPM packages, this is the folder
54+
/// containing Package.swift. For compilation databases it is the root folder based on which the compilation database
55+
/// was found.
56+
public var projectRoot: AbsolutePath? {
57+
get async {
58+
return await buildSystem?.projectRoot
59+
}
60+
}
61+
5362
/// Create a BuildSystemManager that wraps the given build system. The new
5463
/// manager will modify the delegate of the underlying build system.
5564
public init(

Sources/SKCore/CompilationDatabaseBuildSystem.swift

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public actor CompilationDatabaseBuildSystem {
4343
self.delegate = delegate
4444
}
4545

46-
let projectRoot: AbsolutePath?
46+
public let projectRoot: AbsolutePath
4747

4848
let searchPaths: [RelativePath]
4949

@@ -74,16 +74,14 @@ public actor CompilationDatabaseBuildSystem {
7474
}
7575

7676
public init?(
77-
projectRoot: AbsolutePath? = nil,
77+
projectRoot: AbsolutePath,
7878
searchPaths: [RelativePath],
7979
fileSystem: FileSystem = localFileSystem
8080
) {
8181
self.fileSystem = fileSystem
8282
self.projectRoot = projectRoot
8383
self.searchPaths = searchPaths
84-
if let path = projectRoot,
85-
let compdb = tryLoadCompilationDatabase(directory: path, additionalSearchPaths: searchPaths, fileSystem)
86-
{
84+
if let compdb = tryLoadCompilationDatabase(directory: projectRoot, additionalSearchPaths: searchPaths, fileSystem) {
8785
self.compdb = compdb
8886
} else {
8987
return nil
@@ -160,8 +158,6 @@ extension CompilationDatabaseBuildSystem: BuildSystem {
160158
/// The compilation database has been changed on disk.
161159
/// Reload it and notify the delegate about build setting changes.
162160
private func reloadCompilationDatabase() async {
163-
guard let projectRoot = self.projectRoot else { return }
164-
165161
self.compdb = tryLoadCompilationDatabase(
166162
directory: projectRoot,
167163
additionalSearchPaths: searchPaths,

Sources/SKSwiftPMWorkspace/SwiftPMWorkspace.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,8 @@ public actor SwiftPMWorkspace {
8282
}
8383

8484
let workspacePath: TSCAbsolutePath
85-
let packageRoot: TSCAbsolutePath
86-
/// *Public for testing*
87-
public var _packageRoot: TSCAbsolutePath { packageRoot }
85+
/// The directory containing `Package.swift`.
86+
public var projectRoot: TSCAbsolutePath
8887
var packageGraph: PackageGraph
8988
let workspace: Workspace
9089
public let buildParameters: BuildParameters
@@ -122,7 +121,7 @@ public actor SwiftPMWorkspace {
122121
throw Error.noManifest(workspacePath: workspacePath)
123122
}
124123

125-
self.packageRoot = try resolveSymlinks(packageRoot)
124+
self.projectRoot = try resolveSymlinks(packageRoot)
126125

127126
guard let destinationToolchainBinDir = await getDefaultToolchain(toolchainRegistry)?.swiftc?.parentDirectory else {
128127
throw Error.cannotDetermineHostToolchain
@@ -216,7 +215,7 @@ extension SwiftPMWorkspace {
216215
})
217216

218217
let packageGraph = try self.workspace.loadPackageGraph(
219-
rootInput: PackageGraphRootInput(packages: [AbsolutePath(packageRoot)]),
218+
rootInput: PackageGraphRootInput(packages: [AbsolutePath(projectRoot)]),
220219
forceResolvedVersions: true,
221220
availableLibraries: self.buildParameters.toolchain.providedLibraries,
222221
observabilityScope: observabilitySystem.topScope

Sources/SourceKitLSP/SourceKitServer.swift

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import LanguageServerProtocol
1919
import PackageLoading
2020
import SKCore
2121
import SKSupport
22+
import SKSwiftPMWorkspace
2223
import SourceKitD
2324

2425
import struct PackageModel.BuildFlags
@@ -407,20 +408,24 @@ public actor SourceKitServer {
407408
/// Must only be accessed from `queue`.
408409
private var uriToWorkspaceCache: [DocumentURI: WeakWorkspace] = [:]
409410

410-
private(set) var workspaces: [Workspace] = [] {
411+
/// The open workspaces.
412+
///
413+
/// Implicit workspaces are workspaces that weren't actually specified by the client during initialization or by a
414+
/// `didChangeWorkspaceFolders` request. Instead, they were opened by sourcekit-lsp because a file could not be
415+
/// handled by any of the open workspaces but one of the file's parent directories had handling capabilities for it.
416+
private var workspacesAndIsImplicit: [(workspace: Workspace, isImplicit: Bool)] = [] {
411417
didSet {
412418
uriToWorkspaceCache = [:]
413419
}
414420
}
415421

416-
/// **Public for testing**
417-
public var _workspaces: [Workspace] {
418-
get {
419-
return self.workspaces
420-
}
421-
set {
422-
self.workspaces = newValue
423-
}
422+
var workspaces: [Workspace] {
423+
return workspacesAndIsImplicit.map(\.workspace)
424+
}
425+
426+
@_spi(Testing)
427+
public func setWorkspaces(_ newValue: [(workspace: Workspace, isImplicit: Bool)]) {
428+
self.workspacesAndIsImplicit = newValue
424429
}
425430

426431
/// The requests that we are currently handling.
@@ -454,13 +459,37 @@ public actor SourceKitServer {
454459
self.client = client
455460
}
456461

457-
public func workspaceForDocument(uri: DocumentURI) async -> Workspace? {
458-
if workspaces.count == 1 {
459-
// Special handling: If there is only one workspace, open all files in it.
460-
// This retains the behavior of SourceKit-LSP before it supported multiple workspaces.
461-
return workspaces.first
462+
/// Search through all the parent directories of `uri` and check if any of these directories contain a workspace
463+
/// capable of handling `uri`.
464+
///
465+
/// The search will not consider any directory that is not a child of any of the directories in `rootUris`. This
466+
/// prevents us from picking up a workspace that is outside of the folders that the user opened.
467+
private func findWorkspaceCapableOfHandlingDocument(at uri: DocumentURI) async -> Workspace? {
468+
guard var url = uri.fileURL?.deletingLastPathComponent() else {
469+
return nil
470+
}
471+
let projectRoots = await self.workspacesAndIsImplicit.filter { !$0.isImplicit }.asyncCompactMap {
472+
await $0.workspace.buildSystemManager.projectRoot
473+
}
474+
let rootURLs = workspacesAndIsImplicit.filter { !$0.isImplicit }.compactMap { $0.workspace.rootUri?.fileURL }
475+
while url.pathComponents.count > 1 && rootURLs.contains(where: { $0.isPrefix(of: url) }) {
476+
// Ignore workspaces that can't handle this file or that have the same project root as an existing workspace.
477+
// The latter might happen if there is an existing SwiftPM workspace that hasn't been reloaded after a new file
478+
// was added to it and thus currently doesn't know that it can handle that file. In that case, we shouldn't open
479+
// a new workspace for the same root. Instead, the existing workspace's build system needs to be reloaded.
480+
if let workspace = await self.createWorkspace(WorkspaceFolder(uri: DocumentURI(url))),
481+
await workspace.buildSystemManager.fileHandlingCapability(for: uri) == .handled,
482+
let projectRoot = await workspace.buildSystemManager.projectRoot,
483+
!projectRoots.contains(projectRoot)
484+
{
485+
return workspace
486+
}
487+
url.deleteLastPathComponent()
462488
}
489+
return nil
490+
}
463491

492+
public func workspaceForDocument(uri: DocumentURI) async -> Workspace? {
464493
if let cachedWorkspace = uriToWorkspaceCache[uri]?.value {
465494
return cachedWorkspace
466495
}
@@ -474,8 +503,41 @@ public actor SourceKitServer {
474503
bestWorkspace = (workspace, fileHandlingCapability)
475504
}
476505
}
506+
if bestWorkspace.fileHandlingCapability < .handled {
507+
// We weren't able to handle the document with any of the known workspaces. See if any of the document's parent
508+
// directories contain a workspace that can handle the document.
509+
let rootUris = workspaces.map(\.rootUri)
510+
if let workspace = await findWorkspaceCapableOfHandlingDocument(at: uri) {
511+
if workspaces.map(\.rootUri) != rootUris {
512+
// Workspaces was modified while running `findWorkspaceCapableOfHandlingDocument`, so we raced.
513+
// This is unlikely to happen unless the user opens many files that in sub-workspaces simultaneously.
514+
// Try again based on the new data. Very likely the workspace that can handle this document is now in
515+
// `workspaces` and we will be able to return it without having to search again.
516+
logger.debug("findWorkspaceCapableOfHandlingDocument raced with another workspace creation. Trying again.")
517+
return await workspaceForDocument(uri: uri)
518+
}
519+
// Appending a workspace is fine and doesn't require checking if we need to re-open any documents because:
520+
// - Any currently open documents that have FileHandlingCapability `.handled` will continue to be opened in
521+
// their current workspace because it occurs further in front inside the workspace list
522+
// - Any currently open documents that have FileHandlingCapability < `.handled` also went through this check
523+
// and didn't find any parent workspace that was able to handle them. We assume that a workspace can only
524+
// properly handle files within its root directory, so those files now also can't be handled by the new
525+
// workspace.
526+
logger.log("Opening implicit workspace at \(workspace.rootUri.forLogging) to handle \(uri.forLogging)")
527+
workspacesAndIsImplicit.append((workspace: workspace, isImplicit: true))
528+
bestWorkspace = (workspace, .handled)
529+
}
530+
}
477531
uriToWorkspaceCache[uri] = WeakWorkspace(bestWorkspace.workspace)
478-
return bestWorkspace.workspace
532+
if let workspace = bestWorkspace.workspace {
533+
return workspace
534+
}
535+
if let workspace = workspaces.only {
536+
// Special handling: If there is only one workspace, open all files in it, even it it cannot handle the document.
537+
// This retains the behavior of SourceKit-LSP before it supported multiple workspaces.
538+
return workspace
539+
}
540+
return nil
479541
}
480542

481543
/// Execute `notificationHandler` with the request as well as the workspace
@@ -1095,16 +1157,21 @@ extension SourceKitServer {
10951157
capabilityRegistry = CapabilityRegistry(clientCapabilities: req.capabilities)
10961158

10971159
if let workspaceFolders = req.workspaceFolders {
1098-
self.workspaces += await workspaceFolders.asyncCompactMap { await self.createWorkspace($0) }
1160+
self.workspacesAndIsImplicit += await workspaceFolders.asyncCompactMap {
1161+
guard let workspace = await self.createWorkspace($0) else {
1162+
return nil
1163+
}
1164+
return (workspace: workspace, isImplicit: false)
1165+
}
10991166
} else if let uri = req.rootURI {
11001167
let workspaceFolder = WorkspaceFolder(uri: uri)
11011168
if let workspace = await self.createWorkspace(workspaceFolder) {
1102-
self.workspaces.append(workspace)
1169+
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
11031170
}
11041171
} else if let path = req.rootPath {
11051172
let workspaceFolder = WorkspaceFolder(uri: DocumentURI(URL(fileURLWithPath: path)))
11061173
if let workspace = await self.createWorkspace(workspaceFolder) {
1107-
self.workspaces.append(workspace)
1174+
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
11081175
}
11091176
}
11101177

@@ -1127,7 +1194,7 @@ extension SourceKitServer {
11271194
// discard the workspace we created here since `workspaces` now isn't
11281195
// empty anymore.
11291196
if self.workspaces.isEmpty {
1130-
self.workspaces.append(workspace)
1197+
self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false))
11311198
}
11321199
}
11331200

@@ -1451,18 +1518,19 @@ extension SourceKitServer {
14511518
preChangeWorkspaces[docUri] = await self.workspaceForDocument(uri: docUri)
14521519
}
14531520
if let removed = notification.event.removed {
1454-
self.workspaces.removeAll { workspace in
1455-
return removed.contains(where: { workspaceFolder in
1456-
workspace.rootUri == workspaceFolder.uri
1457-
})
1521+
self.workspacesAndIsImplicit.removeAll { workspace in
1522+
// Close all implicit workspaces as well because we could have opened a new explicit workspace that now contains
1523+
// files from a previous implicit workspace.
1524+
return workspace.isImplicit
1525+
|| removed.contains(where: { workspaceFolder in workspace.workspace.rootUri == workspaceFolder.uri })
14581526
}
14591527
}
14601528
if let added = notification.event.added {
14611529
let newWorkspaces = await added.asyncCompactMap { await self.createWorkspace($0) }
14621530
for workspace in newWorkspaces {
14631531
await workspace.buildSystemManager.setDelegate(self)
14641532
}
1465-
self.workspaces.append(contentsOf: newWorkspaces)
1533+
self.workspacesAndIsImplicit += newWorkspaces.map { (workspace: $0, isImplicit: false) }
14661534
}
14671535

14681536
// For each document that has moved to a different workspace, close it in
@@ -2406,3 +2474,12 @@ fileprivate extension Sequence where Element: Hashable {
24062474
return self.filter { set.insert($0).inserted }
24072475
}
24082476
}
2477+
2478+
fileprivate extension URL {
2479+
func isPrefix(of other: URL) -> Bool {
2480+
guard self.pathComponents.count < other.pathComponents.count else {
2481+
return false
2482+
}
2483+
return other.pathComponents[0..<self.pathComponents.count] == self.pathComponents[...]
2484+
}
2485+
}

Tests/SKCoreTests/BuildSystemManagerTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,8 @@ private final actor ManualMainFilesProvider: MainFilesProvider {
425425

426426
/// A simple `BuildSystem` that wraps a dictionary, for testing.
427427
class ManualBuildSystem: BuildSystem {
428+
var projectRoot = try! AbsolutePath(validating: "/")
429+
428430
var map: [DocumentURI: FileBuildSettings] = [:]
429431

430432
var delegate: BuildSystemDelegate? = nil

Tests/SKSwiftPMWorkspaceTests/SwiftPMWorkspaceTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,7 @@ final class SwiftPMWorkspaceTests: XCTestCase {
669669
buildSetup: SourceKitServer.Options.testDefault.buildSetup
670670
)
671671

672-
assertEqual(await ws._packageRoot, try resolveSymlinks(tempDir.appending(component: "pkg")))
672+
assertEqual(await ws.projectRoot, try resolveSymlinks(tempDir.appending(component: "pkg")))
673673
}
674674
}
675675

Tests/SourceKitLSPTests/BuildSystemTests.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,14 @@ import LSPTestSupport
1515
import LanguageServerProtocol
1616
@_spi(Testing) import SKCore
1717
import SKTestSupport
18-
import SourceKitLSP
18+
@_spi(Testing) import SourceKitLSP
1919
import TSCBasic
2020
import XCTest
2121

22-
fileprivate extension SourceKitServer {
23-
func setWorkspaces(_ workspaces: [Workspace]) {
24-
self._workspaces = workspaces
25-
}
26-
}
27-
2822
/// Build system to be used for testing BuildSystem and BuildSystemDelegate functionality with SourceKitServer
2923
/// and other components.
3024
final class TestBuildSystem: BuildSystem {
25+
var projectRoot: AbsolutePath = try! AbsolutePath(validating: "/")
3126
var indexStorePath: AbsolutePath? = nil
3227
var indexDatabasePath: AbsolutePath? = nil
3328
var indexPrefixMappings: [PathPrefixMapping] = []
@@ -106,7 +101,7 @@ final class BuildSystemTests: XCTestCase {
106101
indexDelegate: nil
107102
)
108103

109-
await server.setWorkspaces([workspace])
104+
await server.setWorkspaces([(workspace: workspace, isImplicit: false)])
110105
await workspace.buildSystemManager.setDelegate(server)
111106
}
112107

0 commit comments

Comments
 (0)