Skip to content

Add ability to load Language Servers as extensions (bundles) #465

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CodeEdit/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@
</dict>
</array>
<key>GitHash</key>
<string>c7fee3a29a4477ce93e6e892af08e32078fa19ac</string>
<string>df5126734cd5f8c976187eba5d43204046775769</string>
</dict>
</plist>
118 changes: 97 additions & 21 deletions CodeEditModules/Modules/ExtensionsStore/src/ExtensionsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
import Light_Swift_Untar
import CodeEditKit
import GRDB
import LSP

/// Class which handles all extensions (its bundles, instances for each workspace and so on)
public final class ExtensionsManager {
Expand All @@ -28,6 +29,7 @@ public final class ExtensionsManager {

var loadedBundles: [UUID: Bundle] = [:]
var loadedPlugins: [PluginWorkspaceKey: ExtensionInterface] = [:]
var loadedLanguageServers: [PluginWorkspaceKey: LSPClient] = [:]

init() throws {
self.codeeditFolder = try FileManager.default
Expand All @@ -53,6 +55,11 @@ public final class ExtensionsManager {
table.column("loadable", .boolean)
}
}
migrator.registerMigration("v1.0.1") { database in
try database.alter(table: DownloadedPlugin.databaseTableName) { body in
body.add(column: "sdk", .text).defaults(to: "swift")
}
}
try migrator.migrate(self.dbQueue)
}

Expand All @@ -64,58 +71,119 @@ public final class ExtensionsManager {
}.forEach { (key: PluginWorkspaceKey, _) in
loadedPlugins.removeValue(forKey: key)
}
}

private func getExtensionBuilder(id: UUID) throws -> ExtensionBuilder.Type? {
if loadedBundles.keys.contains(id) {
return loadedBundles[id]?.principalClass as? ExtensionBuilder.Type
loadedLanguageServers.filter { elem in
return elem.key.workspace == url
}.forEach { (key: PluginWorkspaceKey, client: LSPClient) in
client.close()
loadedLanguageServers.removeValue(forKey: key)
}
}

private func loadBundle(id: UUID, withExtension ext: String) throws -> Bundle? {
guard let bundleURL = try FileManager.default.contentsOfDirectory(
at: extensionsFolder.appendingPathComponent(id.uuidString,
isDirectory: true),
includingPropertiesForKeys: nil,
options: .skipsPackageDescendants
).first else { return nil }
).first(where: {$0.pathExtension == ext}) else { return nil }

guard bundleURL.pathExtension == "ceext" else { return nil }
guard let bundle = Bundle(url: bundleURL) else { return nil }

guard bundle.load() else { return nil }

loadedBundles[id] = bundle

return bundle
}

private func getExtensionBuilder(id: UUID) throws -> ExtensionBuilder.Type? {
if loadedBundles.keys.contains(id) {
return loadedBundles[id]?.principalClass as? ExtensionBuilder.Type
}

guard let bundle = try loadBundle(id: id, withExtension: "ceext") else {
return nil
}

guard bundle.load() else { return nil }

return bundle.principalClass as? ExtensionBuilder.Type
}

private func getLSPClient(id: UUID, workspaceURL: URL) throws -> LSPClient? {
if loadedBundles.keys.contains(id) {
guard let lspFile = loadedBundles[id]?.infoDictionary?["CELSPExecutable"] as? String else {
return nil
}

guard let lspURL = loadedBundles[id]?.url(forResource: lspFile, withExtension: nil) else {
return nil
}

return try LSPClient(lspURL,
workspace: workspaceURL,
arguments: loadedBundles[id]?.infoDictionary?["CELSPArguments"] as? [String])
}

guard let bundle = try loadBundle(id: id, withExtension: "celsp") else {
return nil
}

guard let lspFile = bundle.infoDictionary?["CELSPExecutable"] as? String else {
return nil
}

guard let lspURL = bundle.url(forResource: lspFile, withExtension: nil) else {
return nil
}

return try LSPClient(lspURL,
workspace: workspaceURL,
arguments: loadedBundles[id]?.infoDictionary?["CELSPArguments"] as? [String])
}

/// Preloads all extensions' bundles to `loadedBundles`
public func preload() throws {
let plugins = try self.dbQueue.read { database in
try DownloadedPlugin.filter(Column("loadable") == true).fetchAll(database)
}

try plugins.forEach { plugin in
_ = try getExtensionBuilder(id: plugin.release)
switch plugin.sdk {
case .swift:
_ = try loadBundle(id: plugin.release, withExtension: "ceext")
case .languageServer:
_ = try loadBundle(id: plugin.release, withExtension: "celsp")
}
}
}

