diff --git a/Sources/SKCore/BuildServerBuildSystem.swift b/Sources/SKCore/BuildServerBuildSystem.swift index eb6bba43..dcf189b2 100644 --- a/Sources/SKCore/BuildServerBuildSystem.swift +++ b/Sources/SKCore/BuildServerBuildSystem.swift @@ -219,6 +219,8 @@ extension BuildServerBuildSystem: BuildSystem { } } } + + public func filesDidChange(_ events: [FileEvent]) {} } private func loadBuildServerConfig(path: AbsolutePath, fileSystem: FileSystem) throws -> BuildServerConfig { diff --git a/Sources/SKCore/BuildSystem.swift b/Sources/SKCore/BuildSystem.swift index e39d1cb9..e0725f1b 100644 --- a/Sources/SKCore/BuildSystem.swift +++ b/Sources/SKCore/BuildSystem.swift @@ -59,6 +59,9 @@ public protocol BuildSystem: AnyObject { /// Returns the output paths for the requested build targets func buildTargetOutputPaths(targets: [BuildTargetIdentifier], reply: @escaping (LSPResult<[OutputsItem]>) -> Void) + + /// Called when files in the project change. + func filesDidChange(_ events: [FileEvent]) } public let buildTargetsNotSupported = diff --git a/Sources/SKCore/BuildSystemManager.swift b/Sources/SKCore/BuildSystemManager.swift index 321d521a..5f53a50e 100644 --- a/Sources/SKCore/BuildSystemManager.swift +++ b/Sources/SKCore/BuildSystemManager.swift @@ -125,6 +125,13 @@ public final class BuildSystemManager { self.fallbackSettingsTimeout = fallbackSettingsTimeout self.buildSystem?.delegate = self } + + public func filesDidChange(_ events: [FileEvent]) { + queue.async { + self.buildSystem?.filesDidChange(events) + self.fallbackBuildSystem?.filesDidChange(events) + } + } } extension BuildSystemManager: BuildSystem { diff --git a/Sources/SKCore/CompilationDatabaseBuildSystem.swift b/Sources/SKCore/CompilationDatabaseBuildSystem.swift index f7062422..da2cdde6 100644 --- a/Sources/SKCore/CompilationDatabaseBuildSystem.swift +++ b/Sources/SKCore/CompilationDatabaseBuildSystem.swift @@ -109,6 +109,8 @@ extension CompilationDatabaseBuildSystem: BuildSystem { return compdb } + + public func filesDidChange(_ events: [FileEvent]) {} } extension CompilationDatabaseBuildSystem { diff --git a/Sources/SKCore/FallbackBuildSystem.swift b/Sources/SKCore/FallbackBuildSystem.swift index 3560997c..8faf8d3e 100644 --- a/Sources/SKCore/FallbackBuildSystem.swift +++ b/Sources/SKCore/FallbackBuildSystem.swift @@ -98,4 +98,6 @@ public final class FallbackBuildSystem: BuildSystem { args.append(file) return FileBuildSettings(compilerArguments: args) } + + public func filesDidChange(_ events: [FileEvent]) {} } diff --git a/Sources/SKSwiftPMWorkspace/SwiftPMWorkspace.swift b/Sources/SKSwiftPMWorkspace/SwiftPMWorkspace.swift index b7d8d7ea..d72f3974 100644 --- a/Sources/SKSwiftPMWorkspace/SwiftPMWorkspace.swift +++ b/Sources/SKSwiftPMWorkspace/SwiftPMWorkspace.swift @@ -162,7 +162,7 @@ extension SwiftPMWorkspace { log(diagnostic.description, level: diagnostic.severity.asLogLevel) }) - self.packageGraph = try self.workspace.loadPackageGraph( + let packageGraph = try self.workspace.loadPackageGraph( rootInput: PackageGraphRootInput(packages: [packageRoot]), observabilityScope: observabilitySystem.topScope ) @@ -174,6 +174,11 @@ extension SwiftPMWorkspace { observabilityScope: observabilitySystem.topScope ) + /// Make sure to execute any throwing statements before setting any + /// properties because otherwise we might end up in an inconsistent state + /// with only some properties modified. + self.packageGraph = packageGraph + self.fileToTarget = [AbsolutePath: TargetBuildDescription]( packageGraph.allTargets.flatMap { target in return target.sources.paths.compactMap { @@ -293,6 +298,15 @@ extension SwiftPMWorkspace: SKCore.BuildSystem { return nil } + + public func filesDidChange(_ events: [FileEvent]) { + if events.contains(where: { $0.type == .created }) { + // TODO: It should not be necessary to reload the entire package just to get build settings for one file. + orLog { + try self.reloadPackage() + } + } + } } extension SwiftPMWorkspace { diff --git a/Sources/SKTestSupport/SKSwiftPMTestWorkspace.swift b/Sources/SKTestSupport/SKSwiftPMTestWorkspace.swift index 974c395d..180c1866 100644 --- a/Sources/SKTestSupport/SKSwiftPMTestWorkspace.swift +++ b/Sources/SKTestSupport/SKSwiftPMTestWorkspace.swift @@ -133,6 +133,10 @@ extension SKSwiftPMTestWorkspace { version: 1, text: try sources.sourceCache.get(url)))) } + + public func closeDocument(_ url: URL) { + sk.send(DidCloseTextDocumentNotification(textDocument: TextDocumentIdentifier(DocumentURI(url)))) + } } extension XCTestCase { diff --git a/Sources/SourceKitLSP/CapabilityRegistry.swift b/Sources/SourceKitLSP/CapabilityRegistry.swift index 3fe417bb..c6074397 100644 --- a/Sources/SourceKitLSP/CapabilityRegistry.swift +++ b/Sources/SourceKitLSP/CapabilityRegistry.swift @@ -29,6 +29,9 @@ public final class CapabilityRegistry { /// Dynamically registered semantic tokens options. private var semanticTokens: [CapabilityRegistration: SemanticTokensRegistrationOptions] = [:] + /// Dynamically registered file watchers. + private var didChangeWatchedFiles: DidChangeWatchedFilesRegistrationOptions? + /// Dynamically registered command IDs. private var commandIds: Set = [] @@ -54,6 +57,10 @@ public final class CapabilityRegistry { clientCapabilities.workspace?.executeCommand?.dynamicRegistration == true } + public var clientHasDynamicDidChangeWatchedFilesRegistration: Bool { + clientCapabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration == true + } + /// Dynamically register completion capabilities if the client supports it and /// we haven't yet registered any completion capabilities for the given /// languages. @@ -82,6 +89,28 @@ public final class CapabilityRegistry { registerOnClient(registration) } + public func registerDidChangeWatchedFiles( + watchers: [FileSystemWatcher], + registerOnClient: ClientRegistrationHandler + ) { + guard clientHasDynamicDidChangeWatchedFilesRegistration else { return } + if let registration = didChangeWatchedFiles { + if watchers != registration.watchers { + log("Unable to register new file system watchers \(watchers) due to pre-existing options \(registration.watchers)", level: .warning) + } + return + } + let registrationOptions = DidChangeWatchedFilesRegistrationOptions( + watchers: watchers) + let registration = CapabilityRegistration( + method: DidChangeWatchedFilesNotification.method, + registerOptions: self.encode(registrationOptions)) + + self.didChangeWatchedFiles = registrationOptions + + registerOnClient(registration) + } + /// Dynamically register folding range capabilities if the client supports it and /// we haven't yet registered any folding range capabilities for the given /// languages. diff --git a/Sources/SourceKitLSP/SourceKitServer.swift b/Sources/SourceKitLSP/SourceKitServer.swift index 6e18f5e7..180b3dba 100644 --- a/Sources/SourceKitLSP/SourceKitServer.swift +++ b/Sources/SourceKitLSP/SourceKitServer.swift @@ -75,6 +75,7 @@ public final class SourceKitServer: LanguageServer { registerWorkspaceNotfication(SourceKitServer.openDocument) registerWorkspaceNotfication(SourceKitServer.closeDocument) registerWorkspaceNotfication(SourceKitServer.changeDocument) + registerWorkspaceNotfication(SourceKitServer.didChangeWatchedFiles) registerToolchainTextDocumentNotification(SourceKitServer.willSaveDocument) registerToolchainTextDocumentNotification(SourceKitServer.didSaveDocument) @@ -582,6 +583,14 @@ extension SourceKitServer { self.dynamicallyRegisterCapability($0, registry) } } + /// Request the client to send us DidChangeWatchedFileNotifications for all new files. + /// Currently, these notifications are only handled by SwiftPMWorkspace, which will reload the package when a new file is added. + let watchers = [ + FileSystemWatcher(globPattern: "**", kind: [.create, .delete]) + ] + registry.registerDidChangeWatchedFiles(watchers: watchers) { + self.dynamicallyRegisterCapability($0, registry) + } } private func dynamicallyRegisterCapability( @@ -662,7 +671,7 @@ extension SourceKitServer { func openDocument(_ note: Notification, workspace: Workspace) { openDocument(note.params, workspace: workspace) } - + private func openDocument(_ note: DidOpenTextDocumentNotification, workspace: Workspace) { // Immediately open the document even if the build system isn't ready. This is important since // we check that the document is open when we receive messages from the build system. @@ -748,6 +757,10 @@ extension SourceKitServer { languageService.didSaveDocument(note.params) } + func didChangeWatchedFiles(_ note: Notification, workspace: Workspace) { + workspace.buildSystemManager.filesDidChange(note.params.changes) + } + // MARK: - Language features func completion( diff --git a/Tests/SKCoreTests/BuildSystemManagerTests.swift b/Tests/SKCoreTests/BuildSystemManagerTests.swift index e49074c1..ea1d56ab 100644 --- a/Tests/SKCoreTests/BuildSystemManagerTests.swift +++ b/Tests/SKCoreTests/BuildSystemManagerTests.swift @@ -503,6 +503,8 @@ class ManualBuildSystem: BuildSystem { reply: @escaping (LSPResult<[OutputsItem]>) -> Void) { fatalError() } + + func filesDidChange(_ events: [FileEvent]) {} } /// A `BuildSystemDelegate` setup for testing. diff --git a/Tests/SourceKitLSPTests/BuildSystemTests.swift b/Tests/SourceKitLSPTests/BuildSystemTests.swift index 91ef2b6d..ae8d0605 100644 --- a/Tests/SourceKitLSPTests/BuildSystemTests.swift +++ b/Tests/SourceKitLSPTests/BuildSystemTests.swift @@ -65,6 +65,8 @@ final class TestBuildSystem: BuildSystem { func buildTargetOutputPaths(targets: [BuildTargetIdentifier], reply: @escaping (LSPResult<[OutputsItem]>) -> Void) { reply(.failure(buildTargetsNotSupported)) } + + func filesDidChange(_ events: [FileEvent]) {} } final class BuildSystemTests: XCTestCase { diff --git a/Tests/SourceKitLSPTests/SwiftPMIntegration.swift b/Tests/SourceKitLSPTests/SwiftPMIntegration.swift index 62cd5e40..892b314e 100644 --- a/Tests/SourceKitLSPTests/SwiftPMIntegration.swift +++ b/Tests/SourceKitLSPTests/SwiftPMIntegration.swift @@ -56,4 +56,67 @@ final class SwiftPMIntegrationTests: XCTestCase { deprecated: false), ]) } + + func testAddFile() throws { + guard let ws = try staticSourceKitSwiftPMWorkspace(name: "SwiftPMPackage") else { return } + try ws.buildAndIndex() + + /// Add a new file to the project that wasn't built + _ = try ws.sources.edit { builder in + let otherFile = ws.sources.rootDirectory + .appendingPathComponent("Sources") + .appendingPathComponent("lib") + .appendingPathComponent("other.swift") + let otherFileContents = """ + func baz(l: Lib) { + l . /*newFile:call*/foo() + } + """ + builder.write(otherFileContents, to: otherFile) + } + + let newFile = ws.testLoc("newFile:call") + + // Check that we don't get cross-file code completion before we send a `DidChangeWatchedFilesNotification` to make sure we didn't include the file in the initial retrieval of build settings. + try ws.openDocument(newFile.url, language: .swift) + + let completionsBeforeDidChangeNotification = try withExtendedLifetime(ws) { + try ws.sk.sendSync(CompletionRequest(textDocument: newFile.docIdentifier, position: newFile.position)) + } + XCTAssertEqual(completionsBeforeDidChangeNotification.items, []) + ws.closeDocument(newFile.url) + + // Send a `DidChangeWatchedFilesNotification` and verify that we now get cross-file code completion. + ws.sk.send(DidChangeWatchedFilesNotification(changes: [ + FileEvent(uri: newFile.docUri, type: .created) + ])) + try ws.openDocument(newFile.url, language: .swift) + + let completions = try withExtendedLifetime(ws) { + try ws.sk.sendSync(CompletionRequest(textDocument: newFile.docIdentifier, position: newFile.position)) + } + + XCTAssertEqual(completions.items, [ + CompletionItem( + label: "foo()", + kind: .method, + detail: "Void", + sortText: nil, + filterText: "foo()", + textEdit: TextEdit(range: Position(line: 1, utf16index: 22)..