diff --git a/src/cm/lsp/api.ts b/src/cm/lsp/api.ts new file mode 100644 index 000000000..fe3b8aad4 --- /dev/null +++ b/src/cm/lsp/api.ts @@ -0,0 +1,96 @@ +import { defineBundle, defineServer, installers } from "./providerUtils"; +import { + getServerBundle, + listServerBundles, + registerServerBundle, + unregisterServerBundle, +} from "./serverCatalog"; +import { + getServer, + getServersForLanguage, + listServers, + onRegistryChange, + type RegisterServerOptions, + registerServer, + type ServerUpdater, + unregisterServer, + updateServer, +} from "./serverRegistry"; +import type { + LspServerBundle, + LspServerDefinition, + LspServerManifest, +} from "./types"; + +export { defineBundle, defineServer, installers }; + +export type LspRegistrationEntry = LspServerManifest | LspServerBundle; + +function isBundleEntry(entry: LspRegistrationEntry): entry is LspServerBundle { + return typeof (entry as LspServerBundle)?.getServers === "function"; +} + +export function register( + entry: LspRegistrationEntry, + options?: RegisterServerOptions & { replace?: boolean }, +): LspServerDefinition | LspServerBundle { + if (isBundleEntry(entry)) { + return registerServerBundle(entry, options); + } + + return registerServer(entry, options); +} + +export function upsert( + entry: LspRegistrationEntry, +): LspServerDefinition | LspServerBundle { + return register(entry, { replace: true }); +} + +export const servers = { + get(id: string): LspServerDefinition | null { + return getServer(id); + }, + list(): LspServerDefinition[] { + return listServers(); + }, + listForLanguage( + languageId: string, + options?: { includeDisabled?: boolean }, + ): LspServerDefinition[] { + return getServersForLanguage(languageId, options); + }, + update(id: string, updater: ServerUpdater): LspServerDefinition | null { + return updateServer(id, updater); + }, + unregister(id: string): boolean { + return unregisterServer(id); + }, + onChange(listener: Parameters[0]): () => void { + return onRegistryChange(listener); + }, +}; + +export const bundles = { + list(): LspServerBundle[] { + return listServerBundles(); + }, + getForServer(id: string): LspServerBundle | null { + return getServerBundle(id); + }, + unregister(id: string): boolean { + return unregisterServerBundle(id); + }, +}; + +const lspApi = { + defineServer, + defineBundle, + register, + upsert, + installers, + servers, + bundles, +}; + +export default lspApi; diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index 8d48aa770..232905c33 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -1,3 +1,13 @@ +export { + bundles, + default as lspApi, + defineBundle, + defineServer, + installers, + register, + servers, + upsert, +} from "./api"; export { default as clientManager, LspClientManager } from "./clientManager"; export type { CodeActionItem } from "./codeActions"; export { diff --git a/src/cm/lsp/installRuntime.ts b/src/cm/lsp/installRuntime.ts new file mode 100644 index 000000000..bf639394b --- /dev/null +++ b/src/cm/lsp/installRuntime.ts @@ -0,0 +1,46 @@ +function getExecutor(): Executor { + const executor = (globalThis as unknown as { Executor?: Executor }).Executor; + if (!executor) { + throw new Error("Executor plugin is not available"); + } + return executor; +} + +function getBackgroundExecutor(): Executor { + const executor = getExecutor(); + return executor.BackgroundExecutor ?? executor; +} + +export function quoteArg(value: unknown): string { + const str = String(value ?? ""); + if (!str.length) return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(str)) return str; + return `'${str.replace(/'/g, "'\\''")}'`; +} + +export function formatCommand( + command: string | string[] | null | undefined, +): string { + if (Array.isArray(command)) { + return command.map((part) => quoteArg(part)).join(" "); + } + if (typeof command === "string") { + return command.trim(); + } + return ""; +} + +function wrapShellCommand(command: string): string { + const script = command.trim(); + return `sh -lc ${quoteArg(`set -e\n${script}`)}`; +} + +export async function runQuickCommand(command: string): Promise { + const wrapped = wrapShellCommand(command); + return getBackgroundExecutor().execute(wrapped, true); +} + +export async function runForegroundCommand(command: string): Promise { + const wrapped = wrapShellCommand(command); + return getExecutor().execute(wrapped, true); +} diff --git a/src/cm/lsp/installerUtils.ts b/src/cm/lsp/installerUtils.ts new file mode 100644 index 000000000..0d2b715f8 --- /dev/null +++ b/src/cm/lsp/installerUtils.ts @@ -0,0 +1,59 @@ +const ARCH_ALIASES = { + aarch64: ["aarch64", "arm64", "arm64-v8a"], + x86_64: ["x86_64", "amd64"], + armv7: ["armv7", "armv7l", "armeabi-v7a"], +} as const; + +export type NormalizedArch = keyof typeof ARCH_ALIASES; + +export function normalizeArchitecture(arch: string | null | undefined): string { + const normalized = String(arch || "") + .trim() + .toLowerCase(); + + for (const [canonical, aliases] of Object.entries(ARCH_ALIASES)) { + if (aliases.includes(normalized as never)) { + return canonical; + } + } + + return normalized; +} + +export function getArchitectureMatchers( + assets: Record | undefined | null, +): Array<{ canonicalArch: string; aliases: string[]; asset: string }> { + if (!assets || typeof assets !== "object") return []; + + const resolved = new Map(); + for (const [rawArch, rawAsset] of Object.entries(assets)) { + const asset = String(rawAsset || "").trim(); + if (!asset) continue; + + const canonicalArch = normalizeArchitecture(rawArch); + if (!canonicalArch) continue; + + const aliases = ( + ARCH_ALIASES[canonicalArch as NormalizedArch] || [canonicalArch] + ).map((value) => String(value)); + resolved.set(canonicalArch, { aliases, asset }); + } + + return Array.from(resolved.entries()).map(([canonicalArch, value]) => ({ + canonicalArch, + aliases: value.aliases, + asset: value.asset, + })); +} + +export function buildShellArchCase( + assets: Record | undefined | null, + quote: (value: unknown) => string, +): string { + return getArchitectureMatchers(assets) + .map( + ({ aliases, asset }) => + `\t${aliases.join("|")}) ASSET=${quote(asset)} ;;`, + ) + .join("\n"); +} diff --git a/src/cm/lsp/providerUtils.ts b/src/cm/lsp/providerUtils.ts new file mode 100644 index 000000000..511b33ddc --- /dev/null +++ b/src/cm/lsp/providerUtils.ts @@ -0,0 +1,240 @@ +import type { + BridgeConfig, + InstallCheckResult, + LauncherInstallConfig, + LspServerBundle, + LspServerManifest, + TransportDescriptor, +} from "./types"; + +export interface ManagedServerOptions { + id: string; + label: string; + languages: string[]; + enabled?: boolean; + useWorkspaceFolders?: boolean; + command?: string; + args?: string[]; + transport?: Partial; + bridge?: Partial | null; + installer?: LauncherInstallConfig; + checkCommand?: string; + versionCommand?: string; + updateCommand?: string; + uninstallCommand?: string; + startupTimeout?: number; + initializationOptions?: Record; + clientConfig?: LspServerManifest["clientConfig"]; + resolveLanguageId?: LspServerManifest["resolveLanguageId"]; + rootUri?: LspServerManifest["rootUri"]; + capabilityOverrides?: Record; +} + +export interface BundleHooks { + getExecutable?: ( + serverId: string, + manifest: LspServerManifest, + ) => string | null | undefined; + checkInstallation?: ( + serverId: string, + manifest: LspServerManifest, + ) => Promise; + installServer?: ( + serverId: string, + manifest: LspServerManifest, + mode: "install" | "update" | "reinstall", + options?: { promptConfirm?: boolean }, + ) => Promise; +} + +export function defineBundle(options: { + id: string; + label?: string; + servers: LspServerManifest[]; + hooks?: BundleHooks; +}): LspServerBundle { + const { id, label, servers, hooks } = options; + return { + id, + label, + getServers: () => servers, + ...hooks, + }; +} + +export function defineServer(options: ManagedServerOptions): LspServerManifest { + const { + id, + label, + languages, + enabled = true, + useWorkspaceFolders = false, + command, + args, + transport, + bridge, + installer, + checkCommand, + versionCommand, + updateCommand, + uninstallCommand, + startupTimeout, + initializationOptions, + clientConfig, + resolveLanguageId, + rootUri, + capabilityOverrides, + } = options; + + const bridgeCommand = command || bridge?.command; + return { + id, + label, + languages, + enabled, + useWorkspaceFolders, + transport: { + kind: "websocket", + ...(transport || {}), + } as TransportDescriptor, + launcher: { + checkCommand, + versionCommand, + updateCommand, + uninstallCommand, + install: installer, + bridge: bridgeCommand + ? { + kind: "axs", + command: bridgeCommand, + args: args || bridge?.args, + port: bridge?.port, + session: bridge?.session, + } + : undefined, + }, + startupTimeout, + initializationOptions, + clientConfig, + resolveLanguageId, + rootUri, + capabilityOverrides, + }; +} + +export const installers = { + apk(options: { + packages: string[]; + executable: string; + label?: string; + source?: string; + }): LauncherInstallConfig { + return { + kind: "apk", + source: options.source || "apk", + label: options.label, + executable: options.executable, + packages: options.packages, + }; + }, + npm(options: { + packages: string[]; + executable: string; + label?: string; + source?: string; + global?: boolean; + }): LauncherInstallConfig { + return { + kind: "npm", + source: options.source || "npm", + label: options.label, + executable: options.executable, + packages: options.packages, + global: options.global, + }; + }, + pip(options: { + packages: string[]; + executable: string; + label?: string; + source?: string; + breakSystemPackages?: boolean; + }): LauncherInstallConfig { + return { + kind: "pip", + source: options.source || "pip", + label: options.label, + executable: options.executable, + packages: options.packages, + breakSystemPackages: options.breakSystemPackages, + }; + }, + cargo(options: { + packages: string[]; + executable: string; + label?: string; + source?: string; + }): LauncherInstallConfig { + return { + kind: "cargo", + source: options.source || "cargo", + label: options.label, + executable: options.executable, + packages: options.packages, + }; + }, + manual(options: { + binaryPath: string; + executable?: string; + label?: string; + source?: string; + }): LauncherInstallConfig { + return { + kind: "manual", + source: options.source || "manual", + label: options.label, + executable: options.executable || options.binaryPath, + binaryPath: options.binaryPath, + }; + }, + shell(options: { + command: string; + executable: string; + updateCommand?: string; + uninstallCommand?: string; + label?: string; + source?: string; + }): LauncherInstallConfig { + return { + kind: "shell", + source: options.source || "custom", + label: options.label, + executable: options.executable, + command: options.command, + updateCommand: options.updateCommand, + uninstallCommand: options.uninstallCommand, + }; + }, + githubRelease(options: { + repo: string; + binaryPath: string; + executable?: string; + assetNames: Record; + extractFile?: string; + archiveType?: "zip" | "binary"; + label?: string; + source?: string; + }): LauncherInstallConfig { + return { + kind: "github-release", + source: options.source || "github-release", + label: options.label, + executable: options.executable || options.binaryPath, + repo: options.repo, + assetNames: options.assetNames, + extractFile: options.extractFile, + archiveType: options.archiveType, + binaryPath: options.binaryPath, + }; + }, +}; diff --git a/src/cm/lsp/serverCatalog.ts b/src/cm/lsp/serverCatalog.ts new file mode 100644 index 000000000..d1b1aefab --- /dev/null +++ b/src/cm/lsp/serverCatalog.ts @@ -0,0 +1,127 @@ +import { builtinServerBundles } from "./servers"; +import type { LspServerBundle, LspServerManifest } from "./types"; + +function toKey(id: string | undefined | null): string { + return String(id ?? "") + .trim() + .toLowerCase(); +} + +interface RegistryAdapter { + registerServer: ( + definition: LspServerManifest, + options?: { replace?: boolean }, + ) => unknown; + unregisterServer: (id: string) => boolean; +} + +const bundles = new Map(); +const bundleServers = new Map>(); +const serverOwners = new Map(); + +let registryAdapter: RegistryAdapter | null = null; +let builtinsRegistered = false; + +export function bindServerRegistry(adapter: RegistryAdapter): void { + registryAdapter = adapter; +} + +function requireRegistry(): RegistryAdapter { + if (!registryAdapter) { + throw new Error("LSP server catalog is not bound to the registry"); + } + return registryAdapter; +} + +function resolveBundleServers(bundle: LspServerBundle): LspServerManifest[] { + const servers = bundle.getServers(); + return Array.isArray(servers) ? servers : []; +} + +export function registerServerBundle( + bundle: LspServerBundle, + options: { replace?: boolean } = {}, +): LspServerBundle { + const { replace = false } = options; + const key = toKey(bundle.id); + if (!key) { + throw new Error("LSP server bundle requires a non-empty id"); + } + + if (bundles.has(key) && !replace) { + const existing = bundles.get(key); + if (existing) return existing; + } + + const registry = requireRegistry(); + const definitions = resolveBundleServers(bundle); + const previousIds = bundleServers.get(key) || new Set(); + const nextIds = new Set(); + + for (const definition of definitions) { + const serverId = toKey(definition.id); + if (!serverId) { + throw new Error(`LSP server bundle ${key} returned a server without id`); + } + + const owner = serverOwners.get(serverId); + if (owner && owner !== key && !replace) { + throw new Error( + `LSP server ${serverId} is already provided by ${owner}; ${key} must replace explicitly`, + ); + } + + registry.registerServer(definition, { replace: true }); + serverOwners.set(serverId, key); + nextIds.add(serverId); + } + + for (const previousId of previousIds) { + if (!nextIds.has(previousId) && serverOwners.get(previousId) === key) { + registry.unregisterServer(previousId); + serverOwners.delete(previousId); + } + } + + const normalizedBundle = { + ...bundle, + id: key, + }; + bundles.set(key, normalizedBundle); + bundleServers.set(key, nextIds); + return normalizedBundle; +} + +export function unregisterServerBundle(id: string): boolean { + const key = toKey(id); + if (!key || !bundles.has(key)) return false; + + const registry = requireRegistry(); + for (const serverId of bundleServers.get(key) || []) { + if (serverOwners.get(serverId) === key) { + registry.unregisterServer(serverId); + serverOwners.delete(serverId); + } + } + + bundleServers.delete(key); + return bundles.delete(key); +} + +export function listServerBundles(): LspServerBundle[] { + return Array.from(bundles.values()); +} + +export function getServerBundle(id: string): LspServerBundle | null { + const owner = serverOwners.get(toKey(id)); + if (!owner) return null; + return bundles.get(owner) || null; +} + +export function ensureBuiltinBundlesRegistered(): void { + if (builtinsRegistered) return; + builtinServerBundles.forEach((bundle) => { + registerServerBundle(bundle, { replace: false }); + }); + builtinsRegistered = true; +} diff --git a/src/cm/lsp/serverLauncher.ts b/src/cm/lsp/serverLauncher.ts index c2f89a533..5b00c077a 100644 --- a/src/cm/lsp/serverLauncher.ts +++ b/src/cm/lsp/serverLauncher.ts @@ -3,8 +3,17 @@ import toast from "components/toast"; import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; +import { buildShellArchCase } from "./installerUtils"; +import { + formatCommand, + quoteArg, + runForegroundCommand, + runQuickCommand, +} from "./installRuntime"; +import { getServerBundle } from "./serverCatalog"; import type { BridgeConfig, + InstallCheckResult, InstallStatus, LauncherConfig, LspServerDefinition, @@ -54,38 +63,11 @@ function getBackgroundExecutor(): Executor { } function joinCommand(command: string, args: string[] = []): string { - if (!Array.isArray(args)) return command; - return [command, ...args].join(" "); + if (!Array.isArray(args) || !args.length) return quoteArg(command); + return [quoteArg(command), ...args.map((arg) => quoteArg(arg))].join(" "); } -function wrapShellCommand(command: string): string { - const script = command.trim(); - const escaped = script.replace(/"/g, '\\"'); - return `sh -lc "set -e; ${escaped}"`; -} - -/** - * Run a quick shell command using the background executor. - */ -async function runQuickCommand(command: string): Promise { - const wrapped = wrapShellCommand(command); - return getBackgroundExecutor().execute(wrapped, true); -} - -/** - * Run a shell command using the foreground executor - */ -async function runForegroundCommand(command: string): Promise { - const wrapped = wrapShellCommand(command); - return getExecutor().execute(wrapped, true); -} - -function quoteArg(value: unknown): string { - const str = String(value ?? ""); - if (!str.length) return "''"; - if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(str)) return str; - return `'${str.replace(/'/g, "'\\''")}'`; -} +export { formatCommand } from "./installRuntime"; // ============================================================================ // Auto-Port Discovery @@ -304,7 +286,11 @@ export async function canReuseExistingServer( session: string, ): Promise { const bridge = server.launcher?.bridge; - const serverName = bridge?.command || server.launcher?.command || server.id; + const serverName = + resolveServerExecutable(server) || + bridge?.command || + server.launcher?.command || + server.id; const portInfo = await getLspPort(serverName, session); if (!portInfo) { @@ -329,15 +315,17 @@ export async function canReuseExistingServer( function buildAxsBridgeCommand( bridge: BridgeConfig | undefined, + commandOverride?: string | null, session?: string, ): string | null { if (!bridge || bridge.kind !== "axs") return null; - const binary = bridge.command - ? String(bridge.command) - : (() => { - throw new Error("Bridge requires a command to execute"); - })(); + const binary = + commandOverride || bridge.command + ? String(commandOverride || bridge.command) + : (() => { + throw new Error("Bridge requires a command to execute"); + })(); const args: string[] = Array.isArray(bridge.args) ? bridge.args.map((arg) => String(arg)) : []; @@ -374,30 +362,397 @@ function resolveStartCommand( ): string | null { const launcher = server.launcher; if (!launcher) return null; + const executable = resolveServerExecutable(server); if (launcher.startCommand) { - return Array.isArray(launcher.startCommand) - ? launcher.startCommand.join(" ") - : String(launcher.startCommand); + return formatCommand(launcher.startCommand); } if (launcher.command) { - return joinCommand(launcher.command, launcher.args); + return joinCommand(executable || launcher.command, launcher.args); } if (launcher.bridge) { - return buildAxsBridgeCommand(launcher.bridge, session); + return buildAxsBridgeCommand(launcher.bridge, executable, session); } return null; } +export function getStartCommand(server: LspServerDefinition): string | null { + return resolveStartCommand(server); +} + +function getInstallCacheKey(server: LspServerDefinition): string | null { + const checkCommand = + server.launcher?.checkCommand || buildDerivedCheckCommand(server); + if (!checkCommand) return null; + return `${server.id}:${checkCommand}`; +} + +function normalizeInstallSpec(server: LspServerDefinition) { + const install = server.launcher?.install; + if (!install) return null; + + const packages = Array.isArray(install.packages) + ? install.packages + .map((entry) => String(entry || "").trim()) + .filter(Boolean) + : []; + const kind = + install.kind || + (install.binaryPath ? "manual" : null) || + (install.source === "apk" ? "apk" : null) || + (install.source === "npm" ? "npm" : null) || + (install.source === "pip" ? "pip" : null) || + (install.source === "cargo" ? "cargo" : null) || + (install.command ? "shell" : null) || + "shell"; + + return { + ...install, + kind, + packages, + command: + typeof install.command === "string" && install.command.trim() + ? install.command.trim() + : undefined, + updateCommand: + typeof install.updateCommand === "string" && install.updateCommand.trim() + ? install.updateCommand.trim() + : undefined, + source: + install.source || + (kind === "shell" ? "custom" : kind === "manual" ? "manual" : kind), + executable: + typeof install.executable === "string" && install.executable.trim() + ? install.executable.trim() + : undefined, + binaryPath: + typeof install.binaryPath === "string" && install.binaryPath.trim() + ? install.binaryPath.trim() + : undefined, + repo: + typeof install.repo === "string" && install.repo.trim() + ? install.repo.trim() + : undefined, + assetNames: + install.assetNames && typeof install.assetNames === "object" + ? Object.fromEntries( + Object.entries(install.assetNames) + .map(([key, value]) => [String(key), String(value || "").trim()]) + .filter(([, value]) => Boolean(value)), + ) + : {}, + archiveType: install.archiveType === "binary" ? "binary" : "zip", + extractFile: + typeof install.extractFile === "string" && install.extractFile.trim() + ? install.extractFile.trim() + : undefined, + npmCommand: + typeof install.npmCommand === "string" && install.npmCommand.trim() + ? install.npmCommand.trim() + : "npm", + pipCommand: + typeof install.pipCommand === "string" && install.pipCommand.trim() + ? install.pipCommand.trim() + : "pip", + pythonCommand: + typeof install.pythonCommand === "string" && install.pythonCommand.trim() + ? install.pythonCommand.trim() + : "python3", + global: install.global !== false, + breakSystemPackages: install.breakSystemPackages !== false, + }; +} + +function getInstallerExecutable(server: LspServerDefinition): string | null { + const install = normalizeInstallSpec(server); + if (!install) return null; + return install.binaryPath || install.executable || null; +} + +function getProviderExecutable(server: LspServerDefinition): string | null { + const bundle = getServerBundle(server.id); + if (!bundle?.getExecutable) return null; + try { + return bundle.getExecutable(server.id, server) || null; + } catch (error) { + console.warn(`Failed to resolve bundle executable for ${server.id}`, error); + return null; + } +} + +function resolveServerExecutable(server: LspServerDefinition): string | null { + return ( + getProviderExecutable(server) || + getInstallerExecutable(server) || + server.launcher?.bridge?.command || + server.launcher?.command || + null + ); +} + +function getInstallLabel(server: LspServerDefinition): string { + return ( + normalizeInstallSpec(server)?.label || + server.launcher?.install?.label || + server.label || + server.id + ).trim(); +} + +function buildUninstallCommand(server: LspServerDefinition): string | null { + const spec = normalizeInstallSpec(server); + if (!spec) return null; + + if (spec.uninstallCommand) { + return spec.uninstallCommand; + } + if (server.launcher?.uninstallCommand) { + return server.launcher.uninstallCommand; + } + + switch (spec.kind) { + case "apk": + return spec.packages.length + ? `apk del ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "npm": { + if (!spec.packages.length) return null; + const npmCommand = spec.npmCommand || "npm"; + const uninstallFlags = + spec.global !== false ? "uninstall -g" : "uninstall"; + return `${npmCommand} ${uninstallFlags} ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; + } + case "pip": + return spec.packages.length + ? `${spec.pipCommand || "pip"} uninstall -y ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "cargo": + return spec.packages.length + ? spec.packages + .map((entry) => `cargo uninstall ${quoteArg(entry)}`) + .join(" && ") + : null; + case "github-release": + case "manual": + return spec.binaryPath ? `rm -f ${quoteArg(spec.binaryPath)}` : null; + default: + return null; + } +} + +function buildInstallCommand( + server: LspServerDefinition, + mode: "install" | "update" = "install", +): string | null { + const spec = normalizeInstallSpec(server); + if (!spec) return null; + + if (mode === "update" && spec.updateCommand) { + return spec.updateCommand; + } + + switch (spec.kind) { + case "apk": + return spec.packages.length + ? `apk add --no-cache ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "npm": { + if (!spec.packages.length) return null; + const npmCommand = spec.npmCommand || "npm"; + const installFlags = spec.global !== false ? "install -g" : "install"; + return `apk add --no-cache nodejs npm && ${npmCommand} ${installFlags} ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; + } + case "pip": { + if (!spec.packages.length) return null; + const pipCommand = spec.pipCommand || "pip"; + const breakPackages = + spec.breakSystemPackages !== false + ? "PIP_BREAK_SYSTEM_PACKAGES=1 " + : ""; + return `apk add --no-cache python3 py3-pip && ${breakPackages}${pipCommand} install ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}`; + } + case "cargo": + return spec.packages.length + ? `apk add --no-cache rust cargo && cargo install ${spec.packages.map((entry) => quoteArg(entry)).join(" ")}` + : null; + case "github-release": { + if (!spec.repo || !spec.binaryPath) return null; + const caseLines = buildShellArchCase(spec.assetNames, quoteArg); + if (!caseLines) return null; + const archivePath = '"$TMP_DIR/$ASSET"'; + const extractedFile = quoteArg(spec.extractFile || "luau-lsp"); + const installTarget = quoteArg(spec.binaryPath); + const downloadUrl = `https://github.com/${spec.repo}/releases/latest/download/$ASSET`; + + if (spec.archiveType === "binary") { + return `apk add --no-cache curl && ARCH="$(uname -m)" && case "$ARCH" in\n${caseLines}\n\t*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;\nesac && TMP_DIR="$(mktemp -d)" && cleanup() { rm -rf "$TMP_DIR"; } && trap cleanup EXIT && curl -fsSL "${downloadUrl}" -o ${archivePath} && install -Dm755 ${archivePath} ${installTarget}`; + } + + return `apk add --no-cache curl unzip && ARCH="$(uname -m)" && case "$ARCH" in\n${caseLines}\n\t*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;\nesac && TMP_DIR="$(mktemp -d)" && cleanup() { rm -rf "$TMP_DIR"; } && trap cleanup EXIT && curl -fsSL "${downloadUrl}" -o ${archivePath} && unzip -oq ${archivePath} -d "$TMP_DIR" && install -Dm755 "$TMP_DIR"/${extractedFile} ${installTarget}`; + } + case "manual": + return null; + default: + return spec.command || null; + } +} + +function buildDerivedCheckCommand(server: LspServerDefinition): string | null { + const binary = resolveServerExecutable(server)?.trim() || ""; + const install = normalizeInstallSpec(server); + + if (install?.kind === "manual" && install.binaryPath) { + return `test -x ${quoteArg(install.binaryPath)}`; + } + + if (binary.includes("/")) { + return `test -x ${quoteArg(binary)}`; + } + + if (binary) { + return `which ${quoteArg(binary)}`; + } + + return null; +} + +function getUpdateCommand(server: LspServerDefinition): string | null { + const launcher = server.launcher; + if (!launcher) return null; + if ( + typeof launcher.updateCommand === "string" && + launcher.updateCommand.trim() + ) { + return launcher.updateCommand.trim(); + } + return buildInstallCommand(server, "update"); +} + +async function readServerVersion( + server: LspServerDefinition, +): Promise { + const command = server.launcher?.versionCommand; + if (!command) return null; + + try { + const output = await runQuickCommand(command); + const version = String(output || "") + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + return version || null; + } catch { + return null; + } +} + +export function getInstallCommand( + server: LspServerDefinition, + mode: "install" | "update" = "install", +): string | null { + if (mode === "update") { + return getUpdateCommand(server); + } + return buildInstallCommand(server, "install"); +} + +export function getInstallSource(server: LspServerDefinition): string | null { + return normalizeInstallSpec(server)?.source || null; +} + +export function getUninstallCommand( + server: LspServerDefinition, +): string | null { + return buildUninstallCommand(server); +} + +export async function checkServerInstallation( + server: LspServerDefinition, +): Promise { + const bundle = getServerBundle(server.id); + if (bundle?.checkInstallation) { + try { + const result = await bundle.checkInstallation(server.id, server); + if (result) return result; + } catch (error) { + return { + status: "failed", + version: null, + canInstall: Boolean(getInstallCommand(server, "install")), + canUpdate: Boolean(getInstallCommand(server, "update")), + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const launcher = server.launcher; + const installCommand = getInstallCommand(server, "install"); + const updateCommand = getInstallCommand(server, "update"); + const checkCommand = + launcher?.checkCommand || buildDerivedCheckCommand(server); + + if (!checkCommand) { + return { + status: "unknown", + version: await readServerVersion(server), + canInstall: Boolean(installCommand), + canUpdate: Boolean(updateCommand), + message: "No install check configured for this server.", + }; + } + + try { + await runQuickCommand(checkCommand); + return { + status: "present", + version: await readServerVersion(server), + canInstall: Boolean(installCommand), + canUpdate: Boolean(updateCommand), + }; + } catch (error) { + return { + status: installCommand ? "missing" : "failed", + version: null, + canInstall: Boolean(installCommand), + canUpdate: Boolean(updateCommand), + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export function resetInstallState(serverId?: string): void { + if (!serverId) { + checkedCommands.clear(); + return; + } + + const prefix = `${serverId}:`; + for (const key of Array.from(checkedCommands.keys())) { + if (key.startsWith(prefix)) { + checkedCommands.delete(key); + } + } +} + async function ensureInstalled(server: LspServerDefinition): Promise { const launcher = server.launcher; - if (!launcher?.checkCommand) return true; + const checkCommand = + launcher?.checkCommand || buildDerivedCheckCommand(server); + if (!checkCommand) return true; - const cacheKey = `${server.id}:${launcher.checkCommand}`; + const cacheKey = getInstallCacheKey(server); + if (!cacheKey) return true; // Return cached result if already checked if (checkedCommands.has(cacheKey)) { - return checkedCommands.get(cacheKey) === STATUS_PRESENT; + const status = checkedCommands.get(cacheKey); + if (status === STATUS_PRESENT) { + return true; + } + if (status === STATUS_DECLINED) { + return false; + } + checkedCommands.delete(cacheKey); } // If there's already a pending check for this server, wait for it @@ -422,19 +777,146 @@ interface LoaderDialog { destroy: () => void; } +type InstallActionMode = "install" | "update" | "reinstall"; + +export async function installServer( + server: LspServerDefinition, + mode: InstallActionMode = "install", + options: { promptConfirm?: boolean } = {}, +): Promise { + const bundle = getServerBundle(server.id); + if (bundle?.installServer) { + return bundle.installServer(server.id, server, mode, options); + } + + const { promptConfirm = true } = options; + const cacheKey = getInstallCacheKey(server); + const displayLabel = getInstallLabel(server); + const isUpdate = mode === "update"; + const actionLabel = isUpdate ? "Update" : "Install"; + const command = + mode === "install" + ? getInstallCommand(server, "install") + : getUpdateCommand(server); + + if (!command) { + throw new Error( + `${displayLabel} has no ${actionLabel.toLowerCase()} command.`, + ); + } + + if (promptConfirm) { + const shouldContinue = await confirm( + displayLabel, + `${actionLabel} ${displayLabel} language server?`, + ); + if (!shouldContinue) { + if (cacheKey) { + checkedCommands.set(cacheKey, STATUS_DECLINED); + } + return false; + } + } + + let loadingDialog: LoaderDialog | null = null; + try { + loadingDialog = loader.create( + displayLabel, + `${actionLabel}ing ${displayLabel}...`, + ); + loadingDialog.show(); + await runForegroundCommand(command); + resetInstallState(server.id); + + const result = await checkServerInstallation(server); + if (cacheKey && result.status === "present") { + checkedCommands.set(cacheKey, STATUS_PRESENT); + } + + toast( + result.status === "present" + ? `${displayLabel} ${isUpdate ? "updated" : "installed"}` + : `${displayLabel} ${actionLabel.toLowerCase()} finished`, + ); + return true; + } catch (error) { + console.error(`Failed to ${actionLabel.toLowerCase()} ${server.id}`, error); + if (cacheKey) { + checkedCommands.set(cacheKey, STATUS_FAILED); + } + toast(strings?.error ?? "Error"); + throw error; + } finally { + loadingDialog?.destroy?.(); + } +} + +export async function uninstallServer( + server: LspServerDefinition, + options: { promptConfirm?: boolean } = {}, +): Promise { + const bundle = getServerBundle(server.id); + if (bundle?.uninstallServer) { + return bundle.uninstallServer(server.id, server, options); + } + + const { promptConfirm = true } = options; + const cacheKey = getInstallCacheKey(server); + const displayLabel = getInstallLabel(server); + const command = getUninstallCommand(server); + + if (!command) { + throw new Error(`${displayLabel} has no uninstall command.`); + } + + if (promptConfirm) { + const shouldContinue = await confirm( + displayLabel, + `Uninstall ${displayLabel} language server?`, + ); + if (!shouldContinue) { + return false; + } + } + + let loadingDialog: LoaderDialog | null = null; + try { + loadingDialog = loader.create( + displayLabel, + `Uninstalling ${displayLabel}...`, + ); + loadingDialog.show(); + await runForegroundCommand(command); + if (cacheKey) { + checkedCommands.delete(cacheKey); + } + resetInstallState(server.id); + stopManagedServer(server.id); + return true; + } catch (error) { + console.error(`Failed to uninstall ${server.id}`, error); + toast(strings?.error ?? "Error"); + throw error; + } finally { + loadingDialog?.destroy(); + } +} + async function performInstallCheck( server: LspServerDefinition, - launcher: LauncherConfig, + launcher: LauncherConfig | undefined, cacheKey: string, ): Promise { try { - if (launcher.checkCommand) { - await runQuickCommand(launcher.checkCommand); + const checkCommand = + launcher?.checkCommand || buildDerivedCheckCommand(server); + if (checkCommand) { + await runQuickCommand(checkCommand); } checkedCommands.set(cacheKey, STATUS_PRESENT); return true; } catch (error) { - if (!launcher.install) { + if (!getInstallCommand(server, "install")) { checkedCommands.set(cacheKey, STATUS_FAILED); console.warn( `LSP server ${server.id} is missing check command result and has no installer.`, @@ -443,42 +925,15 @@ async function performInstallCheck( throw error; } - const install = launcher.install; - const displayLabel = ( - server.label || - server.id || - "Language server" - ).trim(); - const promptMessage = `Install ${displayLabel} language server?`; - const shouldInstall = await confirm( - server.label || displayLabel, - promptMessage, - ); - - if (!shouldInstall) { + const installed = await installServer(server, "install", { + promptConfirm: true, + }); + if (!installed) { checkedCommands.set(cacheKey, STATUS_DECLINED); return false; } - - let loadingDialog: LoaderDialog | null = null; - try { - loadingDialog = loader.create( - server.label, - `Installing ${server.label}...`, - ); - loadingDialog.show(); - await runForegroundCommand(install.command); - toast(`${server.label} installed`); - checkedCommands.set(cacheKey, STATUS_PRESENT); - return true; - } catch (installError) { - console.error(`Failed to install ${server.id}`, installError); - toast(strings?.error ?? "Error"); - checkedCommands.set(cacheKey, STATUS_FAILED); - throw installError; - } finally { - loadingDialog?.destroy?.(); - } + checkedCommands.set(cacheKey, STATUS_PRESENT); + return true; } } @@ -594,7 +1049,11 @@ export async function ensureServerRunning( // Check if server is already running via port file (dead client detection) const bridge = launcher.bridge; - const serverName = bridge?.command || launcher.command || server.id; + const serverName = + resolveServerExecutable(server) || + bridge?.command || + launcher.command || + server.id; try { const existingPort = await canReuseExistingServer(server, effectiveSession); diff --git a/src/cm/lsp/serverRegistry.ts b/src/cm/lsp/serverRegistry.ts index 23e178ef8..3ebbddf7f 100644 --- a/src/cm/lsp/serverRegistry.ts +++ b/src/cm/lsp/serverRegistry.ts @@ -1,9 +1,14 @@ +import { + bindServerRegistry, + ensureBuiltinBundlesRegistered, +} from "./serverCatalog"; import type { AcodeClientConfig, BridgeConfig, LanguageResolverContext, LauncherConfig, LspServerDefinition, + LspServerManifest, RegistryEventListener, RegistryEventType, RootUriContext, @@ -56,6 +61,31 @@ interface RawBridgeConfig { session?: string; } +function sanitizeInstallKind( + value: unknown, +): + | "apk" + | "npm" + | "pip" + | "cargo" + | "github-release" + | "manual" + | "shell" + | undefined { + switch (value) { + case "apk": + case "npm": + case "pip": + case "cargo": + case "github-release": + case "manual": + case "shell": + return value; + default: + return undefined; + } +} + function sanitizeBridge( serverId: string, bridge: RawBridgeConfig | undefined | null, @@ -98,30 +128,32 @@ interface RawLauncherConfig { args?: unknown[]; startCommand?: string | string[]; checkCommand?: string; - install?: { command?: string }; + versionCommand?: string; + updateCommand?: string; + install?: { + kind?: string; + command?: string; + updateCommand?: string; + uninstallCommand?: string; + label?: string; + source?: string; + executable?: string; + packages?: unknown[]; + pipCommand?: string; + npmCommand?: string; + pythonCommand?: string; + global?: boolean; + breakSystemPackages?: boolean; + repo?: string; + assetNames?: Record; + archiveType?: string; + extractFile?: string; + binaryPath?: string; + }; bridge?: RawBridgeConfig; } -interface RawServerDefinition { - id?: string; - label?: string; - enabled?: boolean; - languages?: string[]; - transport?: RawTransportDescriptor | TransportDescriptor; - initializationOptions?: Record; - clientConfig?: Record | AcodeClientConfig; - startupTimeout?: number; - capabilityOverrides?: Record; - rootUri?: - | ((uri: string, context: unknown) => string | null) - | ((uri: string, context: RootUriContext) => string | null) - | null; - resolveLanguageId?: - | ((context: LanguageResolverContext) => string | null) - | null; - launcher?: RawLauncherConfig | LauncherConfig; - useWorkspaceFolders?: boolean; -} +export type RawServerDefinition = LspServerManifest; function sanitizeDefinition( definition: RawServerDefinition, @@ -181,6 +213,10 @@ function sanitizeDefinition( let launcher: LauncherConfig | undefined; if (definition.launcher && typeof definition.launcher === "object") { const rawLauncher = definition.launcher; + const installExecutable = + typeof rawLauncher.install?.executable === "string" + ? rawLauncher.install.executable.trim() + : ""; launcher = { command: rawLauncher.command, args: Array.isArray(rawLauncher.args) @@ -190,14 +226,57 @@ function sanitizeDefinition( ? rawLauncher.startCommand.map((arg) => String(arg)) : rawLauncher.startCommand, checkCommand: rawLauncher.checkCommand, + versionCommand: rawLauncher.versionCommand, + updateCommand: rawLauncher.updateCommand, + uninstallCommand: rawLauncher.uninstallCommand, install: rawLauncher.install && typeof rawLauncher.install === "object" ? { + kind: sanitizeInstallKind(rawLauncher.install.kind), command: rawLauncher.install.command ?? "", + updateCommand: rawLauncher.install.updateCommand, + uninstallCommand: rawLauncher.install.uninstallCommand, + label: rawLauncher.install.label, + source: rawLauncher.install.source, + executable: installExecutable || undefined, + packages: Array.isArray(rawLauncher.install.packages) + ? rawLauncher.install.packages.map((entry) => String(entry)) + : undefined, + pipCommand: rawLauncher.install.pipCommand, + npmCommand: rawLauncher.install.npmCommand, + pythonCommand: rawLauncher.install.pythonCommand, + global: rawLauncher.install.global, + breakSystemPackages: rawLauncher.install.breakSystemPackages, + repo: rawLauncher.install.repo, + assetNames: + rawLauncher.install.assetNames && + typeof rawLauncher.install.assetNames === "object" + ? Object.fromEntries( + Object.entries(rawLauncher.install.assetNames).map( + ([key, value]) => [String(key), String(value)], + ), + ) + : undefined, + archiveType: + rawLauncher.install.archiveType === "binary" ? "binary" : "zip", + extractFile: rawLauncher.install.extractFile, + binaryPath: rawLauncher.install.binaryPath, } : undefined, bridge: sanitizeBridge(id, rawLauncher.bridge), }; + + const installKind = launcher.install?.kind; + const isManagedInstall = installKind && installKind !== "shell"; + if (isManagedInstall) { + const providedExecutable = + launcher.install?.binaryPath || launcher.install?.executable; + if (!providedExecutable) { + throw new Error( + `LSP server ${id} managed installers must declare install.binaryPath or install.executable`, + ); + } + } } const sanitized: LspServerDefinition = { @@ -230,27 +309,6 @@ function sanitizeDefinition( return sanitized; } -function resolveJsTsLanguageId( - languageId: string | undefined, - languageName: string | undefined, -): string | null { - const lang = toKey(languageId ?? languageName); - switch (lang) { - case "tsx": - case "typescriptreact": - return "typescriptreact"; - case "jsx": - case "javascriptreact": - return "javascriptreact"; - case "ts": - return "typescript"; - case "js": - return "javascript"; - default: - return lang || null; - } -} - function notify(event: RegistryEventType, payload: LspServerDefinition): void { listeners.forEach((fn) => { try { @@ -349,721 +407,11 @@ export function onRegistryChange(listener: RegistryEventListener): () => void { return () => listeners.delete(listener); } -function registerBuiltinServers(): void { - const defaults: RawServerDefinition[] = [ - { - id: "typescript", - label: "TypeScript / JavaScript", - useWorkspaceFolders: true, - languages: [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact", - "tsx", - "jsx", - ], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "typescript-language-server", - args: ["--stdio"], - }, - checkCommand: "which typescript-language-server", - install: { - command: - "apk add --no-cache nodejs npm && npm install -g typescript-language-server typescript", - }, - }, - enabled: true, - initializationOptions: { - provideFormatter: true, - hostInfo: "acode", - tsserver: { - maxTsServerMemory: 4096, - useSeparateSyntaxServer: true, - }, - preferences: { - includeInlayParameterNameHints: "all", - includeInlayParameterNameHintsWhenArgumentMatchesName: true, - includeInlayFunctionParameterTypeHints: true, - includeInlayVariableTypeHints: true, - includeInlayVariableTypeHintsWhenTypeMatchesName: false, - includeInlayPropertyDeclarationTypeHints: true, - includeInlayFunctionLikeReturnTypeHints: true, - includeInlayEnumMemberValueHints: true, - importModuleSpecifierPreference: "shortest", - importModuleSpecifierEnding: "auto", - includePackageJsonAutoImports: "auto", - provideRefactorNotApplicableReason: true, - allowIncompleteCompletions: true, - allowRenameOfImportPath: true, - generateReturnInDocTemplate: true, - organizeImportsIgnoreCase: "auto", - organizeImportsCollation: "ordinal", - organizeImportsCollationConfig: "default", - autoImportFileExcludePatterns: [], - preferTypeOnlyAutoImports: false, - }, - completions: { - completeFunctionCalls: true, - }, - diagnostics: { - reportStyleChecksAsWarnings: true, - }, - }, - resolveLanguageId: ({ languageId, languageName }) => - resolveJsTsLanguageId(languageId, languageName), - }, - { - id: "vtsls", - label: "TypeScript / JavaScript (vtsls)", - useWorkspaceFolders: true, - languages: [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact", - "tsx", - "jsx", - ], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "vtsls", - args: ["--stdio"], - }, - checkCommand: "which vtsls", - install: { - command: - "apk add --no-cache nodejs npm && npm install -g @vtsls/language-server", - }, - }, - enabled: false, - initializationOptions: { - hostInfo: "acode", - typescript: { - enablePromptUseWorkspaceTsdk: true, - inlayHints: { - parameterNames: { - enabled: "all", - suppressWhenArgumentMatchesName: false, - }, - parameterTypes: { - enabled: true, - }, - variableTypes: { - enabled: true, - suppressWhenTypeMatchesName: false, - }, - propertyDeclarationTypes: { - enabled: true, - }, - functionLikeReturnTypes: { - enabled: true, - }, - enumMemberValues: { - enabled: true, - }, - }, - suggest: { - completeFunctionCalls: true, - includeCompletionsForModuleExports: true, - includeCompletionsWithInsertText: true, - includeAutomaticOptionalChainCompletions: true, - includeCompletionsWithSnippetText: true, - includeCompletionsWithClassMemberSnippets: true, - includeCompletionsWithObjectLiteralMethodSnippets: true, - autoImports: true, - classMemberSnippets: { - enabled: true, - }, - objectLiteralMethodSnippets: { - enabled: true, - }, - }, - preferences: { - importModuleSpecifier: "shortest", - importModuleSpecifierEnding: "auto", - includePackageJsonAutoImports: "auto", - preferTypeOnlyAutoImports: false, - quoteStyle: "auto", - jsxAttributeCompletionStyle: "auto", - }, - format: { - enable: true, - insertSpaceAfterCommaDelimiter: true, - insertSpaceAfterSemicolonInForStatements: true, - insertSpaceBeforeAndAfterBinaryOperators: true, - insertSpaceAfterKeywordsInControlFlowStatements: true, - insertSpaceAfterFunctionKeywordForAnonymousFunctions: false, - insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false, - insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false, - insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true, - insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: false, - insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: false, - placeOpenBraceOnNewLineForFunctions: false, - placeOpenBraceOnNewLineForControlBlocks: false, - semicolons: "ignore", - }, - updateImportsOnFileMove: { - enabled: "always", - }, - codeActionsOnSave: { - organizeImports: false, - addMissingImports: false, - }, - workspaceSymbols: { - scope: "allOpenProjects", - }, - }, - javascript: { - inlayHints: { - parameterNames: { - enabled: "all", - suppressWhenArgumentMatchesName: false, - }, - parameterTypes: { - enabled: true, - }, - variableTypes: { - enabled: true, - suppressWhenTypeMatchesName: false, - }, - propertyDeclarationTypes: { - enabled: true, - }, - functionLikeReturnTypes: { - enabled: true, - }, - enumMemberValues: { - enabled: true, - }, - }, - suggest: { - completeFunctionCalls: true, - includeCompletionsForModuleExports: true, - autoImports: true, - classMemberSnippets: { - enabled: true, - }, - }, - preferences: { - importModuleSpecifier: "shortest", - quoteStyle: "auto", - }, - format: { - enable: true, - }, - updateImportsOnFileMove: { - enabled: "always", - }, - }, - tsserver: { - maxTsServerMemory: 8092, - }, - vtsls: { - experimental: { - completion: { - enableServerSideFuzzyMatch: true, - entriesLimit: 5000, - }, - }, - autoUseWorkspaceTsdk: true, - }, - }, - resolveLanguageId: ({ languageId, languageName }) => - resolveJsTsLanguageId(languageId, languageName), - }, - { - id: "python", - label: "Python (pylsp)", - languages: ["python"], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "pylsp", - }, - checkCommand: "which pylsp", - install: { - command: - "apk update && apk upgrade && apk add python3 py3-pip && PIP_BREAK_SYSTEM_PACKAGES=1 pip install 'python-lsp-server[all]'", - }, - }, - initializationOptions: { - pylsp: { - plugins: { - pyflakes: { enabled: true }, - pycodestyle: { enabled: true }, - mccabe: { enabled: true }, - }, - }, - }, - enabled: true, - }, - // { - // id: "luau", - // label: "Luau", - // useWorkspaceFolders: true, - // languages: ["luau"], - // transport: { - // kind: "websocket", - // }, - // launcher: { - // bridge: { - // kind: "axs", - // command: "luau-lsp", - // args: ["lsp"], - // }, - // checkCommand: "which luau-lsp", - // install: { - // command: `apk add --no-cache curl unzip && \ - // ARCH="$(uname -m)" && \ - // case "$ARCH" in \ - // aarch64|arm64) ASSET="luau-lsp-linux-arm64.zip" ;; \ - // x86_64|amd64) ASSET="luau-lsp-linux-x86_64.zip" ;; \ - // *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; \ - // esac && \ - // TMP_DIR="$(mktemp -d)" && \ - // cleanup() { rm -rf "$TMP_DIR"; } && \ - // trap cleanup EXIT && \ - // curl -fsSL "https://github.com/JohnnyMorganz/luau-lsp/releases/latest/download/$ASSET" -o "$TMP_DIR/luau-lsp.zip" && \ - // unzip -oq "$TMP_DIR/luau-lsp.zip" -d "$TMP_DIR" && \ - // install -Dm755 "$TMP_DIR/luau-lsp" /usr/local/bin/luau-lsp`, - // }, - // }, - // enabled: true, - // }, - { - id: "eslint", - label: "ESLint", - languages: [ - "javascript", - "javascriptreact", - "typescript", - "typescriptreact", - "tsx", - "jsx", - "vue", - "svelte", - "html", - "markdown", - "json", - "jsonc", - ], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "vscode-eslint-language-server", - args: ["--stdio"], - }, - checkCommand: "which vscode-eslint-language-server", - install: { - command: - "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", - }, - }, - enabled: false, - initializationOptions: { - validate: "on", - rulesCustomizations: [], - run: "onType", - nodePath: null, - workingDirectory: { - mode: "auto", - }, - problems: { - shortenToSingleLine: false, - }, - codeActionOnSave: { - enable: true, - rules: [], - mode: "all", - }, - codeAction: { - disableRuleComment: { - enable: true, - location: "separateLine", - commentStyle: "line", - }, - showDocumentation: { - enable: true, - }, - }, - experimental: { - useFlatConfig: false, - }, - format: { - enable: true, - }, - quiet: false, - onIgnoredFiles: "off", - useESLintClass: false, - }, - clientConfig: { - builtinExtensions: { - hover: false, - completion: false, - signature: false, - keymaps: false, - diagnostics: true, - }, - }, - resolveLanguageId: ({ languageId, languageName }) => - resolveJsTsLanguageId(languageId, languageName), - }, - { - id: "clangd", - label: "C / C++ (clangd)", - languages: ["c", "cpp"], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "clangd", - }, - checkCommand: "which clangd", - install: { - command: "apk add --no-cache clang-extra-tools", - }, - }, - enabled: false, - }, - { - id: "html", - label: "HTML", - languages: ["html", "vue", "svelte"], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "vscode-html-language-server", - args: ["--stdio"], - }, - checkCommand: "which vscode-html-language-server", - install: { - command: - "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", - }, - }, - enabled: true, - }, - { - id: "css", - label: "CSS", - languages: ["css", "scss", "less"], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "vscode-css-language-server", - args: ["--stdio"], - }, - checkCommand: "which vscode-css-language-server", - install: { - command: - "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", - }, - }, - enabled: true, - }, - { - id: "json", - label: "JSON", - languages: ["json", "jsonc"], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "vscode-json-language-server", - args: ["--stdio"], - }, - checkCommand: "which vscode-json-language-server", - install: { - command: - "apk add --no-cache nodejs npm && npm install -g vscode-langservers-extracted", - }, - }, - enabled: true, - }, - { - id: "gopls", - label: "Go (gopls)", - languages: ["go", "go.mod", "go.sum", "gotmpl"], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "gopls", - args: ["serve"], - }, - checkCommand: "which gopls", - install: { - command: "apk add --no-cache go gopls", - }, - }, - initializationOptions: { - usePlaceholders: false, - completeUnimported: true, - deepCompletion: true, - completionBudget: "100ms", - matcher: "Fuzzy", - staticcheck: true, - gofumpt: true, - hints: { - assignVariableTypes: true, - compositeLiteralFields: true, - compositeLiteralTypes: true, - constantValues: true, - functionTypeParameters: true, - parameterNames: true, - rangeVariableTypes: true, - }, - diagnosticsDelay: "250ms", - diagnosticsTrigger: "Edit", - annotations: { - bounds: true, - escape: true, - inline: true, - nil: true, - }, - semanticTokens: true, - analyses: { - nilness: true, - unusedparams: true, - unusedvariable: true, - unusedwrite: true, - shadow: true, - fieldalignment: false, - stringintconv: true, - }, - importShortcut: "Both", - symbolMatcher: "FastFuzzy", - symbolStyle: "Dynamic", - symbolScope: "all", - local: "", - linksInHover: true, - hoverKind: "FullDocumentation", - verboseOutput: false, - }, - enabled: true, - }, - { - id: "rust-analyzer", - label: "Rust (rust-analyzer)", - useWorkspaceFolders: true, - languages: ["rust"], - transport: { - kind: "websocket", - }, - launcher: { - bridge: { - kind: "axs", - command: "rust-analyzer", - }, - checkCommand: "which rust-analyzer", - install: { - command: "apk add --no-cache rust cargo rust-analyzer", - }, - }, - initializationOptions: { - cargo: { - allFeatures: true, - buildScripts: { - enable: true, - }, - loadOutDirsFromCheck: true, - }, - procMacro: { - enable: true, - attributes: { - enable: true, - }, - }, - checkOnSave: { - enable: true, - command: "clippy", - extraArgs: ["--no-deps"], - }, - diagnostics: { - enable: true, - experimental: { - enable: true, - }, - }, - inlayHints: { - bindingModeHints: { - enable: false, - }, - chainingHints: { - enable: true, - }, - closingBraceHints: { - enable: true, - minLines: 25, - }, - closureReturnTypeHints: { - enable: "with_block", - }, - lifetimeElisionHints: { - enable: "skip_trivial", - useParameterNames: true, - }, - maxLength: 25, - parameterHints: { - enable: true, - }, - reborrowHints: { - enable: "mutable", - }, - typeHints: { - enable: true, - hideClosureInitialization: false, - hideNamedConstructor: false, - }, - }, - lens: { - enable: true, - debug: { - enable: true, - }, - implementations: { - enable: true, - }, - references: { - adt: { enable: false }, - enumVariant: { enable: false }, - method: { enable: false }, - trait: { enable: false }, - }, - run: { - enable: true, - }, - }, - completion: { - autoimport: { - enable: true, - }, - autoself: { - enable: true, - }, - callable: { - snippets: "fill_arguments", - }, - postfix: { - enable: true, - }, - privateEditable: { - enable: false, - }, - }, - semanticHighlighting: { - doc: { - comment: { - inject: { - enable: true, - }, - }, - }, - operator: { - enable: true, - specialization: { - enable: true, - }, - }, - punctuation: { - enable: false, - separate: { - macro: { - bang: true, - }, - }, - specialization: { - enable: true, - }, - }, - strings: { - enable: true, - }, - }, - hover: { - actions: { - debug: { - enable: true, - }, - enable: true, - gotoTypeDef: { - enable: true, - }, - implementations: { - enable: true, - }, - references: { - enable: true, - }, - run: { - enable: true, - }, - }, - documentation: { - enable: true, - }, - links: { - enable: true, - }, - }, - workspace: { - symbol: { - search: { - kind: "all_symbols", - scope: "workspace", - }, - }, - }, - rustfmt: { - extraArgs: [], - overrideCommand: null, - rangeFormatting: { - enable: false, - }, - }, - }, - enabled: true, - }, - ]; - - defaults.forEach((def) => { - try { - registerServer(def, { replace: false }); - } catch (error) { - console.error("Failed to register builtin LSP server", def.id, error); - } - }); -} - -registerBuiltinServers(); +bindServerRegistry({ + registerServer, + unregisterServer, +}); +ensureBuiltinBundlesRegistered(); export default { registerServer, diff --git a/src/cm/lsp/servers/index.ts b/src/cm/lsp/servers/index.ts new file mode 100644 index 000000000..9164ea1a8 --- /dev/null +++ b/src/cm/lsp/servers/index.ts @@ -0,0 +1,22 @@ +import type { LspServerBundle, LspServerManifest } from "../types"; +import { javascriptBundle, javascriptServers } from "./javascript"; +import { luauBundle, luauServers } from "./luau"; +import { pythonBundle, pythonServers } from "./python"; +import { systemsBundle, systemsServers } from "./systems"; +import { webBundle, webServers } from "./web"; + +export const builtinServers: LspServerManifest[] = [ + ...javascriptServers, + ...pythonServers, + ...luauServers, + ...webServers, + ...systemsServers, +]; + +export const builtinServerBundles: LspServerBundle[] = [ + javascriptBundle, + pythonBundle, + luauBundle, + webBundle, + systemsBundle, +]; diff --git a/src/cm/lsp/servers/javascript.ts b/src/cm/lsp/servers/javascript.ts new file mode 100644 index 000000000..066359275 --- /dev/null +++ b/src/cm/lsp/servers/javascript.ts @@ -0,0 +1,308 @@ +import { defineBundle, defineServer, installers } from "../providerUtils"; +import type { LspServerBundle, LspServerManifest } from "../types"; +import { resolveJsTsLanguageId } from "./shared"; + +export const javascriptServers: LspServerManifest[] = [ + defineServer({ + id: "typescript", + label: "TypeScript / JavaScript", + useWorkspaceFolders: true, + languages: [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "tsx", + "jsx", + ], + transport: { + kind: "websocket", + }, + command: "typescript-language-server", + args: ["--stdio"], + checkCommand: "which typescript-language-server", + installer: installers.npm({ + executable: "typescript-language-server", + packages: ["typescript-language-server", "typescript"], + }), + enabled: true, + initializationOptions: { + provideFormatter: true, + hostInfo: "acode", + tsserver: { + maxTsServerMemory: 4096, + useSeparateSyntaxServer: true, + }, + preferences: { + includeInlayParameterNameHints: "all", + includeInlayParameterNameHintsWhenArgumentMatchesName: true, + includeInlayFunctionParameterTypeHints: true, + includeInlayVariableTypeHints: true, + includeInlayVariableTypeHintsWhenTypeMatchesName: false, + includeInlayPropertyDeclarationTypeHints: true, + includeInlayFunctionLikeReturnTypeHints: true, + includeInlayEnumMemberValueHints: true, + importModuleSpecifierPreference: "shortest", + importModuleSpecifierEnding: "auto", + includePackageJsonAutoImports: "auto", + provideRefactorNotApplicableReason: true, + allowIncompleteCompletions: true, + allowRenameOfImportPath: true, + generateReturnInDocTemplate: true, + organizeImportsIgnoreCase: "auto", + organizeImportsCollation: "ordinal", + organizeImportsCollationConfig: "default", + autoImportFileExcludePatterns: [], + preferTypeOnlyAutoImports: false, + }, + completions: { + completeFunctionCalls: true, + }, + diagnostics: { + reportStyleChecksAsWarnings: true, + }, + }, + resolveLanguageId: ({ languageId, languageName }) => + resolveJsTsLanguageId(languageId, languageName), + }), + defineServer({ + id: "vtsls", + label: "TypeScript / JavaScript (vtsls)", + useWorkspaceFolders: true, + languages: [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "tsx", + "jsx", + ], + transport: { + kind: "websocket", + }, + command: "vtsls", + args: ["--stdio"], + checkCommand: "which vtsls", + installer: installers.npm({ + executable: "vtsls", + packages: ["@vtsls/language-server"], + }), + enabled: false, + initializationOptions: { + hostInfo: "acode", + typescript: { + enablePromptUseWorkspaceTsdk: true, + inlayHints: { + parameterNames: { + enabled: "all", + suppressWhenArgumentMatchesName: false, + }, + parameterTypes: { + enabled: true, + }, + variableTypes: { + enabled: true, + suppressWhenTypeMatchesName: false, + }, + propertyDeclarationTypes: { + enabled: true, + }, + functionLikeReturnTypes: { + enabled: true, + }, + enumMemberValues: { + enabled: true, + }, + }, + suggest: { + completeFunctionCalls: true, + includeCompletionsForModuleExports: true, + includeCompletionsWithInsertText: true, + includeAutomaticOptionalChainCompletions: true, + includeCompletionsWithSnippetText: true, + includeCompletionsWithClassMemberSnippets: true, + includeCompletionsWithObjectLiteralMethodSnippets: true, + autoImports: true, + classMemberSnippets: { + enabled: true, + }, + objectLiteralMethodSnippets: { + enabled: true, + }, + }, + preferences: { + importModuleSpecifier: "shortest", + importModuleSpecifierEnding: "auto", + includePackageJsonAutoImports: "auto", + preferTypeOnlyAutoImports: false, + quoteStyle: "auto", + jsxAttributeCompletionStyle: "auto", + }, + format: { + enable: true, + insertSpaceAfterCommaDelimiter: true, + insertSpaceAfterSemicolonInForStatements: true, + insertSpaceBeforeAndAfterBinaryOperators: true, + insertSpaceAfterKeywordsInControlFlowStatements: true, + insertSpaceAfterFunctionKeywordForAnonymousFunctions: false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true, + insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: false, + insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: false, + placeOpenBraceOnNewLineForFunctions: false, + placeOpenBraceOnNewLineForControlBlocks: false, + semicolons: "ignore", + }, + updateImportsOnFileMove: { + enabled: "always", + }, + codeActionsOnSave: { + organizeImports: false, + addMissingImports: false, + }, + workspaceSymbols: { + scope: "allOpenProjects", + }, + }, + javascript: { + inlayHints: { + parameterNames: { + enabled: "all", + suppressWhenArgumentMatchesName: false, + }, + parameterTypes: { + enabled: true, + }, + variableTypes: { + enabled: true, + suppressWhenTypeMatchesName: false, + }, + propertyDeclarationTypes: { + enabled: true, + }, + functionLikeReturnTypes: { + enabled: true, + }, + enumMemberValues: { + enabled: true, + }, + }, + suggest: { + completeFunctionCalls: true, + includeCompletionsForModuleExports: true, + autoImports: true, + classMemberSnippets: { + enabled: true, + }, + }, + preferences: { + importModuleSpecifier: "shortest", + quoteStyle: "auto", + }, + format: { + enable: true, + }, + updateImportsOnFileMove: { + enabled: "always", + }, + }, + tsserver: { + maxTsServerMemory: 8092, + }, + vtsls: { + experimental: { + completion: { + enableServerSideFuzzyMatch: true, + entriesLimit: 5000, + }, + }, + autoUseWorkspaceTsdk: true, + }, + }, + resolveLanguageId: ({ languageId, languageName }) => + resolveJsTsLanguageId(languageId, languageName), + }), + defineServer({ + id: "eslint", + label: "ESLint", + languages: [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "tsx", + "jsx", + "vue", + "svelte", + "html", + "markdown", + "json", + "jsonc", + ], + transport: { + kind: "websocket", + }, + command: "vscode-eslint-language-server", + args: ["--stdio"], + checkCommand: "which vscode-eslint-language-server", + installer: installers.npm({ + executable: "vscode-eslint-language-server", + packages: ["vscode-langservers-extracted"], + }), + enabled: false, + initializationOptions: { + validate: "on", + rulesCustomizations: [], + run: "onType", + nodePath: null, + workingDirectory: { + mode: "auto", + }, + problems: { + shortenToSingleLine: false, + }, + codeActionOnSave: { + enable: true, + rules: [], + mode: "all", + }, + codeAction: { + disableRuleComment: { + enable: true, + location: "separateLine", + commentStyle: "line", + }, + showDocumentation: { + enable: true, + }, + }, + experimental: { + useFlatConfig: false, + }, + format: { + enable: true, + }, + quiet: false, + onIgnoredFiles: "off", + useESLintClass: false, + }, + clientConfig: { + builtinExtensions: { + hover: false, + completion: false, + signature: false, + keymaps: false, + diagnostics: true, + }, + }, + resolveLanguageId: ({ languageId, languageName }) => + resolveJsTsLanguageId(languageId, languageName), + }), +]; + +export const javascriptBundle: LspServerBundle = defineBundle({ + id: "builtin-javascript", + label: "JavaScript / TypeScript", + servers: javascriptServers, +}); diff --git a/src/cm/lsp/servers/luau.ts b/src/cm/lsp/servers/luau.ts new file mode 100644 index 000000000..4d401ff4d --- /dev/null +++ b/src/cm/lsp/servers/luau.ts @@ -0,0 +1,188 @@ +import toast from "components/toast"; +import confirm from "dialogs/confirm"; +import loader from "dialogs/loader"; +import { buildShellArchCase } from "../installerUtils"; +import { + quoteArg, + runForegroundCommand, + runQuickCommand, +} from "../installRuntime"; +import { defineBundle, defineServer, installers } from "../providerUtils"; +import type { + InstallCheckResult, + LspServerBundle, + LspServerManifest, +} from "../types"; + +function isGlibcRuntimeError(output: string): boolean { + return ( + output.includes("ld-linux-aarch64.so.1") || + output.includes("ld-linux-x86-64.so.2") || + output.includes("Error loading shared library") || + output.includes("__fprintf_chk") || + output.includes("__snprintf_chk") || + output.includes("__vsnprintf_chk") || + output.includes("__libc_single_threaded") || + output.includes("GLIBC_") + ); +} + +function getLuauRuntimeFailureMessage(output: string): string { + if (isGlibcRuntimeError(output)) { + return "Luau release binary requires glibc and is not runnable in this Alpine/musl environment."; + } + + const firstLine = String(output || "") + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + return firstLine || "Luau binary is installed but not runnable."; +} + +async function readLuauRuntimeFailure(binaryPath: string): Promise { + const command = `${quoteArg(binaryPath)} --help >/dev/null 2>&1 || ${quoteArg(binaryPath)} lsp --help >/dev/null 2>&1`; + try { + await runQuickCommand(command); + return ""; + } catch (error) { + const primaryMessage = + error instanceof Error ? error.message : String(error); + try { + const lddOutput = await runQuickCommand( + `command -v ldd >/dev/null 2>&1 && ldd ${quoteArg(binaryPath)} 2>&1 || true`, + ); + return [primaryMessage, lddOutput].filter(Boolean).join("\n"); + } catch { + return primaryMessage; + } + } +} + +export const luauServers: LspServerManifest[] = [ + defineServer({ + id: "luau", + label: "Luau", + useWorkspaceFolders: true, + languages: ["luau"], + command: "/usr/local/bin/luau-lsp", + args: ["lsp"], + installer: installers.githubRelease({ + repo: "JohnnyMorganz/luau-lsp", + binaryPath: "/usr/local/bin/luau-lsp", + assetNames: { + aarch64: "luau-lsp-linux-arm64.zip", + arm64: "luau-lsp-linux-arm64.zip", + "arm64-v8a": "luau-lsp-linux-arm64.zip", + x86_64: "luau-lsp-linux-x86_64.zip", + amd64: "luau-lsp-linux-x86_64.zip", + }, + extractFile: "luau-lsp", + }), + enabled: false, + }), +]; + +export const luauBundle: LspServerBundle = defineBundle({ + id: "builtin-luau", + label: "Luau", + servers: luauServers, + hooks: { + getExecutable: (_, manifest) => + manifest.launcher?.install?.binaryPath || + manifest.launcher?.install?.executable || + null, + async checkInstallation(_, manifest): Promise { + const binary = + manifest.launcher?.install?.binaryPath || + manifest.launcher?.install?.executable; + if (!binary) { + return { + status: "failed", + version: null, + canInstall: true, + canUpdate: true, + message: "Luau bundle is missing a binary path", + }; + } + + try { + await runQuickCommand(`test -x ${quoteArg(binary)}`); + const runtimeFailure = await readLuauRuntimeFailure(binary); + if (runtimeFailure) { + return { + status: "failed", + version: null, + canInstall: true, + canUpdate: true, + message: getLuauRuntimeFailureMessage(runtimeFailure), + }; + } + return { + status: "present", + version: null, + canInstall: true, + canUpdate: true, + }; + } catch (error) { + return { + status: "missing", + version: null, + canInstall: true, + canUpdate: true, + message: error instanceof Error ? error.message : String(error), + }; + } + }, + async installServer(_, manifest, mode, options = {}): Promise { + const { promptConfirm = true } = options; + const install = manifest.launcher?.install; + const assetCases = buildShellArchCase(install?.assetNames, quoteArg); + const binaryPath = install?.binaryPath; + const repo = install?.repo; + if (!assetCases || !binaryPath || !repo) { + throw new Error("Luau bundle is missing release metadata"); + } + + const label = manifest.label || "Luau"; + const actionLabel = mode === "update" ? "Update" : "Install"; + + if (promptConfirm) { + const shouldContinue = await confirm( + label, + `${actionLabel} ${label} language server?`, + ); + if (!shouldContinue) { + return false; + } + } + + const downloadUrl = `https://github.com/${repo}/releases/latest/download/$ASSET`; + const command = `apk add --no-cache curl unzip && ARCH="$(uname -m)" && case "$ARCH" in +${assetCases} +\t*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; +esac && apk add --no-cache gcompat libstdc++ && TMP_DIR="$(mktemp -d)" && cleanup() { rm -rf "$TMP_DIR"; } && trap cleanup EXIT && curl -fsSL "${downloadUrl}" -o "$TMP_DIR/$ASSET" && unzip -oq "$TMP_DIR/$ASSET" -d "$TMP_DIR" && chmod +x "$TMP_DIR/luau-lsp" && if ! "$TMP_DIR/luau-lsp" --help >/dev/null 2>&1 && ! "$TMP_DIR/luau-lsp" lsp --help >/dev/null 2>&1; then command -v ldd >/dev/null 2>&1 && ldd "$TMP_DIR/luau-lsp" >&2 || true; echo "Luau release binary is not runnable in this environment." >&2; exit 1; fi && install -Dm755 "$TMP_DIR/luau-lsp" ${quoteArg(binaryPath)}`; + + const loadingDialog = loader.create( + label, + `${actionLabel}ing ${label}...`, + ); + try { + loadingDialog.show(); + await runForegroundCommand(command); + const runtimeFailure = await readLuauRuntimeFailure(binaryPath); + if (runtimeFailure) { + await runQuickCommand(`rm -f ${quoteArg(binaryPath)}`); + throw new Error(getLuauRuntimeFailureMessage(runtimeFailure)); + } + toast(`${label} ${mode === "update" ? "updated" : "installed"}`); + return true; + } catch (error) { + console.error(`Failed to ${actionLabel.toLowerCase()} ${label}`, error); + toast(strings?.error ?? "Error"); + throw error; + } finally { + loadingDialog.destroy(); + } + }, + }, +}); diff --git a/src/cm/lsp/servers/python.ts b/src/cm/lsp/servers/python.ts new file mode 100644 index 000000000..cabeeb83a --- /dev/null +++ b/src/cm/lsp/servers/python.ts @@ -0,0 +1,32 @@ +import { defineBundle, defineServer, installers } from "../providerUtils"; +import type { LspServerBundle, LspServerManifest } from "../types"; + +export const pythonServers: LspServerManifest[] = [ + defineServer({ + id: "python", + label: "Python (pylsp)", + languages: ["python"], + command: "pylsp", + checkCommand: "which pylsp", + installer: installers.pip({ + executable: "pylsp", + packages: ["python-lsp-server[all]"], + }), + initializationOptions: { + pylsp: { + plugins: { + pyflakes: { enabled: true }, + pycodestyle: { enabled: true }, + mccabe: { enabled: true }, + }, + }, + }, + enabled: true, + }), +]; + +export const pythonBundle: LspServerBundle = defineBundle({ + id: "builtin-python", + label: "Python", + servers: pythonServers, +}); diff --git a/src/cm/lsp/servers/shared.ts b/src/cm/lsp/servers/shared.ts new file mode 100644 index 000000000..5b0d84e8c --- /dev/null +++ b/src/cm/lsp/servers/shared.ts @@ -0,0 +1,28 @@ +export function normalizeServerLanguageKey( + value: string | undefined | null, +): string { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +export function resolveJsTsLanguageId( + languageId: string | undefined, + languageName: string | undefined, +): string | null { + const lang = normalizeServerLanguageKey(languageId ?? languageName); + switch (lang) { + case "tsx": + case "typescriptreact": + return "typescriptreact"; + case "jsx": + case "javascriptreact": + return "javascriptreact"; + case "ts": + return "typescript"; + case "js": + return "javascript"; + default: + return lang || null; + } +} diff --git a/src/cm/lsp/servers/systems.ts b/src/cm/lsp/servers/systems.ts new file mode 100644 index 000000000..94a34dc56 --- /dev/null +++ b/src/cm/lsp/servers/systems.ts @@ -0,0 +1,255 @@ +import { defineBundle, defineServer, installers } from "../providerUtils"; +import type { LspServerBundle, LspServerManifest } from "../types"; + +export const systemsServers: LspServerManifest[] = [ + defineServer({ + id: "clangd", + label: "C / C++ (clangd)", + languages: ["c", "cpp"], + command: "clangd", + checkCommand: "which clangd", + installer: installers.apk({ + executable: "clangd", + packages: ["clang-extra-tools"], + }), + enabled: false, + }), + defineServer({ + id: "gopls", + label: "Go (gopls)", + languages: ["go", "go.mod", "go.sum", "gotmpl"], + command: "gopls", + args: ["serve"], + checkCommand: "which gopls", + installer: installers.apk({ + executable: "gopls", + packages: ["go", "gopls"], + }), + initializationOptions: { + usePlaceholders: false, + completeUnimported: true, + deepCompletion: true, + completionBudget: "100ms", + matcher: "Fuzzy", + staticcheck: true, + gofumpt: true, + hints: { + assignVariableTypes: true, + compositeLiteralFields: true, + compositeLiteralTypes: true, + constantValues: true, + functionTypeParameters: true, + parameterNames: true, + rangeVariableTypes: true, + }, + diagnosticsDelay: "250ms", + diagnosticsTrigger: "Edit", + annotations: { + bounds: true, + escape: true, + inline: true, + nil: true, + }, + semanticTokens: true, + analyses: { + nilness: true, + unusedparams: true, + unusedvariable: true, + unusedwrite: true, + shadow: true, + fieldalignment: false, + stringintconv: true, + }, + importShortcut: "Both", + symbolMatcher: "FastFuzzy", + symbolStyle: "Dynamic", + symbolScope: "all", + local: "", + linksInHover: true, + hoverKind: "FullDocumentation", + verboseOutput: false, + }, + enabled: true, + }), + defineServer({ + id: "rust-analyzer", + label: "Rust (rust-analyzer)", + useWorkspaceFolders: true, + languages: ["rust"], + command: "rust-analyzer", + checkCommand: "which rust-analyzer", + installer: installers.apk({ + executable: "rust-analyzer", + packages: ["rust", "cargo", "rust-analyzer"], + }), + initializationOptions: { + cargo: { + allFeatures: true, + buildScripts: { + enable: true, + }, + loadOutDirsFromCheck: true, + }, + procMacro: { + enable: true, + attributes: { + enable: true, + }, + }, + checkOnSave: { + enable: true, + command: "clippy", + extraArgs: ["--no-deps"], + }, + diagnostics: { + enable: true, + experimental: { + enable: true, + }, + }, + inlayHints: { + bindingModeHints: { + enable: false, + }, + chainingHints: { + enable: true, + }, + closingBraceHints: { + enable: true, + minLines: 25, + }, + closureReturnTypeHints: { + enable: "with_block", + }, + lifetimeElisionHints: { + enable: "skip_trivial", + useParameterNames: true, + }, + maxLength: 25, + parameterHints: { + enable: true, + }, + reborrowHints: { + enable: "mutable", + }, + typeHints: { + enable: true, + hideClosureInitialization: false, + hideNamedConstructor: false, + }, + }, + lens: { + enable: true, + debug: { + enable: true, + }, + implementations: { + enable: true, + }, + references: { + adt: { enable: false }, + enumVariant: { enable: false }, + method: { enable: false }, + trait: { enable: false }, + }, + run: { + enable: true, + }, + }, + completion: { + autoimport: { + enable: true, + }, + autoself: { + enable: true, + }, + callable: { + snippets: "fill_arguments", + }, + postfix: { + enable: true, + }, + privateEditable: { + enable: false, + }, + }, + semanticHighlighting: { + doc: { + comment: { + inject: { + enable: true, + }, + }, + }, + operator: { + enable: true, + specialization: { + enable: true, + }, + }, + punctuation: { + enable: false, + separate: { + macro: { + bang: true, + }, + }, + specialization: { + enable: true, + }, + }, + strings: { + enable: true, + }, + }, + hover: { + actions: { + debug: { + enable: true, + }, + enable: true, + gotoTypeDef: { + enable: true, + }, + implementations: { + enable: true, + }, + references: { + enable: true, + }, + run: { + enable: true, + }, + }, + documentation: { + enable: true, + }, + links: { + enable: true, + }, + }, + workspace: { + symbol: { + search: { + kind: "all_symbols", + scope: "workspace", + }, + }, + }, + rustfmt: { + extraArgs: [], + overrideCommand: null, + rangeFormatting: { + enable: false, + }, + }, + }, + enabled: true, + }), +]; + +export const systemsBundle: LspServerBundle = defineBundle({ + id: "builtin-systems", + label: "Systems", + servers: systemsServers, +}); diff --git a/src/cm/lsp/servers/web.ts b/src/cm/lsp/servers/web.ts new file mode 100644 index 000000000..789963116 --- /dev/null +++ b/src/cm/lsp/servers/web.ts @@ -0,0 +1,50 @@ +import { defineBundle, defineServer, installers } from "../providerUtils"; +import type { LspServerBundle, LspServerManifest } from "../types"; + +export const webServers: LspServerManifest[] = [ + defineServer({ + id: "html", + label: "HTML", + languages: ["html", "vue", "svelte"], + command: "vscode-html-language-server", + args: ["--stdio"], + checkCommand: "which vscode-html-language-server", + installer: installers.npm({ + executable: "vscode-html-language-server", + packages: ["vscode-langservers-extracted"], + }), + enabled: true, + }), + defineServer({ + id: "css", + label: "CSS", + languages: ["css", "scss", "less"], + command: "vscode-css-language-server", + args: ["--stdio"], + checkCommand: "which vscode-css-language-server", + installer: installers.npm({ + executable: "vscode-css-language-server", + packages: ["vscode-langservers-extracted"], + }), + enabled: true, + }), + defineServer({ + id: "json", + label: "JSON", + languages: ["json", "jsonc"], + command: "vscode-json-language-server", + args: ["--stdio"], + checkCommand: "which vscode-json-language-server", + installer: installers.npm({ + executable: "vscode-json-language-server", + packages: ["vscode-langservers-extracted"], + }), + enabled: true, + }), +]; + +export const webBundle: LspServerBundle = defineBundle({ + id: "builtin-web", + label: "Web", + servers: webServers, +}); diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index 9eae2072c..946e34f2e 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -95,8 +95,34 @@ export interface BridgeConfig { session?: string; } +export type InstallerKind = + | "apk" + | "npm" + | "pip" + | "cargo" + | "github-release" + | "manual" + | "shell"; + export interface LauncherInstallConfig { - command: string; + kind?: InstallerKind; + command?: string; + updateCommand?: string; + uninstallCommand?: string; + label?: string; + source?: string; + executable?: string; + packages?: string[]; + pipCommand?: string; + npmCommand?: string; + pythonCommand?: string; + global?: boolean; + breakSystemPackages?: boolean; + repo?: string; + assetNames?: Record; + archiveType?: "zip" | "binary"; + extractFile?: string; + binaryPath?: string; } export interface LauncherConfig { @@ -104,6 +130,9 @@ export interface LauncherConfig { args?: string[]; startCommand?: string | string[]; checkCommand?: string; + versionCommand?: string; + updateCommand?: string; + uninstallCommand?: string; install?: LauncherInstallConfig; bridge?: BridgeConfig; } @@ -138,6 +167,54 @@ export interface LanguageResolverContext { file?: AcodeFile; } +export interface LspServerManifest { + id?: string; + label?: string; + enabled?: boolean; + languages?: string[]; + transport?: TransportDescriptor; + initializationOptions?: Record; + clientConfig?: Record | AcodeClientConfig; + startupTimeout?: number; + capabilityOverrides?: Record; + rootUri?: + | ((uri: string, context: unknown) => string | null) + | ((uri: string, context: RootUriContext) => string | null) + | null; + resolveLanguageId?: + | ((context: LanguageResolverContext) => string | null) + | null; + launcher?: LauncherConfig; + useWorkspaceFolders?: boolean; +} + +export interface LspServerBundle { + id: string; + label?: string; + getServers: () => LspServerManifest[]; + getExecutable?: ( + serverId: string, + manifest: LspServerManifest, + ) => string | null | undefined; + checkInstallation?: ( + serverId: string, + manifest: LspServerManifest, + ) => Promise; + installServer?: ( + serverId: string, + manifest: LspServerManifest, + mode: "install" | "update" | "reinstall", + options?: { promptConfirm?: boolean }, + ) => Promise; + uninstallServer?: ( + serverId: string, + manifest: LspServerManifest, + options?: { promptConfirm?: boolean }, + ) => Promise; +} + +export type LspServerProvider = LspServerBundle; + export interface LspServerDefinition { id: string; label: string; @@ -240,6 +317,14 @@ export interface ManagedServerEntry { export type InstallStatus = "present" | "declined" | "failed"; +export interface InstallCheckResult { + status: "present" | "missing" | "failed" | "unknown"; + version?: string | null; + canInstall: boolean; + canUpdate: boolean; + message?: string; +} + /** * Port information from auto-port discovery */ diff --git a/src/lib/acode.js b/src/lib/acode.js index 9ab4c414b..2f2280d37 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -16,9 +16,9 @@ import { removeExternalCommand, executeCommand as runCommand, } from "cm/commandRegistry"; +import { default as lspApi } from "cm/lsp/api"; import lspClientManager from "cm/lsp/clientManager"; import { registerLspFormatter } from "cm/lsp/formatter"; -import serverRegistry from "cm/lsp/serverRegistry"; import { addMode, getModeForPath, @@ -231,14 +231,7 @@ export default class Acode { }; const lspModule = { - registerServer: (definition, options) => - serverRegistry.registerServer(definition, options), - unregisterServer: (id) => serverRegistry.unregisterServer(id), - updateServer: (id, updater) => serverRegistry.updateServer(id, updater), - getServer: (id) => serverRegistry.getServer(id), - listServers: () => serverRegistry.listServers(), - getServersForLanguage: (languageId, options) => - serverRegistry.getServersForLanguage(languageId, options), + ...lspApi, clientManager: { setOptions: (options) => lspClientManager.setOptions(options), getActiveClients: () => lspClientManager.getActiveClients(), diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index fb57388d4..2170f6a10 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -36,6 +36,7 @@ import { registerExternalCommand, removeExternalCommand, } from "cm/commandRegistry"; +import lspApi from "cm/lsp/api"; import lspClientManager from "cm/lsp/clientManager"; import { getLspDiagnostics, @@ -44,7 +45,6 @@ import { lspDiagnosticsUiExtension, } from "cm/lsp/diagnostics"; import { stopManagedServer } from "cm/lsp/serverLauncher"; -import serverRegistry from "cm/lsp/serverRegistry"; // CodeMirror mode management import { getModeForPath, @@ -676,9 +676,9 @@ async function EditorManager($header, $body) { .trim() .toLowerCase(); if (!key) continue; - const existing = serverRegistry.getServer(key); + const existing = lspApi.servers.get(key); if (existing) { - serverRegistry.updateServer(key, (current) => { + lspApi.servers.update(key, (current) => { const next = { ...current }; if (Array.isArray(config.languages) && config.languages.length) { next.languages = config.languages.map((lang) => @@ -729,7 +729,7 @@ async function EditorManager($header, $body) { typeof config.transport === "object" ) { try { - serverRegistry.registerServer({ + lspApi.upsert({ id: key, label: config.label || key, languages: config.languages, @@ -740,7 +740,7 @@ async function EditorManager($header, $body) { launcher: config.launcher, enabled: config.enabled !== false, }); - serverRegistry.updateServer(key, (current) => { + lspApi.servers.update(key, (current) => { if (current.transport?.protocols) { const updated = { ...current }; updated.transport = { ...current.transport }; diff --git a/src/settings/lspConfigUtils.js b/src/settings/lspConfigUtils.js new file mode 100644 index 000000000..3444f43ec --- /dev/null +++ b/src/settings/lspConfigUtils.js @@ -0,0 +1,136 @@ +import lspApi from "cm/lsp/api"; +import appSettings from "lib/settings"; + +function cloneLspSettings() { + return JSON.parse(JSON.stringify(appSettings.value?.lsp || {})); +} + +export function normalizeServerId(id) { + return String(id || "") + .trim() + .toLowerCase(); +} + +export function normalizeLanguages(value) { + if (Array.isArray(value)) { + return value + .map((lang) => + String(lang || "") + .trim() + .toLowerCase(), + ) + .filter(Boolean); + } + + return String(value || "") + .split(",") + .map((lang) => lang.trim().toLowerCase()) + .filter(Boolean); +} + +export function getServerOverride(id) { + return appSettings.value?.lsp?.servers?.[normalizeServerId(id)] || {}; +} + +export function isCustomServer(id) { + return getServerOverride(id).custom === true; +} + +export async function updateServerConfig(serverId, partial) { + const key = normalizeServerId(serverId); + if (!key) { + throw new Error("Server id is required"); + } + + const current = cloneLspSettings(); + current.servers = current.servers || {}; + const nextServer = { + ...(current.servers[key] || {}), + }; + + Object.entries(partial || {}).forEach(([entryKey, value]) => { + if (value === undefined) { + delete nextServer[entryKey]; + return; + } + nextServer[entryKey] = value; + }); + + if (Object.keys(nextServer).length) { + current.servers[key] = nextServer; + } else { + delete current.servers[key]; + } + + await appSettings.update({ lsp: current }, false); +} + +export async function upsertCustomServer(serverId, config) { + const key = normalizeServerId(serverId); + if (!key) { + throw new Error("Server id is required"); + } + + const existingServer = lspApi.servers.get(key); + if (existingServer && getServerOverride(key).custom !== true) { + throw new Error("A built-in server already uses this id"); + } + + const languages = normalizeLanguages(config.languages); + if (!languages.length) { + throw new Error("At least one language id is required"); + } + + const current = cloneLspSettings(); + current.servers = current.servers || {}; + const existing = current.servers[key] || {}; + const nextConfig = { + ...existing, + ...config, + custom: true, + label: config.label || existing.label || key, + languages, + transport: config.transport || existing.transport || { kind: "websocket" }, + launcher: config.launcher || existing.launcher, + enabled: config.enabled !== false, + }; + + const installKind = nextConfig.launcher?.install?.kind; + if (installKind && installKind !== "shell") { + const providedExecutable = + nextConfig.launcher.install.binaryPath || + nextConfig.launcher.install.executable; + if (!providedExecutable) { + throw new Error( + "Managed installers must declare the executable path or command they provide", + ); + } + } + + current.servers[key] = nextConfig; + await appSettings.update({ lsp: current }, false); + + const definition = { + id: key, + label: nextConfig.label, + languages, + transport: nextConfig.transport, + launcher: nextConfig.launcher, + clientConfig: nextConfig.clientConfig, + initializationOptions: nextConfig.initializationOptions, + startupTimeout: nextConfig.startupTimeout, + enabled: nextConfig.enabled !== false, + }; + + lspApi.upsert(definition); + return key; +} + +export async function removeCustomServer(serverId) { + const key = normalizeServerId(serverId); + const current = cloneLspSettings(); + current.servers = current.servers || {}; + delete current.servers[key]; + await appSettings.update({ lsp: current }, false); + lspApi.servers.unregister(key); +} diff --git a/src/settings/lspServerDetail.js b/src/settings/lspServerDetail.js index 05c161863..53fb6fd80 100644 --- a/src/settings/lspServerDetail.js +++ b/src/settings/lspServerDetail.js @@ -1,24 +1,85 @@ -import serverRegistry from "cm/lsp/serverRegistry"; +import lspApi from "cm/lsp/api"; +import { + checkServerInstallation, + getInstallCommand, + getUninstallCommand, + installServer, + stopManagedServer, + uninstallServer, +} from "cm/lsp/serverLauncher"; import settingsPage from "components/settingsPage"; import toast from "components/toast"; import alert from "dialogs/alert"; +import confirm from "dialogs/confirm"; import prompt from "dialogs/prompt"; -import appSettings from "lib/settings"; - -/** - * Get the current override settings for a server - * @param {string} id Server ID - * @returns {object} Override settings object - */ -function getServerOverride(id) { - return appSettings.value?.lsp?.servers?.[id] || {}; +import { getServerOverride, updateServerConfig } from "./lspConfigUtils"; + +const FEATURE_ITEMS = [ + [ + "ext_hover", + "hover", + "Hover Information", + "Show type information and documentation on hover", + ], + [ + "ext_completion", + "completion", + "Code Completion", + "Enable autocomplete suggestions from the server", + ], + [ + "ext_signature", + "signature", + "Signature Help", + "Show function parameter hints while typing", + ], + [ + "ext_diagnostics", + "diagnostics", + "Diagnostics", + "Show errors and warnings from the language server", + ], + [ + "ext_inlayHints", + "inlayHints", + "Inlay Hints", + "Show inline type hints in the editor", + ], + [ + "ext_documentHighlights", + "documentHighlights", + "Document Highlights", + "Highlight all occurrences of the word under cursor", + ], + [ + "ext_formatting", + "formatting", + "Formatting", + "Enable code formatting from the language server", + ], +]; + +function clone(value) { + if (!value || typeof value !== "object") return value; + return JSON.parse(JSON.stringify(value)); +} + +function mergeLauncher(base, patch) { + if (!base && !patch) return undefined; + return { + ...(base || {}), + ...(patch || {}), + bridge: { + ...(base?.bridge || {}), + ...(patch?.bridge || {}), + }, + install: { + ...(base?.install || {}), + ...(patch?.install || {}), + }, + }; } -/** - * Merge server definition with user overrides - * @param {object} server Server definition from registry - * @returns {object} Merged server configuration - */ function getMergedConfig(server) { const override = getServerOverride(server.id); return { @@ -37,205 +98,366 @@ function getMergedConfig(server) { ...(override.clientConfig?.builtinExtensions || {}), }, }, + launcher: mergeLauncher(server.launcher, override.launcher), }; } -/** - * Update LSP server configuration in app settings - * @param {string} serverId Server ID - * @param {object} partial Partial configuration to update - */ -async function updateServerConfig(serverId, partial) { - const current = JSON.parse(JSON.stringify(appSettings.value.lsp || {})); - current.servers = current.servers || {}; - const nextServer = { - ...(current.servers[serverId] || {}), - }; - - Object.entries(partial || {}).forEach(([key, value]) => { - if (value === undefined) { - delete nextServer[key]; - return; - } - nextServer[key] = value; - }); +function formatInstallStatus(result) { + switch (result?.status) { + case "present": + return result.version ? `Installed (${result.version})` : "Installed"; + case "missing": + return "Not installed"; + case "failed": + return "Check failed"; + default: + return "Unknown"; + } +} - if (Object.keys(nextServer).length) { - current.servers[serverId] = nextServer; - } else { - delete current.servers[serverId]; +function formatValue(value) { + if (value === undefined || value === null || value === "") return ""; + let text = String(value); + if (text.includes("\n")) { + [text] = text.split("\n"); } + if (text.length > 47) { + text = `${text.slice(0, 47)}...`; + } + return text; +} - await appSettings.update({ lsp: current }, false); +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = String(text || ""); + return div.innerHTML; } -/** - * LSP Server detail settings page - * @param {string} serverId - The server ID to show settings for - * @returns {import('components/settingsPage').SettingsPage} - */ -export default function lspServerDetail(serverId) { - const server = serverRegistry.getServer(serverId); - if (!server) { - toast("Server not found"); - return null; +function updateItemDisplay($list, itemsByKey, key, value, extras = {}) { + const item = itemsByKey.get(key); + if (!item) return; + + if ("value" in extras) { + item.value = extras.value; + } else if (value !== undefined) { + item.value = value; } - const merged = getMergedConfig(server); - const title = server.label || server.id; + if ("info" in extras) { + item.info = extras.info; + } - const items = []; - const builtinExts = merged.clientConfig?.builtinExtensions || {}; + if ("checkbox" in extras) { + item.checkbox = extras.checkbox; + } - // Server enable/disable - items.push({ - key: "enabled", - text: "Enabled", - checkbox: merged.enabled, - info: "Enable or disable this language server", - }); + const $item = $list?.querySelector?.(`[data-key="${key}"]`); + if (!$item) return; - // Feature toggles - items.push({ - key: "ext_hover", - text: "Hover Information", - checkbox: builtinExts.hover !== false, - info: "Show type information and documentation on hover", - }); + const $value = $item.querySelector(".value"); + if ($value) { + $value.textContent = formatValue(item.value); + } - items.push({ - key: "ext_completion", - text: "Code Completion", - checkbox: builtinExts.completion !== false, - info: "Enable autocomplete suggestions from the server", - }); + const $checkbox = $item.querySelector(".input-checkbox"); + if ($checkbox && typeof item.checkbox === "boolean") { + $checkbox.checked = item.checkbox; + } +} - items.push({ - key: "ext_signature", - text: "Signature Help", - checkbox: builtinExts.signature !== false, - info: "Show function parameter hints while typing", - }); +async function buildSnapshot(serverId) { + const liveServer = lspApi.servers.get(serverId); + if (!liveServer) return null; + + const merged = getMergedConfig(liveServer); + const override = getServerOverride(serverId); + const installResult = await checkServerInstallation(merged).catch( + (error) => ({ + status: "failed", + version: null, + canInstall: true, + canUpdate: true, + message: error instanceof Error ? error.message : String(error), + }), + ); - items.push({ - key: "ext_diagnostics", - text: "Diagnostics", - checkbox: builtinExts.diagnostics !== false, - info: "Show errors and warnings from the language server", - }); + return { + liveServer, + merged, + override, + installResult, + builtinExts: merged.clientConfig?.builtinExtensions || {}, + installCommand: getInstallCommand(merged, "install"), + updateCommand: getInstallCommand(merged, "update"), + uninstallCommand: getUninstallCommand(merged), + }; +} - items.push({ - key: "ext_inlayHints", - text: "Inlay Hints", - checkbox: builtinExts.inlayHints !== false, - info: "Show inline type hints in the editor", - }); +function createItems(snapshot) { + const items = [ + { + key: "enabled", + text: "Enabled", + checkbox: snapshot.merged.enabled !== false, + info: "Enable or disable this language server", + }, + { + key: "install_status", + text: "Installed", + value: formatInstallStatus(snapshot.installResult), + info: + snapshot.installResult.message || + "Current installation state for this language server", + }, + { + key: "install_server", + text: "Install / Repair", + info: "Install or repair this language server", + }, + { + key: "update_server", + text: "Update Server", + info: "Update this language server if an update flow exists", + }, + { + key: "uninstall_server", + text: "Uninstall Server", + info: "Remove installed packages or binaries for this server", + }, + { + key: "startup_timeout", + text: "Startup Timeout", + value: + typeof snapshot.merged.startupTimeout === "number" + ? `${snapshot.merged.startupTimeout} ms` + : "Default (5000 ms)", + info: "Configure how long Acode waits for the server to start", + }, + { + key: "edit_init_options", + text: "Edit Initialization Options", + value: Object.keys(snapshot.override.initializationOptions || {}).length + ? "Configured" + : "Empty", + info: "Edit initialization options as JSON", + }, + { + key: "view_init_options", + text: "View Initialization Options", + info: "View the effective initialization options as JSON", + }, + ]; - items.push({ - key: "ext_documentHighlights", - text: "Document Highlights", - checkbox: builtinExts.documentHighlights !== false, - info: "Highlight all occurrences of the word under cursor", + FEATURE_ITEMS.forEach(([key, extKey, text, info]) => { + items.push({ + key, + text, + checkbox: snapshot.builtinExts[extKey] !== false, + info, + }); }); - items.push({ - key: "ext_formatting", - text: "Formatting", - checkbox: builtinExts.formatting !== false, - info: "Enable code formatting from the language server", - }); + return items; +} - if (server.launcher?.install?.command) { - items.push({ - key: "view_install", - text: "View Install Command", - info: "View the command to install this language server", +async function refreshVisibleState($list, itemsByKey, serverId) { + if (!$list) return; + + const snapshot = await buildSnapshot(serverId); + if (!snapshot) return; + + updateItemDisplay($list, itemsByKey, "enabled", undefined, { + checkbox: snapshot.merged.enabled !== false, + }); + updateItemDisplay( + $list, + itemsByKey, + "install_status", + formatInstallStatus(snapshot.installResult), + { + info: + snapshot.installResult.message || + "Current installation state for this language server", + }, + ); + updateItemDisplay($list, itemsByKey, "install_server", ""); + updateItemDisplay($list, itemsByKey, "update_server", ""); + updateItemDisplay($list, itemsByKey, "uninstall_server", ""); + updateItemDisplay( + $list, + itemsByKey, + "startup_timeout", + typeof snapshot.merged.startupTimeout === "number" + ? `${snapshot.merged.startupTimeout} ms` + : "Default (5000 ms)", + ); + updateItemDisplay( + $list, + itemsByKey, + "edit_init_options", + Object.keys(snapshot.override.initializationOptions || {}).length + ? "Configured" + : "Empty", + ); + + FEATURE_ITEMS.forEach(([key, extKey]) => { + updateItemDisplay($list, itemsByKey, key, undefined, { + checkbox: snapshot.builtinExts[extKey] !== false, }); + }); +} + +async function persistEnabled(serverId, value) { + await updateServerConfig(serverId, { enabled: value }); + lspApi.servers.update(serverId, (current) => ({ + ...current, + enabled: value, + })); +} + +async function persistClientConfig(serverId, clientConfig) { + await updateServerConfig(serverId, { clientConfig }); + lspApi.servers.update(serverId, (current) => ({ + ...current, + clientConfig: { + ...(current.clientConfig || {}), + ...clientConfig, + }, + })); +} + +async function persistStartupTimeout(serverId, timeout) { + await updateServerConfig(serverId, { startupTimeout: timeout }); + lspApi.servers.update(serverId, (current) => ({ + ...current, + startupTimeout: timeout, + })); +} + +async function persistInitOptions(serverId, value) { + await updateServerConfig(serverId, { initializationOptions: value }); + lspApi.servers.update(serverId, (current) => ({ + ...current, + initializationOptions: value, + })); +} + +export default function lspServerDetail(serverId) { + const initialServer = lspApi.servers.get(serverId); + if (!initialServer) { + toast("Server not found"); + return null; } - // Advanced options - items.push({ - key: "view_init_options", - text: "View Initialization Options", - info: "View the server initialization options as JSON", - }); + const initialSnapshot = { + liveServer: initialServer, + merged: getMergedConfig(initialServer), + override: getServerOverride(serverId), + installResult: { + status: "unknown", + version: null, + canInstall: true, + canUpdate: true, + message: "Checking installation status...", + }, + builtinExts: + getMergedConfig(initialServer).clientConfig?.builtinExtensions || {}, + installCommand: getInstallCommand( + getMergedConfig(initialServer), + "install", + ), + updateCommand: getInstallCommand(getMergedConfig(initialServer), "update"), + uninstallCommand: getUninstallCommand(getMergedConfig(initialServer)), + }; - items.push({ - key: "edit_startup_timeout", - text: "Startup Timeout", - info: - typeof merged.startupTimeout === "number" - ? `${merged.startupTimeout} ms` - : "Default (5000 ms)", - }); + const items = createItems(initialSnapshot); + const itemsByKey = new Map(items.map((item) => [item.key, item])); + const page = settingsPage( + initialServer.label || initialServer.id, + items, + callback, + undefined, + { + preserveOrder: true, + }, + ); - items.push({ - key: "edit_init_options", - text: "Edit Initialization Options", - info: "Edit custom initialization options (JSON)", - }); + const baseShow = page.show.bind(page); - return settingsPage(title, items, callback, undefined, { - preserveOrder: true, - }); + return { + ...page, + show(goTo) { + baseShow(goTo); + const $list = document.querySelector("#settings .main.list"); + refreshVisibleState($list, itemsByKey, serverId).catch(console.error); + }, + }; async function callback(key, value) { - const override = getServerOverride(serverId); + const $list = this?.parentElement; + const snapshot = await buildSnapshot(serverId); + if (!snapshot) { + toast("Server not found"); + return; + } switch (key) { case "enabled": - await updateServerConfig(serverId, { enabled: value }); - // Update the registry so client manager picks it up - serverRegistry.updateServer(serverId, (current) => ({ - ...current, - enabled: value, - })); + await persistEnabled(serverId, value); + if (!value) { + stopManagedServer(serverId); + } toast(value ? "Server enabled" : "Server disabled"); break; - case "ext_hover": - case "ext_completion": - case "ext_signature": - case "ext_diagnostics": - case "ext_inlayHints": - case "ext_documentHighlights": - case "ext_formatting": { - const extKey = key.replace("ext_", ""); - const currentClientConfig = override.clientConfig || {}; - const currentBuiltins = currentClientConfig.builtinExtensions || {}; - - await updateServerConfig(serverId, { - clientConfig: { - ...currentClientConfig, - builtinExtensions: { - ...currentBuiltins, - [extKey]: value, - }, - }, - }); - toast(`${extKey} ${value ? "enabled" : "disabled"}`); + case "install_status": { + const result = await checkServerInstallation(snapshot.merged); + const lines = [ + `Status: ${formatInstallStatus(result)}`, + result.message ? `Details: ${result.message}` : null, + ].filter(Boolean); + alert("Installation Status", lines.join("
")); break; } - case "view_install": - if (server.launcher?.install?.command) { - alert("Install Command", server.launcher.install.command); + case "install_server": + if (!snapshot.installCommand) { + toast("Install command not available"); + break; } + await installServer(snapshot.merged, "install"); break; - case "view_init_options": { - const initOpts = merged.initializationOptions || {}; - const json = JSON.stringify(initOpts, null, 2); - alert( - "Initialization Options", - `
${escapeHtml(json)}
`, - ); + case "update_server": + if (!snapshot.updateCommand) { + toast("Update command not available"); + break; + } + await installServer(snapshot.merged, "update"); break; - } - case "edit_startup_timeout": { + case "uninstall_server": + if (!snapshot.uninstallCommand) { + toast("Uninstall command not available"); + break; + } + if ( + !(await confirm( + "Uninstall Server", + `Remove installed files for ${snapshot.liveServer.label || serverId}?`, + )) + ) { + break; + } + await uninstallServer(snapshot.merged, { promptConfirm: false }); + toast("Server uninstalled"); + break; + + case "startup_timeout": { const currentTimeout = - override.startupTimeout ?? server.startupTimeout ?? 5000; + snapshot.override.startupTimeout ?? + snapshot.liveServer.startupTimeout ?? + 5000; const result = await prompt( "Startup Timeout (milliseconds)", String(currentTimeout), @@ -248,72 +470,91 @@ export default function lspServerDetail(serverId) { }, ); - if (result !== null) { - const timeout = Number.parseInt(String(result), 10); - if (!Number.isFinite(timeout) || timeout < 1000) { - toast("Invalid timeout value"); - break; - } - - await updateServerConfig(serverId, { - startupTimeout: timeout, - }); - serverRegistry.updateServer(serverId, (current) => ({ - ...current, - startupTimeout: timeout, - })); - toast(`Startup timeout set to ${timeout} ms`); + if (result === null) { + break; } + + const timeout = Number.parseInt(String(result), 10); + if (!Number.isFinite(timeout) || timeout < 1000) { + toast("Invalid timeout value"); + break; + } + + await persistStartupTimeout(serverId, timeout); + toast(`Startup timeout set to ${timeout} ms`); break; } case "edit_init_options": { - const currentInitOpts = override.initializationOptions || {}; - const currentJson = JSON.stringify(currentInitOpts, null, 2); - - try { - const result = await prompt( - "Initialization Options (JSON)", - currentJson || "{}", - "textarea", - { - test: (val) => { - try { - JSON.parse(val); - return true; - } catch { - return false; - } - }, + const currentJson = JSON.stringify( + snapshot.override.initializationOptions || {}, + null, + 2, + ); + const result = await prompt( + "Initialization Options (JSON)", + currentJson || "{}", + "textarea", + { + test: (val) => { + try { + JSON.parse(val); + return true; + } catch { + return false; + } }, - ); - - if (result !== null) { - const parsed = JSON.parse(result); - await updateServerConfig(serverId, { - initializationOptions: parsed, - }); - toast("Initialization options updated"); - } - } catch (error) { - toast("Invalid JSON"); + }, + ); + + if (result === null) { + break; } + + await persistInitOptions(serverId, JSON.parse(result)); + toast("Initialization options updated"); + break; + } + + case "view_init_options": { + const json = JSON.stringify( + snapshot.merged.initializationOptions || {}, + null, + 2, + ); + alert( + "Initialization Options", + `
${escapeHtml(json)}
`, + ); + break; + } + + case "ext_hover": + case "ext_completion": + case "ext_signature": + case "ext_diagnostics": + case "ext_inlayHints": + case "ext_documentHighlights": + case "ext_formatting": { + const extKey = key.replace("ext_", ""); + const currentClientConfig = clone(snapshot.override.clientConfig || {}); + const currentBuiltins = currentClientConfig.builtinExtensions || {}; + + await persistClientConfig(serverId, { + ...currentClientConfig, + builtinExtensions: { + ...currentBuiltins, + [extKey]: value, + }, + }); + toast(`${extKey} ${value ? "enabled" : "disabled"}`); break; } default: break; } - } -} -/** - * Escape HTML entities - * @param {string} text - * @returns {string} - */ -function escapeHtml(text) { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; + await refreshVisibleState($list, itemsByKey, serverId); + } } diff --git a/src/settings/lspSettings.js b/src/settings/lspSettings.js index d542ad7e8..58fcb99af 100644 --- a/src/settings/lspSettings.js +++ b/src/settings/lspSettings.js @@ -1,15 +1,103 @@ import serverRegistry from "cm/lsp/serverRegistry"; import settingsPage from "components/settingsPage"; -import appSettings from "lib/settings"; +import toast from "components/toast"; +import prompt from "dialogs/prompt"; +import select from "dialogs/select"; +import { + getServerOverride, + normalizeLanguages, + normalizeServerId, + upsertCustomServer, +} from "./lspConfigUtils"; import lspServerDetail from "./lspServerDetail"; -/** - * Get the current override settings for a server - * @param {string} id Server ID - * @returns {object} Override settings object - */ -function getServerOverride(id) { - return appSettings.value?.lsp?.servers?.[id] || {}; +function parseArgsInput(value) { + const normalized = String(value || "").trim(); + if (!normalized) return []; + + const parsed = JSON.parse(normalized); + if (!Array.isArray(parsed)) { + throw new Error("Arguments must be a JSON array"); + } + return parsed.map((entry) => String(entry)); +} + +function normalizePackages(value) { + return String(value || "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +const INSTALL_METHODS = [ + { value: "manual", text: "Manual binary" }, + { value: "apk", text: "APK package" }, + { value: "npm", text: "npm package" }, + { value: "pip", text: "pip package" }, + { value: "cargo", text: "cargo crate" }, + { value: "shell", text: "Custom shell" }, +]; + +async function promptInstaller(binaryCommand) { + const method = await select("Install Method", INSTALL_METHODS); + if (!method) return null; + + switch (method) { + case "manual": { + const binaryPath = await prompt( + "Binary Path (optional)", + String(binaryCommand || "").includes("/") ? String(binaryCommand) : "", + "text", + ); + if (binaryPath === null) return null; + return { + kind: "manual", + source: "manual", + executable: String(binaryCommand || "").trim() || undefined, + binaryPath: String(binaryPath || "").trim() || undefined, + }; + } + case "apk": + case "npm": + case "pip": + case "cargo": { + const packagesInput = await prompt( + `${method.toUpperCase()} Packages (comma separated)`, + "", + "text", + ); + if (packagesInput === null) return null; + const packages = normalizePackages(packagesInput); + if (!packages.length) { + throw new Error("At least one package is required"); + } + return { + kind: method, + source: method, + executable: String(binaryCommand || "").trim() || undefined, + packages, + }; + } + case "shell": { + const installCommand = await prompt("Install Command", "", "textarea"); + if (installCommand === null) return null; + const updateCommand = await prompt( + "Update Command (optional)", + String(installCommand || ""), + "textarea", + ); + if (updateCommand === null) return null; + return { + kind: "shell", + source: "custom", + executable: String(binaryCommand || "").trim() || undefined, + command: String(installCommand || "").trim() || undefined, + updateCommand: String(updateCommand || "").trim() || undefined, + }; + } + default: + return null; + } } /** @@ -20,7 +108,6 @@ export default function lspSettings() { const title = strings?.lsp_settings || "Language Servers"; const servers = serverRegistry.listServers(); - // Sort: enabled servers first, then alphabetically const sortedServers = servers.sort((a, b) => { const aEnabled = getServerOverride(a.id).enabled ?? a.enabled; const bEnabled = getServerOverride(b.id).enabled ?? b.enabled; @@ -31,14 +118,23 @@ export default function lspSettings() { return a.label.localeCompare(b.label); }); - const items = []; + const items = [ + { + key: "add_custom_server", + text: "Add Custom Server", + info: "Register a user-defined language server with install, update, and launch commands", + index: 0, + }, + ]; for (const server of sortedServers) { - // Languages info + const source = server.launcher?.install?.source + ? ` • ${server.launcher.install.source}` + : ""; const languagesList = Array.isArray(server.languages) && server.languages.length - ? server.languages.join(", ") - : ""; + ? `${server.languages.join(", ")}${source}` + : source.slice(3); items.push({ key: `server:${server.id}`, @@ -47,14 +143,100 @@ export default function lspSettings() { }); } - // Add note items.push({ - note: "Language servers provide IDE features like autocomplete, diagnostics, and hover information. Enable a server for the languages you work with. Make sure the terminal is installed and the server is installed in the proot environment.", + note: "Language servers provide IDE features like autocomplete, diagnostics, and hover information. You can now install, update, and define custom servers from these settings. Managed installers still run inside the terminal/proot environment.", + }); + + return settingsPage(title, items, callback, undefined, { + preserveOrder: true, }); - return settingsPage(title, items, callback); + async function callback(key) { + if (key === "add_custom_server") { + try { + const idInput = await prompt("Server ID", "", "text"); + if (idInput === null) return; + + const serverId = normalizeServerId(idInput); + if (!serverId) { + toast("Server id is required"); + return; + } + + const label = await prompt("Server Label", serverId, "text"); + if (label === null) return; + + const languageInput = await prompt( + "Language IDs (comma separated)", + "", + "text", + ); + if (languageInput === null) return; + const languages = normalizeLanguages(languageInput); + if (!languages.length) { + toast("At least one language id is required"); + return; + } + + const binaryCommand = await prompt("Binary Command", "", "text"); + if (binaryCommand === null) return; + if (!String(binaryCommand).trim()) { + toast("Binary command is required"); + return; + } + + const argsInput = await prompt( + "Binary Args (JSON array)", + "[]", + "textarea", + { + test: (value) => { + try { + parseArgsInput(value); + return true; + } catch { + return false; + } + }, + }, + ); + if (argsInput === null) return; + + const installer = await promptInstaller(binaryCommand); + if (installer === null) return; + + const checkCommand = await prompt( + "Check Command (optional override)", + "", + "text", + ); + if (checkCommand === null) return; + + await upsertCustomServer(serverId, { + label: String(label || "").trim() || serverId, + languages, + transport: { kind: "websocket" }, + launcher: { + bridge: { + kind: "axs", + command: String(binaryCommand).trim(), + args: parseArgsInput(argsInput), + }, + checkCommand: String(checkCommand || "").trim() || undefined, + install: installer, + }, + enabled: true, + }); + + toast("Custom server added"); + const detailPage = lspServerDetail(serverId); + detailPage?.show(); + } catch (error) { + toast(error instanceof Error ? error.message : "Failed to add server"); + } + return; + } - function callback(key) { if (key.startsWith("server:")) { const id = key.split(":")[1]; const detailPage = lspServerDetail(id);