Beginning language server

Changed files
+468 -19
Sources
Tests
PterodactylLanguageServerTests
+26 -2
Package.swift
···
.library(
name: "PterodactylBuild",
targets: ["PterodactylBuild"]
+
),
+
.library(
+
name: "PterodactylLanguageServer",
+
targets: ["PterodactylLanguageServer"]
)
],
dependencies: [
-
// .package(url: "https://github.com/ChimeHQ/LanguageServer", branch: "main"),
+
.package(url: "https://github.com/ChimeHQ/LanguageServer", branch: "main"),
.package(url: "https://github.com/ChimeHQ/LanguageServerProtocol", branch: "main"),
.package(url: "https://github.com/apple/swift-llbuild2.git", branch: "main"),
-
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0")
+
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
+
.package(url: "https://github.com/apple/swift-log", from: "1.6.0")
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
···
.product(name: "llbuild2fx", package: "swift-llbuild2")
]
),
+
.target(
+
name: "PterodactylLanguageServer",
+
dependencies: [
+
"LanguageServer",
+
.product(name: "llbuild2fx", package: "swift-llbuild2"),
+
.product(name: "Logging", package: "swift-log"),
+
"PterodactylBuild",
+
"PterodactylSyntax"
+
]
+
),
.testTarget(
name: "PterodactylBuildTests",
dependencies: [
"PterodactylBuild",
"PterodactylSyntax",
+
.product(name: "llbuild2fx", package: "swift-llbuild2")
+
]
+
),
+
.testTarget(
+
name: "PterodactylLanguageServerTests",
+
dependencies: [
+
"PterodactylBuild",
+
"PterodactylSyntax",
+
"PterodactylLanguageServer",
.product(name: "llbuild2fx", package: "swift-llbuild2")
]
),
+4 -4
Sources/PterodactylBuild/BuildContext.swift
···
}
extension BuildKey {
-
func computeValue(_ fi: FXFunctionInterface<Self>, _ ctx: Context) async throws -> ValueType {
+
public func computeValue(_ fi: FXFunctionInterface<Self>, _ ctx: Context) async throws -> ValueType {
try await computeValue(BuildContext(functionInterface: fi, context: ctx))
}
}
extension LLBCASFileTree {
-
static func load<X: FXKey>(id: LLBDataID, in ctx: BuildContext<X>) async throws -> LLBCASFileTree {
+
public static func load<X: FXKey>(id: LLBDataID, in ctx: BuildContext<X>) async throws -> LLBCASFileTree {
try await load(id: id, from: ctx.db, ctx.context).get()
}
-
func remove<X: FXKey>(path: AbsolutePath, in ctx: BuildContext<X>) async throws -> LLBCASFileTree {
+
public func remove<X: FXKey>(path: AbsolutePath, in ctx: BuildContext<X>) async throws -> LLBCASFileTree {
try await remove(path: path, in: ctx.db, ctx.context).get()
}
-
func traverse<X: FXKey>(root: AbsolutePath, in ctx: BuildContext<X>, _ callback: (AbsolutePath, LLBDataID, LLBDirectoryEntry) async throws -> Void) async throws {
+
public func traverse<X: FXKey>(root: AbsolutePath, in ctx: BuildContext<X>, _ callback: (AbsolutePath, LLBDataID, LLBDirectoryEntry) async throws -> Void) async throws {
try await traverse(root: root, in: ctx.db, ctx.context, callback)
}
}
+3 -3
Sources/PterodactylBuild/Keys.swift
···
import Foundation
-
enum Keys {
-
enum Blob {}
-
enum SourceTree {}
+
public enum Keys {
+
public enum Blob {}
+
public enum SourceTree {}
}
+8 -4
Sources/PterodactylBuild/Keys/Blob/ReadContents.swift
···
import llbuild2fx
extension Keys.Blob {
-
struct ReadContents: BuildKey {
+
public struct ReadContents: BuildKey {
let blobId: LLBDataID
-
typealias ValueType = String
+
public init(blobId: LLBDataID) {
+
self.blobId = blobId
+
}
-
static let versionDependencies: [any FXVersioning.Type] = []
+
public typealias ValueType = String
-
func computeValue(_ ctx: BuildContext<Self>) async throws -> String {
+
public static let versionDependencies: [any FXVersioning.Type] = []
+
+
public func computeValue(_ ctx: BuildContext<Self>) async throws -> String {
let contents = try await ctx.load(blobId)
return try await String(decoding: Data(ctx.read(blob: contents.blob!)), as: UTF8.self)
}
+15
Sources/PterodactylLanguageServer/AbsolutePath+URI.swift
···
+
import Foundation
+
import TSCBasic
+
import LanguageServerProtocol
+
+
extension AbsolutePath {
+
public static func fromDocumentUri(_ uri: DocumentUri) throws -> Self {
+
guard let url = URL(string: uri) else {
+
throw URLError(.badURL)
+
}
+
guard url.isFileURL else {
+
throw URLError(.unsupportedURL)
+
}
+
return try Self(validating: url.path)
+
}
+
}
+252
Sources/PterodactylLanguageServer/EventHandler.swift
···
+
// SPDX-FileCopyrightText: 2025 The Project Pterodactyl Developers
+
//
+
// SPDX-License-Identifier: MPL-2.0
+
+
import Foundation
+
import JSONRPC
+
import LanguageServer
+
import LanguageServerProtocol
+
import Logging
+
import PterodactylBuild
+
import TSCBasic
+
import llbuild2fx
+
+
final class EventHandler {
+
private let connection: JSONRPCClientConnection
+
private let buildEngine: FXEngine
+
private let casContext: TSCUtility.Context
+
private let casClient: LLBCASFSClient
+
private let sourceTreeManager: SourceTreeManager
+
+
init(connection: JSONRPCClientConnection) async throws {
+
self.connection = connection
+
+
let group = LLBMakeDefaultDispatchGroup()
+
let db = LLBInMemoryCASDatabase(group: group)
+
let functionCache = FXInMemoryFunctionCache(group: group)
+
let executor = FXLocalExecutor()
+
self.buildEngine = FXEngine(group: group, db: db, functionCache: functionCache, executor: executor)
+
self.casClient = LLBCASFSClient(db)
+
self.casContext = Context()
+
+
self.sourceTreeManager = try await SourceTreeManager(buildEngine: buildEngine, casClient: casClient, casContext: casContext)
+
}
+
+
func publishDiagnostics(uri: String, version: Int?) async throws {
+
+
}
+
}
+
+
+
extension EventHandler: LanguageServer.EventHandler {
+
var textDocumentSinkOptions: TextDocumentSyncOptions {
+
TextDocumentSyncOptions(
+
openClose: true,
+
change: .full,
+
save: .optionA(false)
+
)
+
}
+
+
var completionOptions: CompletionOptions {
+
CompletionOptions(
+
workDoneProgress: false,
+
triggerCharacters: [], // TODO
+
allCommitCharacters: nil,
+
resolveProvider: false,
+
completionItem: nil
+
)
+
}
+
+
var semanticTokensLegend: SemanticTokensLegend {
+
SemanticTokensLegend(tokenTypes: SemanticTokenTypes.allStrings, tokenModifiers: SemanticTokenModifiers.allStrings)
+
}
+
+
var semanticTokensOptions: SemanticTokensOptions {
+
SemanticTokensOptions(legend: semanticTokensLegend, full: .optionB(SemanticTokensClientCapabilities.Requests.Full(delta: false)))
+
}
+
+
func initialize(id: JSONId, params: InitializeParams) async -> Response<InitializationResponse> {
+
Logger.shared.debug("Received initialize request")
+
var serverCapabilities = ServerCapabilities()
+
serverCapabilities.textDocumentSync = .optionA(textDocumentSinkOptions)
+
serverCapabilities.completionProvider = completionOptions
+
serverCapabilities.hoverProvider = .optionA(false)
+
serverCapabilities.semanticTokensProvider = .optionA(semanticTokensOptions)
+
serverCapabilities.foldingRangeProvider = .optionA(true)
+
let response = InitializationResponse(
+
capabilities: serverCapabilities,
+
serverInfo: nil
+
)
+
return .success(response)
+
}
+
+
func textDocumentDidChange(_ params: DidChangeTextDocumentParams) async {
+
guard let text = params.contentChanges.first?.text else { return }
+
do {
+
try await sourceTreeManager.setBufferText(uri: params.textDocument.uri, text: text)
+
// FIXME: this needs to be a transaction.
+
// try await publishDiagnostics(uri: params.textDocument.uri, version: params.textDocument.version)
+
} catch {}
+
}
+
+
func textDocumentDidOpen(_ params: DidOpenTextDocumentParams) async {
+
// TODO: restore
+
// await bufferManager.setBufferText(key: params.textDocument.uri, text: params.textDocument.text)
+
do {
+
try await publishDiagnostics(uri: params.textDocument.uri, version: params.textDocument.version)
+
} catch {}
+
}
+
+
func semanticTokensFull(id: JSONId, params: SemanticTokensParams) async -> Response<SemanticTokensResponse> {
+
// TODO: restore
+
// guard let document = await bufferManager.documentForBuffer(key: params.textDocument.uri) else { return .success(nil) }
+
+
let tokenSink: [SemanticToken] = []
+
// TODO: restore
+
// document.tree.collectSemanticTokens(&tokenSink)
+
+
let response = SemanticTokensResponse(SemanticTokens(resultId: nil, tokens: tokenSink))
+
return .success(response)
+
}
+
+
func foldingRange(id: JSONId, params: FoldingRangeParams) async -> Response<FoldingRangeResponse> {
+
// TODO: restore
+
// guard let document = await bufferManager.documentForBuffer(key: params.textDocument.uri) else { return .success(nil) }
+
let rangeSink: [FoldingRange] = []
+
// TODO: restore
+
// document.tree.collectFoldingRanges(&rangeSink)
+
let response = FoldingRangeResponse(rangeSink)
+
Logger.shared.info("Ranges: \(rangeSink)")
+
return .success(response)
+
}
+
+
+
func typeHierarchySupertypes(
+
id: JSONRPC.JSONId,
+
params: TypeHierarchySupertypesParams
+
) async -> Response<TypeHierarchySupertypesResponse> {
+
.success(nil)
+
}
+
+
func typeHierarchySubtypes(
+
id: JSONId,
+
params: TypeHierarchySubtypesParams
+
) async -> Response<TypeHierarchySubtypesResponse> {
+
.success(nil)
+
}
+
+
func internalError(_ error: any Error) async {
+
Logger.shared.error("Received error: \(error)")
+
}
+
+
func initialized(_ params: InitializedParams) async {
+
+
}
+
+
func exit() async {
+
Logger.shared.info("Received exit notification")
+
Foundation.exit(0)
+
}
+
+
func diagnostics(id: JSONId, params: DocumentDiagnosticParams) async -> Response<DocumentDiagnosticReport> {
+
return .success(.init(kind: .unchanged))
+
}
+
+
+
+
func textDocumentDidClose(_ params: DidCloseTextDocumentParams) async {
+
+
}
+
+
func textDocumentWillSave(_ params: WillSaveTextDocumentParams) async {
+
+
}
+
+
func textDocumentDidSave(_ params: DidSaveTextDocumentParams) async {
+
+
}
+
+
func protocolCancelRequest(_ params: CancelParams) async {
+
+
}
+
+
func protocolSetTrace(_ params: SetTraceParams) async {
+
+
}
+
+
func workspaceDidChangeWatchedFiles(_ params: DidChangeWatchedFilesParams) async {
+
+
}
+
+
func windowWorkDoneProgressCancel(_ params: WorkDoneProgressCancelParams) async {
+
+
}
+
+
func workspaceDidChangeWorkspaceFolders(_ params: DidChangeWorkspaceFoldersParams) async {
+
+
}
+
+
func workspaceDidChangeConfiguration(_ params: DidChangeConfigurationParams) async {
+
+
}
+
+
func workspaceDidCreateFiles(_ params: CreateFilesParams) async {
+
+
}
+
+
func workspaceDidRenameFiles(_ params: RenameFilesParams) async {
+
+
}
+
+
func workspaceDidDeleteFiles(_ params: DeleteFilesParams) async {
+
+
}
+
+
func shutdown(id: JSONId) async {}
+
func workspaceInlayHintRefresh(id: JSONId) async {}
+
func workspaceExecuteCommand(id: JSONId, params: ExecuteCommandParams) async -> Response<LSPAny?> { .success(nil) }
+
func workspaceWillCreateFiles(id: JSONId, params: CreateFilesParams) async -> Response<WorkspaceEdit?> { .success(nil) }
+
func workspaceWillRenameFiles(id: JSONId, params: RenameFilesParams) async -> Response<WorkspaceEdit?> { .success(nil) }
+
func workspaceWillDeleteFiles(id: JSONId, params: DeleteFilesParams) async -> Response<WorkspaceEdit?> { .success(nil) }
+
func workspaceSymbol(id: JSONId, params: WorkspaceSymbolParams) async -> Response<WorkspaceSymbolResponse> { .success(nil) }
+
// func workspaceSymbolResolve(id: JSONId, params: WorkspaceSymbol) async -> Response<WorkspaceSymbol> { .success(nil) }
+
func textDocumentWillSaveWaitUntil(id: JSONId, params: WillSaveTextDocumentParams) async -> Response<[TextEdit]?> { .success(nil) }
+
func completion(id: JSONId, params: CompletionParams) async -> Response<CompletionResponse> {
+
.success(CompletionResponse(.optionB(.init(isIncomplete: false, items: []))))
+
}
+
// func completionItemResolve(id: JSONId, params: CompletionItem) async -> Response<CompletionItem> { .success(nil) }
+
func hover(id: JSONId, params: TextDocumentPositionParams) async -> Response<HoverResponse> { .success(nil) }
+
func signatureHelp(id: JSONId, params: TextDocumentPositionParams) async -> Response<SignatureHelpResponse> { .success(nil) }
+
func declaration(id: JSONId, params: TextDocumentPositionParams) async -> Response<DeclarationResponse> { .success(nil) }
+
func definition(id: JSONId, params: TextDocumentPositionParams) async -> Response<DefinitionResponse> { .success(nil) }
+
func typeDefinition(id: JSONId, params: TextDocumentPositionParams) async -> Response<TypeDefinitionResponse> { .success(nil) }
+
func implementation(id: JSONId, params: TextDocumentPositionParams) async -> Response<ImplementationResponse> { .success(nil) }
+
func documentHighlight(id: JSONId, params: DocumentHighlightParams) async -> Response<DocumentHighlightResponse> { .success(nil) }
+
func documentSymbol(id: JSONId, params: DocumentSymbolParams) async -> Response<DocumentSymbolResponse> { .success(nil) }
+
func codeAction(id: JSONId, params: CodeActionParams) async -> Response<CodeActionResponse> { .success(nil) }
+
// func codeActionResolve(id: JSONId, params: CodeAction) async -> Response<CodeAction> { .success(nil) }
+
func codeLens(id: JSONId, params: CodeLensParams) async -> Response<CodeLensResponse> { .success(nil) }
+
// func codeLensResolve(id: JSONId, params: CodeLens) async -> Response<CodeLens> { .success(nil) }
+
func selectionRange(id: JSONId, params: SelectionRangeParams) async -> Response<SelectionRangeResponse> { .success(nil) }
+
func linkedEditingRange(id: JSONId, params: LinkedEditingRangeParams) async -> Response<LinkedEditingRangeResponse> { .success(nil) }
+
func prepareCallHierarchy(id: JSONId, params: CallHierarchyPrepareParams) async -> Response<CallHierarchyPrepareResponse> { .success(nil) }
+
func prepareRename(id: JSONId, params: PrepareRenameParams) async -> Response<PrepareRenameResponse> { .success(nil) }
+
func prepareTypeHeirarchy(id: JSONId, params: TypeHierarchyPrepareParams) async -> Response<PrepareTypeHeirarchyResponse> { .success(nil) }
+
func rename(id: JSONId, params: RenameParams) async -> Response<RenameResponse> { .success(nil) }
+
func inlayHint(id: JSONId, params: InlayHintParams) async -> Response<InlayHintResponse> { .success(nil) }
+
func inlayHintResolve(id: JSONId, params: InlayHint) async -> Response<InlayHintResponse> { .success(nil) }
+
func documentLink(id: JSONId, params: DocumentLinkParams) async -> Response<DocumentLinkResponse> { .success(nil) }
+
// func documentLinkResolve(id: JSONId, params: DocumentLink) async -> Response<DocumentLink> { .success(nil) }
+
// func documentColor(id: JSONId, params: DocumentColorParams) async -> Response<DocumentColorResponse> { .success(nil) }
+
// func colorPresentation(id: JSONId, params: ColorPresentationParams) async -> Response<ColorPresentationResponse> { .success(nil) }
+
func formatting(id: JSONId, params: DocumentFormattingParams) async -> Response<FormattingResult> { .success(nil) }
+
func rangeFormatting(id: JSONId, params: DocumentRangeFormattingParams) async -> Response<FormattingResult> { .success(nil) }
+
func onTypeFormatting(id: JSONId, params: DocumentOnTypeFormattingParams) async -> Response<FormattingResult> { .success(nil) }
+
func references(id: JSONId, params: ReferenceParams) async -> Response<ReferenceResponse> { .success(nil) }
+
func moniker(id: JSONId, params: MonikerParams) async -> Response<MonikerResponse> { .success(nil) }
+
func semanticTokensFullDelta(id: JSONId, params: SemanticTokensDeltaParams) async -> Response<SemanticTokensDeltaResponse> { .success(nil) }
+
func semanticTokensRange(id: JSONId, params: SemanticTokensRangeParams) async -> Response<SemanticTokensResponse> { .success(nil) }
+
func callHierarchyIncomingCalls(id: JSONId, params: CallHierarchyIncomingCallsParams) async -> Response<CallHierarchyIncomingCallsResponse> { .success(nil) }
+
func callHierarchyOutgoingCalls(id: JSONId, params: CallHierarchyOutgoingCallsParams) async -> Response<CallHierarchyOutgoingCallsResponse> { .success(nil) }
+
func custom(id: JSONId, method: String, params: LSPAny) async -> Response<LSPAny> { .success(nil) }
+
}
+13
Sources/PterodactylLanguageServer/LLBDeclFileTree+Singleton.swift
···
+
import Foundation
+
import TSCBasic
+
import llbuild2fx
+
+
extension LLBDeclFileTree {
+
public static func file(absolutePath: AbsolutePath, contents: String) -> Self {
+
let components = absolutePath.components
+
let leaf = LLBDeclFileTree.file(contents)
+
return components.dropFirst().reversed().reduce(leaf) { tail, name in
+
.directory(files: [name: tail])
+
}
+
}
+
}
+59
Sources/PterodactylLanguageServer/Logger.swift
···
+
import Foundation
+
import Logging
+
+
struct FileLogHandler: LogHandler {
+
private let label: String
+
private let fileHandle: FileHandle
+
var logLevel: Logger.Level = .info
+
var metadata: Logger.Metadata = [:]
+
+
init(label: String, fileURL: URL) {
+
self.label = label
+
+
// Ensure file exists
+
if !FileManager.default.fileExists(atPath: fileURL.path) {
+
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
+
}
+
+
// Open file for updating (read/write)
+
self.fileHandle = try! FileHandle(forUpdating: fileURL)
+
self.fileHandle.seekToEndOfFile()
+
}
+
+
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
+
get { metadata[key] }
+
set { metadata[key] = newValue }
+
}
+
+
func log(
+
level: Logger.Level,
+
message: Logger.Message,
+
metadata: Logger.Metadata?,
+
source: String,
+
file: String,
+
function: String,
+
line: UInt
+
) {
+
var fullMetadata = self.metadata
+
metadata?.forEach { fullMetadata[$0] = $1 }
+
+
let line = "[\(level)] \(message)\n"
+
if let data = line.data(using: .utf8) {
+
fileHandle.write(data)
+
}
+
}
+
}
+
+
extension Logger {
+
static let shared: Self = {
+
let logFile = URL(fileURLWithPath: "/tmp/pterodactyl-language-server.log")
+
+
LoggingSystem.bootstrap { label in
+
FileLogHandler(label: label, fileURL: logFile)
+
}
+
+
var logger = Logger(label: "org.pterodactyl.language-server")
+
logger.logLevel = .debug
+
return logger
+
}()
+
}
+35
Sources/PterodactylLanguageServer/SourceTreeManager.swift
···
+
import LanguageServerProtocol
+
import PterodactylBuild
+
import TSCBasic
+
import llbuild2fx
+
+
actor SourceTreeManager {
+
private let buildEngine: FXEngine
+
private let casClient: LLBCASFSClient
+
private let casContext: TSCUtility.Context
+
private(set) var sourceTree: LLBCASFileTree
+
+
init(buildEngine: FXEngine, casClient: LLBCASFSClient, casContext: TSCUtility.Context, sourceTree: LLBCASFileTree? = nil) async throws {
+
self.buildEngine = buildEngine
+
self.casClient = casClient
+
self.casContext = casContext
+
if let sourceTree {
+
self.sourceTree = sourceTree
+
} else {
+
self.sourceTree = try await casClient.storeDir(.dir([:]), casContext).get()
+
}
+
}
+
+
func setBufferText(uri: DocumentUri, text: String) async throws {
+
let path = try AbsolutePath.fromDocumentUri(uri)
+
let singletonDeclTree = LLBDeclFileTree.file(absolutePath: path, contents: text)
+
let singletonTree: LLBCASFileTree = try await casClient.storeDir(singletonDeclTree, casContext).get()
+
self.sourceTree = try await sourceTree.merge(with: singletonTree, in: casClient.db, casContext).get()
+
}
+
+
func getBufferText(uri: DocumentUri) async throws -> String? {
+
let path = try AbsolutePath.fromDocumentUri(uri)
+
guard let (id, _) = try await sourceTree.lookup(path: path, in: casClient.db, casContext).get() else { return nil }
+
return try await buildEngine.build(key: Keys.Blob.ReadContents(blobId: id), casContext).get()
+
}
+
}
+4 -6
Sources/PterodactylSyntax/Diagnostic.swift
···
//
// SPDX-License-Identifier: MPL-2.0
-
public struct Diagnostic: Equatable, Codable, Sendable {
-
enum Severity: Equatable, Codable {
-
case error
-
case warning
-
case note
-
}
+
import LanguageServerProtocol
+
public struct Diagnostic: Equatable, Codable, Sendable {
+
typealias Severity = LanguageServerProtocol.DiagnosticSeverity
+
let message: String
let severity: Severity
let absoluteUtf16Range: Range<Int>
+49
Tests/PterodactylLanguageServerTests/Test.swift
···
+
//
+
// File.swift
+
// Pterodactyl
+
//
+
// Created by Jon Sterling on 30/11/2025.
+
//
+
+
import Foundation
+
import Testing
+
+
@testable import PterodactylBuild
+
@testable import PterodactylLanguageServer
+
@testable import TSCBasic
+
@testable import llbuild2fx
+
+
struct LanguageServerTests {
+
@Test
+
func testSingletonFileTree() throws {
+
let path = try AbsolutePath(validating: "/foo/bar/file.txt")
+
let foo = LLBDeclFileTree.file(absolutePath: path, contents: "foobar")
+
let expected: LLBDeclFileTree =
+
.dir([
+
"foo": .dir([
+
"bar": .dir([
+
"file.txt": .file("foobar")
+
])
+
])
+
])
+
+
#expect(foo.debugDescription == expected.debugDescription)
+
}
+
+
@Test
+
func testSourceTreeManager() async throws {
+
let group = LLBMakeDefaultDispatchGroup()
+
let db = LLBInMemoryCASDatabase(group: group)
+
let functionCache = FXInMemoryFunctionCache(group: group)
+
let executor = FXLocalExecutor()
+
let engine = FXEngine(group: group, db: db, functionCache: functionCache, executor: executor)
+
let client = LLBCASFSClient(db)
+
let context = Context()
+
+
let manager = try await SourceTreeManager(buildEngine: engine, casClient: client, casContext: context)
+
let uri = "file://foo/bar/wooo.txt"
+
let contents = "hello world"
+
try await manager.setBufferText(uri: uri, text: contents)
+
try await #expect(manager.getBufferText(uri: uri) == contents)
+
}
+
}