/// Loads extensions' bundles which were not loaded before and creates `ExtensionInterface` instances
/// with `ExtensionAPI` created using specified initializer
/// - Parameter apiInitializer: function which creates `ExtensionAPI` instance based on plugin's ID
public func load(_ apiInitializer: (String) -> ExtensionAPI) throws {
/// Loads extensions' bundles which were not loaded before and passes `ExtensionAPI` as a whole class
/// or workspace's URL
/// - Parameter apiBuilder: function which creates `ExtensionAPI` instance based on plugin's ID
public func load(_ apiBuilder: (String) -> ExtensionAPI) throws {
let plugins = try self.dbQueue.read { database in
try DownloadedPlugin.filter(Column("loadable") == true).fetchAll(database)
try DownloadedPlugin
.filter(Column("loadable") == true)
.fetchAll(database)
}

try plugins.forEach { plugin in
guard let builder = try getExtensionBuilder(id: plugin.release) else {
return
}
let api = apiBuilder(plugin.plugin.uuidString)
let key = PluginWorkspaceKey(releaseID: plugin.release, workspace: api.workspaceURL)

let api = apiInitializer(plugin.plugin.uuidString)
switch plugin.sdk {
case .swift:
guard let builder = try getExtensionBuilder(id: plugin.release) else {
return
}

let key = PluginWorkspaceKey(releaseID: plugin.release, workspace: api.workspaceURL)
loadedPlugins[key] = builder.init().build(withAPI: api)
loadedPlugins[key] = builder.init().build(withAPI: api)
case .languageServer:
guard let client = try getLSPClient(id: plugin.release, workspaceURL: api.workspaceURL) else {
return
}
loadedLanguageServers[key] = client
}
}
}

Expand Down Expand Up @@ -144,6 +212,7 @@ public final class ExtensionsManager {
)
.appendingPathComponent("\(release.id.uuidString).tar")

// TODO: show progress
let (source, _) = try await URLSession.shared.download(from: tarball)

if FileManager.default.fileExists(atPath: cacheTar.path) {
Expand All @@ -168,7 +237,7 @@ public final class ExtensionsManager {
// save to db

try await dbQueue.write { database in
try DownloadedPlugin(plugin: plugin.id, release: release.id, loadable: true)
try DownloadedPlugin(plugin: plugin.id, release: release.id, loadable: true, sdk: plugin.sdk)
.insert(database)
}
}
Expand Down Expand Up @@ -203,6 +272,13 @@ public final class ExtensionsManager {
}.forEach { (key: PluginWorkspaceKey, _) in
loadedPlugins.removeValue(forKey: key)
}

loadedLanguageServers.filter { elem in
return elem.key.releaseID == entry.release
}.forEach { (key: PluginWorkspaceKey, client: LSPClient) in
client.close()
loadedLanguageServers.removeValue(forKey: key)
}
}

/// Checks whether extension's bundle (plugin) is installed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ public struct DownloadedPlugin: Codable, FetchableRecord, PersistableRecord, Tab
public var plugin: UUID
public var release: UUID
public var loadable: Bool
public var sdk: Plugin.SDK
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public struct Plugin: Codable, Identifiable, Hashable {

public enum SDK: String, Codable, Hashable {
case swift
case languageServer = "language_server"
}

public enum ReleaseManagement: String, Codable, Hashable {
Expand Down
32 changes: 32 additions & 0 deletions CodeEditModules/Modules/LSP/src/LSPClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// LSPClient.swift
//
//
// Created by Pavel Kasila on 16.04.22.
//

import Foundation

/// A LSP client to handle Language Server process
public class LSPClient {
var executable: URL
var workspace: URL
var process: Process

/// Initialize new LSP client
/// - Parameters:
/// - executable: Executable of the Language Server to be run
/// - workspace: Workspace's URL
/// - arguments: Additional arguments from `CELSPArguments` in `Info.plist` of the Language Server bundle
public init(_ executable: URL, workspace: URL, arguments: [String]?) throws {
self.executable = executable
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: self.executable.path)
self.workspace = workspace
self.process = try Process.run(executable, arguments: arguments ?? ["--stdio"], terminationHandler: nil)
}

/// Close the process
public func close() {
self.process.terminate()
}
}
7 changes: 6 additions & 1 deletion CodeEditModules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,8 @@ let package = Package(
dependencies: [
"CodeEditKit",
"Light-Swift-Untar",
.productItem(name: "GRDB", package: "GRDB.swift", condition: nil)
.productItem(name: "GRDB", package: "GRDB.swift", condition: nil),
"LSP"
],
path: "Modules/ExtensionsStore/src"
),
Expand All @@ -268,5 +269,9 @@ let package = Package(
],
path: "Modules/Feedback/src"
),
.target(
name: "LSP",
path: "Modules/LSP/src"
),
]
)