diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/Extension.affine b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/Extension.affine index a4c63c0..61928cd 100644 --- a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/Extension.affine +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/Extension.affine @@ -1,7 +1,150 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Extension; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// hypatia:ignore cicd_rules/banned_language_file -- VSCode extension panel; AffineScript targets wasm and has no vscode/LSP-client bindings to host a JS editor extension (cf. panels/package.json documented npm exception). Unblock: AffineScript VSCode/LSP-client bindings. +// +// Extension.res — BoJ Orchestrator LSP — VSCode extension entry point. +// +// Registers the cross-domain GenLSP orchestrator as a single language server +// that editors and AI agents connect to once, receiving cross-domain +// intelligence from all 12 poly-*-lsp servers. +// +// Architecture: +// VSCode / AI agent (LanguageClient) ←→ this extension ←→ Elixir adapter +// ↕ routes by domain +// 12 poly-*-lsp servers +// +// Configuration keys (boj.orchestratorLsp.*): +// adapterDir — path to the adapter/ Mix project root +// command — executable to start the adapter (default: "mix") +// args — argument array (default: ["run", "--no-halt"]) +// trace.server — LSP trace level: "off" | "messages" | "verbose" + +// The active LanguageClient, held for graceful shutdown on deactivation. +fn client: ref> = ref(None) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +// Wrap a JS array push to keep ReScript bindings local. +@val @scope("Array.protostruct") +external arrayPush: (array<'a>, 'a) => int = "push" + +// Retrieve the adapter directory from VSCode config or the BOJ env var. +fn resolveAdapterDir = (): string => { + fn cfg = VscodeApi.Workspace2.getConfiguration("boj.orchestratorLsp") + switch VscodeApi.Configuration.get(cfg, "adapterDir") { + | Some(dir) if dir !== "" => dir + | _ => + fn envDir: Js.nullable = %raw(`process.env.BOJ_ORCHESTRATOR_LSP_DIR ?? null`) + switch Js.Nullable.toOption(envDir) { + | Some(d) => d + | None => "cartridges/orchestrator-lsp-mcp/adapter" + } + } +} + +// Workspace root: first open folder, or "." as fallback. +fn resolveWorkspaceRoot = (): string => { + switch VscodeApi.Workspace.workspaceFolders { + | Some(folders) if Array.length(folders) > 0 => + fn uri = folders->Array.getUnsafe(0)->VscodeApi.WorkspaceFolder.uri + VscodeApi.Uri.fsPath(uri) + | _ => "." + } +} + +// Build the ServerOptions record for vscode-languageclient. +fn buildServerOptions = (adapterDir: string): LanguageClient.serverOptions => { + fn cfg = VscodeApi.Workspace2.getConfiguration("boj.orchestratorLsp") + + fn cmd: string = switch VscodeApi.Configuration.get(cfg, "command") { + | Some(c) => c + | None => "mix" + } + + fn args: array = switch VscodeApi.Configuration.get(cfg, "args") { + | Some(a) => a + | None => ["run", "--no-halt"] + } + + fn opts = LanguageClient.executableOptions(~cwd=adapterDir, ()) + fn exe = LanguageClient.executable(~command=cmd, ~args, ~options=opts, ()) + LanguageClient.serverOptions(~run=exe, ~debug=exe) +} + +// Build the ClientOptions for vscode-languageclient. +// The orchestrator accepts all document structs — routing happens inside the adapter. +fn buildClientOptions = (channel: VscodeApi.OutputChannel.t): LanguageClient.clientOptions => { + fn selector = [ + LanguageClient.documentSelectorItem(~scheme="file", ()), + LanguageClient.documentSelectorItem(~scheme="untitled", ()), + ] + LanguageClient.clientOptions(~documentSelector=selector, ~outputChannel=channel, ()) +} + +// Wrap an OutputChannel as a disposable for VSCode subscriptions. +fn channelAsDisposable = (ch: VscodeApi.OutputChannel.t): VscodeApi.disposable => { + {dispose: () => VscodeApi.OutputChannel.dispose(ch)} +} + +// ── Extension lifecycle ─────────────────────────────────────────────────────── + +// Called by VSCode when the extension activates. +// Exported as a top-level binding — ReScript CommonJS output exposes it as +// `module.exports.activate`, which is what VSCode requires. +fn activate = (context: VscodeApi.extensionContext): unit => { + fn channel = VscodeApi.Window.createOutputChannel("BoJ Orchestrator LSP") + fn subscriptions = VscodeApi.ExtensionContext.subscriptions(context) + + fn log = msg => VscodeApi.OutputChannel.appendLine(channel, "[BoJ Orchestrator LSP] " ++ msg) + + fn adapterDir = resolveAdapterDir() + fn wsRoot = resolveWorkspaceRoot() + log("adapter dir: " ++ adapterDir) + log("workspace root: " ++ wsRoot) + + fn serverOpts = buildServerOptions(adapterDir) + fn clientOpts = buildClientOptions(channel) + + fn lspClient = LanguageClient.make( + "boj-orchestrator-lsp", + "BoJ Orchestrator LSP", + serverOpts, + clientOpts, + ) + client := Some(lspClient) + + fn _ = + LanguageClient.start(lspClient) + ->Js.Promise.then_(_ => { + log("adapter ready — connected to 12 domain LSP servers") + Js.Promise.resolve() + }, _) + ->Js.Promise.catch(err => { + fn detail = Js.String.make(err) + log("adapter failed to start: " ++ detail) + VscodeApi.Window.showErrorMessage("BoJ Orchestrator LSP failed to start: " ++ detail) + Js.Promise.resolve() + }, _) + + // Register for automatic cleanup when the extension deactivates. + fn _ = arrayPush(subscriptions, LanguageClient.asDisposable(lspClient)) + fn _ = arrayPush(subscriptions, channelAsDisposable(channel)) + () +} + +// Called by VSCode before the extension host is torn down. +// Returns a promise so VSCode waits for graceful shutdown. +fn deactivate = (): Js.Promise.t => { + switch client.contents { + | Some(c) => + client := None + LanguageClient.stop(c) + | None => Js.Promise.resolve() + } +} + diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/LanguageClient.affine b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/LanguageClient.affine index 9d5aa3a..9bc7e11 100644 --- a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/LanguageClient.affine +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/LanguageClient.affine @@ -1,7 +1,82 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module LanguageClient; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// hypatia:ignore cicd_rules/banned_language_file -- VSCode extension panel; AffineScript targets wasm and has no vscode/LSP-client bindings to host a JS editor extension (cf. panels/package.json documented npm exception). Unblock: AffineScript VSCode/LSP-client bindings. +// +// LanguageClient.res — ReScript bindings for vscode-languageclient/node. +// +// Binds just the subset needed to start, stop, and dispose an LSP client +// from within the VSCode extension host. + +// Opaque LanguageClient instance. +struct t + +// ── ServerOptions ──────────────────────────────────────────────────────────── + +// Environment variable overrides for the server process. +struct processEnv { Js.Dict.t + +// Options for the spawned server process. +@deriving(abstract) +struct executableOptions { { + @optional cwd: string, + @optional env: processEnv, +} + +// A runnable executable (command + args + optional process options). +@deriving(abstract) +struct executable { { + command: string, + @optional args: array, + @optional options: executableOptions, +} + +// Run/debug variants — vscode-languageclient picks `run` in production. +@deriving(abstract) +struct serverOptions { { + run: executable, + debug: executable, +} + +// ── ClientOptions ───────────────────────────────────────────────────────────── + +// A document selector entry — any combination of scheme, language, pattern. +@deriving(abstract) +struct documentSelectorItem { { + @optional scheme: string, + @optional language: string, + @optional pattern: string, +} + +// Options for the LanguageClient. +@deriving(abstract) +struct clientOptions { { + documentSelector: array, + @optional outputChannel: VscodeApi.OutputChannel.t, +} + +// ── LanguageClient constructor ──────────────────────────────────────────────── + +// `new LanguageClient(id, name, serverOptions, clientOptions)` +@module("vscode-languageclient/node") @new +external make: (string, string, serverOptions, clientOptions) => t = "LanguageClient" + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +// Start the language client and server. Returns a promise that resolves when +// the server has initialised. +@send +external start: t => promise = "start" + +// Gracefully stop the language client and server. +@send +external stop: t => promise = "stop" + +// Cast the client as a disposable for subscription registration. +// LanguageClient implements the disposable protocol — this is a safe identity cast. +external asDisposable: t => VscodeApi.disposable = "%identity" + diff --git a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/VscodeApi.affine b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/VscodeApi.affine index c78d15c..db3fac2 100644 --- a/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/VscodeApi.affine +++ b/cartridges/domains/languages/orchestrator-lsp-mcp/panels/src/VscodeApi.affine @@ -1,7 +1,80 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module VscodeApi; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// hypatia:ignore cicd_rules/banned_language_file -- VSCode extension panel; AffineScript targets wasm and has no vscode/LSP-client bindings to host a JS editor extension (cf. panels/package.json documented npm exception). Unblock: AffineScript VSCode/LSP-client bindings. +// +// VscodeApi.res — Minimal ReScript external bindings for the VSCode extension API. +// +// Only the surface used by the orchestrator-lsp-mcp extension is bound here. +// Extend as needed rather than importing a full binding library. + +// A VSCode disposable — anything with a `dispose()` method can be pushed into +// ExtensionContext.subscriptions so VSCode cleans it up on extension deactivation. +struct disposable { {dispose: unit => unit} + +// Opaque handle passed to `activate`. Never constructed by extension code. +struct extensionContext + +module ExtensionContext = { + // Array of disposables to clean up when the extension deactivates. + @get + external subscriptions: extensionContext => array = "subscriptions" +} + +module OutputChannel = { + struct t + + @send + external appendLine: (t, string) => unit = "appendLine" + + @send + external show: t => unit = "show" + + @send + external dispose: t => unit = "dispose" +} + +module Uri = { + struct t + @get external fsPath: t => string = "fsPath" +} + +module WorkspaceFolder = { + struct t + @get external uri: t => Uri.t = "uri" + @get external name: t => string = "name" +} + +module Window = { + @module("vscode") @scope("window") + external createOutputChannel: string => OutputChannel.t = "createOutputChannel" + + @module("vscode") @scope("window") + external showErrorMessage: string => unit = "showErrorMessage" + + @module("vscode") @scope("window") + external showInformationMessage: string => unit = "showInformationMessage" +} + +module Workspace = { + // May be undefined if no folder is open — binds as nullable array. + @module("vscode") @scope("workspace") @return(nullable) + external workspaceFolders: option> = "workspaceFolders" +} + +module Configuration = { + struct t + + @send @return(nullable) + external get: (t, string) => option<'a> = "get" +} + +module Workspace2 = { + @module("vscode") @scope("workspace") + external getConfiguration: string => Configuration.t = "getConfiguration" +} +