diff --git a/Package.swift b/Package.swift index 0abcad90..2684560e 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( name: "SourceKit", dependencies: [ + "BuildServerProtocol", "LanguageServerProtocol", "SKCore", "Csourcekitd", @@ -50,7 +51,7 @@ let package = Package( // suitable for use in other packages. .target( name: "SKCore", - dependencies: ["LanguageServerProtocol"]), + dependencies: ["BuildServerProtocol", "LanguageServerProtocol", "LanguageServerProtocolJSONRPC"]), .testTarget( name: "SKCoreTests", dependencies: ["SKCore", "SKTestSupport"]), @@ -71,6 +72,11 @@ let package = Package( name: "LanguageServerProtocolTests", dependencies: ["LanguageServerProtocol", "SKTestSupport"]), + // BuildServerProtocol: connection between build server and language server to provide build and index info + .target( + name: "BuildServerProtocol", + dependencies: ["LanguageServerProtocolJSONRPC", "LanguageServerProtocol"]), + // SKSupport: Data structures, algorithms and platform-abstraction code that might be generally // useful to any Swift package. Similar in spirit to SwiftPM's Basic module. .target( diff --git a/Sources/BuildServerProtocol/InitializeBuild.swift b/Sources/BuildServerProtocol/InitializeBuild.swift new file mode 100644 index 00000000..be7b61a8 --- /dev/null +++ b/Sources/BuildServerProtocol/InitializeBuild.swift @@ -0,0 +1,135 @@ +import LanguageServerProtocol + +/// Like the language server protocol, the initialize request is sent +/// as the first request from the client to the server. If the server +/// receives a request or notification before the initialize request +/// it should act as follows: +/// +/// - For a request the response should be an error with code: -32002. +/// The message can be picked by the server. +/// +/// - Notifications should be dropped, except for the exit notification. +/// This will allow the exit of a server without an initialize request. +/// +/// Until the server has responded to the initialize request with an +/// InitializeBuildResult, the client must not send any additional +/// requests or notifications to the server. +public struct InitializeBuild: RequestType, Hashable { + public static let method: String = "build/initialize" + public typealias Response = InitializeBuildResult + + /// Name of the client + public var displayName: String + + /// The version of the client + public var version: String + + /// The BSP version that the client speaks= + public var bspVersion: String + + /// The rootUri of the workspace + public var rootUri: URL + + /// The capabilities of the client + public var capabilities: BuildClientCapabilities + + public init(displayName: String, version: String, bspVersion: String, rootUri: URL, capabilities: BuildClientCapabilities) { + self.displayName = displayName + self.version = version + self.bspVersion = bspVersion + self.rootUri = rootUri + self.capabilities = capabilities + } +} + +public struct BuildClientCapabilities: Codable, Hashable { + /// The languages that this client supports. + /// The ID strings for each language is defined in the LSP. + /// The server must never respond with build targets for other + /// languages than those that appear in this list. + public var languageIds: [String] + + public init(languageIds: [String]) { + self.languageIds = languageIds + } +} + +public struct InitializeBuildResult: ResponseType, Hashable { + /// Name of the server + public var displayName: String + + /// The version of the server + public var version: String + + /// The BSP version that the server speaks + public var bspVersion: String + + /// The capabilities of the build server + public var capabilities: BuildServerCapabilities + + /// Optional metadata about the server + public var data: [String:String]? + + public init(displayName: String, version: String, bspVersion: String, capabilities: BuildServerCapabilities, data: [String:String]? = nil) { + self.displayName = displayName + self.version = version + self.bspVersion = bspVersion + self.capabilities = capabilities + self.data = data + } +} + +public struct BuildServerCapabilities: Codable, Hashable { + /// The languages the server supports compilation via method buildTarget/compile. + public var compileProvider: CompileProvider? = nil + + /// The languages the server supports test execution via method buildTarget/test + public var testProvider: TestProvider? = nil + + /// The languages the server supports run via method buildTarget/run + public var runProvider: RunProvider? = nil + + /// The server can provide a list of targets that contain a + /// single text document via the method buildTarget/inverseSources + public var inverseSourcesProvider: Bool? = nil + + /// The server provides sources for library dependencies + /// via method buildTarget/dependencySources + public var dependencySourcesProvider: Bool? = nil + + /// The server provides all the resource dependencies + /// via method buildTarget/resources + public var resourcesProvider: Bool? = nil + + /// The server sends notifications to the client on build + /// target change events via buildTarget/didChange + public var buildTargetChangedProvider: Bool? = nil +} + +public struct CompileProvider: Codable, Hashable { + public var languageIds: [String] + + public init(languageIds: [String]) { + self.languageIds = languageIds + } +} + +public struct RunProvider: Codable, Hashable { + public var languageIds: [String] + + public init(languageIds: [String]) { + self.languageIds = languageIds + } +} + +public struct TestProvider: Codable, Hashable { + public var languageIds: [String] + + public init(languageIds: [String]) { + self.languageIds = languageIds + } +} + +public struct InitializedBuildNotification: NotificationType { + public static let method: String = "build/initialized" +} diff --git a/Sources/BuildServerProtocol/Messages.swift b/Sources/BuildServerProtocol/Messages.swift new file mode 100644 index 00000000..ca981ca8 --- /dev/null +++ b/Sources/BuildServerProtocol/Messages.swift @@ -0,0 +1,13 @@ +import LanguageServerProtocol + +fileprivate let requestTypes: [_RequestType.Type] = [ + InitializeBuild.self, + ShutdownBuild.self, +] + +fileprivate let notificationTypes: [NotificationType.Type] = [ + InitializedBuildNotification.self, + ExitBuildNotification.self, +] + +public let bspRegistry = MessageRegistry(requests: requestTypes, notifications: notificationTypes) diff --git a/Sources/BuildServerProtocol/ShutdownBuild.swift b/Sources/BuildServerProtocol/ShutdownBuild.swift new file mode 100644 index 00000000..d69e240f --- /dev/null +++ b/Sources/BuildServerProtocol/ShutdownBuild.swift @@ -0,0 +1,21 @@ +import LanguageServerProtocol + +/// Like the language server protocol, the shutdown build request is +/// sent from the client to the server. It asks the server to shut down, +/// but to not exit (otherwise the response might not be delivered +/// correctly to the client). There is a separate exit notification +/// that asks the server to exit. +public struct ShutdownBuild: RequestType { + public static let method: String = "build/shutdown" + public typealias Response = ShutdownBuildResult +} + +public struct ShutdownBuildResult: ResponseType { } + +/// Like the language server protocol, a notification to ask the +/// server to exit its process. The server should exit with success +/// code 0 if the shutdown request has been received before; +/// otherwise with error code 1. +public struct ExitBuildNotification: NotificationType { + public static let method: String = "build/exit" +} diff --git a/Sources/SKCore/BuildServerBuildSystem.swift b/Sources/SKCore/BuildServerBuildSystem.swift new file mode 100644 index 00000000..771282a3 --- /dev/null +++ b/Sources/SKCore/BuildServerBuildSystem.swift @@ -0,0 +1,174 @@ +import Basic +import LanguageServerProtocol +import LanguageServerProtocolJSONRPC +import SKSupport +import Foundation +import BuildServerProtocol + +/// A `BuildSystem` based on communicating with a build server +/// +/// Provides build settings from a build server launched based on a +/// `buildServer.json` configuration file provided in the repo root. +public final class BuildServerBuildSystem { + + let projectRoot: AbsolutePath + let buildFolder: AbsolutePath? + let serverConfig: BuildServerConfig + + var handler: BuildServerHandler? + var buildServer: Connection? + public private(set) var indexStorePath: AbsolutePath? + + public init(projectRoot: AbsolutePath, buildFolder: AbsolutePath?, fileSystem: FileSystem = localFileSystem) throws { + let configPath = projectRoot.appending(component: "buildServer.json") + let config = try loadBuildServerConfig(path: configPath, fileSystem: fileSystem) + + self.buildFolder = buildFolder + self.projectRoot = projectRoot + self.serverConfig = config + } + + /// Creates a build system using the Build Server Protocol config. + /// + /// - Returns: nil if `projectRoot` has no config or there is an error parsing it. + public convenience init?(projectRoot: AbsolutePath?, buildSetup: BuildSetup) + { + if projectRoot == nil { return nil } + + do { + try self.init(projectRoot: projectRoot!, buildFolder: buildSetup.path) + } catch { + log("failed to load build server config: \(error)", level: .error) + return nil + } + } + + /// Creates the RPC connection to the server specified in the config + /// and sends an initialize request. + /// + /// - Returns: false if the server fails to start or returns an error during initialization. + public func initialize() -> Bool { + let serverPath = AbsolutePath(serverConfig.argv[0], relativeTo: projectRoot) + let flags = Array(serverConfig.argv[1...]) + let languages = [ + Language.c.rawValue, + Language.cpp.rawValue, + Language.objective_c.rawValue, + Language.objective_cpp.rawValue, + Language.swift.rawValue, + ] + + let initializeRequest = InitializeBuild( + displayName: "SourceKit-LSP", + version: "1.0", + bspVersion: "2.0", + rootUri: self.projectRoot.asURL, + capabilities: BuildClientCapabilities(languageIds: languages)) + + do { + let handler = BuildServerHandler() + let buildServer = try makeJSONRPCBuildServer(client: handler, serverPath: serverPath, serverFlags: flags) + let response = try buildServer.sendSync(initializeRequest) + log("initialized build server \(response.displayName)") + self.buildServer = buildServer + self.handler = handler + return true + } catch { + log("failed to initialize build server: \(error)") + return false + } + } +} + +final class BuildServerHandler: LanguageServerEndpoint { + override func _registerBuiltinHandlers() { } +} + +extension BuildServerBuildSystem: BuildSystem { + + public var indexDatabasePath: AbsolutePath? { + return buildFolder?.appending(components: "index", "db") + } + + public func settings(for url: URL, _ language: Language) -> FileBuildSettings? { + // TODO: add `textDocument/sourceKitOptions` request and response + return nil + } + + public func toolchain(for: URL, _ language: Language) -> Toolchain? { + return nil + } + +} + +private func loadBuildServerConfig(path: AbsolutePath, fileSystem: FileSystem) throws -> BuildServerConfig { + return try BuildServerConfig(json: JSON.init(bytes: fileSystem.readFileContents(path))) +} + +struct BuildServerConfig: JSONMappable { + /// The name of the build tool. + let name: String + + /// The version of the build tool. + let version: String + + /// The bsp version of the build tool. + let bspVersion: String + + /// A collection of languages supported by this BSP server. + let languages: [String] + + /// Command arguments runnable via system processes to start a BSP server. + let argv: [String] + + init(json: JSON) throws { + name = try json.get("name") + version = try json.get("version") + bspVersion = try json.get("bspVersion") + languages = try json.get("languages") + argv = try json.get("argv") + if argv.count < 1 { + throw BuildServerError.invalidConfig + } + } +} + +enum BuildServerError: Error { + case invalidConfig +} + +private func makeJSONRPCBuildServer(client: MessageHandler, serverPath: AbsolutePath, serverFlags: [String]?) throws -> Connection { + let clientToServer = Pipe() + let serverToClient = Pipe() + + let connection = JSONRPCConection( + protocol: BuildServerProtocol.bspRegistry, + inFD: serverToClient.fileHandleForReading.fileDescriptor, + outFD: clientToServer.fileHandleForWriting.fileDescriptor + ) + + connection.start(receiveHandler: client) + let process = Foundation.Process() + + if #available(OSX 10.13, *) { + process.executableURL = serverPath.asURL + } else { + process.launchPath = serverPath.pathString + } + + process.arguments = serverFlags + process.standardOutput = serverToClient + process.standardInput = clientToServer + process.terminationHandler = { process in + log("build server exited: \(process.terminationReason) \(process.terminationStatus)") + connection.close() + } + + if #available(OSX 10.13, *) { + try process.run() + } else { + process.launch() + } + + return connection +} diff --git a/Sources/SourceKit/Workspace.swift b/Sources/SourceKit/Workspace.swift index fe176ed0..e8a9e99c 100644 --- a/Sources/SourceKit/Workspace.swift +++ b/Sources/SourceKit/Workspace.swift @@ -18,7 +18,7 @@ import Basic import SPMUtility import SKSwiftPMWorkspace -/// Represents the configuration and sate of a project or combination of projects being worked on +/// Represents the configuration and state of a project or combination of projects being worked on /// together. /// /// In LSP, this represents the per-workspace state that is typically only available after the @@ -82,13 +82,16 @@ public final class Workspace { let settings = BuildSystemList() self.buildSettings = settings - settings.providers.insert(CompilationDatabaseBuildSystem(projectRoot: rootPath), at: 0) - - if let swiftpm = SwiftPMWorkspace(url: url, - toolchainRegistry: toolchainRegistry, - buildSetup: buildSetup - ) { - settings.providers.insert(swiftpm, at: 0) + if let buildServer = BuildServerBuildSystem(projectRoot: rootPath, buildSetup: buildSetup), + buildServer.initialize() { + settings.providers.insert(buildServer, at: 0) + } else { + settings.providers.insert(CompilationDatabaseBuildSystem(projectRoot: rootPath), at: 0) + if let swiftpm = SwiftPMWorkspace(url: url, + toolchainRegistry: toolchainRegistry, + buildSetup: buildSetup) { + settings.providers.insert(swiftpm, at: 0) + } } if let storePath = buildSettings.indexStorePath, diff --git a/Tests/INPUTS/BuildServerBuildSystemTests.testInitParsesConfig/buildServer.json b/Tests/INPUTS/BuildServerBuildSystemTests.testInitParsesConfig/buildServer.json new file mode 100644 index 00000000..a8f6db1d --- /dev/null +++ b/Tests/INPUTS/BuildServerBuildSystemTests.testInitParsesConfig/buildServer.json @@ -0,0 +1,7 @@ +{ + "name": "client name", + "version": "10", + "bspVersion": "2.0", + "languages": [], + "argv": ["path/to/server"] +} \ No newline at end of file diff --git a/Tests/INPUTS/BuildServerBuildSystemTests.testServerInitialize/buildServer.json b/Tests/INPUTS/BuildServerBuildSystemTests.testServerInitialize/buildServer.json new file mode 100644 index 00000000..cf9e70bd --- /dev/null +++ b/Tests/INPUTS/BuildServerBuildSystemTests.testServerInitialize/buildServer.json @@ -0,0 +1,7 @@ +{ + "name": "client name", + "version": "10", + "bspVersion": "2.0", + "languages": ["a", "b"], + "argv": ["server.py"] +} \ No newline at end of file diff --git a/Tests/INPUTS/BuildServerBuildSystemTests.testServerInitialize/server.py b/Tests/INPUTS/BuildServerBuildSystemTests.testServerInitialize/server.py new file mode 100755 index 00000000..669ddc07 --- /dev/null +++ b/Tests/INPUTS/BuildServerBuildSystemTests.testServerInitialize/server.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +import json +import sys + + +while True: + line = sys.stdin.readline() + if len(line) == 0: + break + + assert line.startswith('Content-Length:') + length = int(line[len('Content-Length:'):]) + sys.stdin.readline() + message = json.loads(sys.stdin.read(length)) + + if message["method"] == "build/initialize": + response = { + "jsonrpc": "2.0", + "id": message["id"], + "result": { + "displayName": "test server", + "version": "0.1", + "bspVersion": "2.0", + "rootUri": "blah", + "capabilities": {"languageIds": ["a", "b"]}, + "data": { + "index_store_path": "some/index/store/path" + } + } + } + else: + response = { + "jsonrpc": "2.0", + "id": message["id"], + "error": { + "code": 123, + "message": "unhandled method", + } + } + + responseStr = json.dumps(response) + sys.stdout.write("Content-Length: {}\r\n\r\n{}".format(len(responseStr), responseStr)) + sys.stdout.flush() + diff --git a/Tests/SKCoreTests/BuildServerBuildSystemTests.swift b/Tests/SKCoreTests/BuildServerBuildSystemTests.swift new file mode 100644 index 00000000..77c57573 --- /dev/null +++ b/Tests/SKCoreTests/BuildServerBuildSystemTests.swift @@ -0,0 +1,29 @@ +import XCTest +import SKCore +import Basic +import LanguageServerProtocol + +final class BuildServerBuildSystemTests: XCTestCase { + + func testInitParsesConfig() { + let root = AbsolutePath( + inputsDirectory().appendingPathComponent(testDirectoryName, isDirectory: true).path) + let buildFolder = AbsolutePath(NSTemporaryDirectory()) + + let buildSystem = try? BuildServerBuildSystem(projectRoot: root, buildFolder: buildFolder) + + XCTAssertNotNil(buildSystem) + } + + func testServerInitialize() { + let root = AbsolutePath( + inputsDirectory().appendingPathComponent(testDirectoryName, isDirectory: true).path) + let buildFolder = AbsolutePath(NSTemporaryDirectory()) + + let buildSystem = try? BuildServerBuildSystem(projectRoot: root, buildFolder: buildFolder) + + XCTAssertNotNil(buildSystem) + XCTAssertTrue(buildSystem!.initialize()) + } + +}