ludic/tools/editors/vscode/src/extension.js
Orkuncakilkaya e175619543 refactor(cli)!: split the contributor tool out of the ludic CLI
`ludic help` ended with a section titled "contributing to the toolchain itself",
listing bootstrap, reseed, docs-gen and release tasks. None of that is available
to someone who installed the language — those tasks need the repository — so the
shipped tool was advertising work its user cannot do, in a namespace they have to
read past to find `new` and `run`.

The tasks move to a second program, dev.ludic -> bin/ludic-dev, built from a
checkout and excluded from every release artifact. `ludic` keeps the project and
package commands and nothing else; `ludic dev …` now explains where the tasks
went instead of failing as an unknown command.

What this shook out: the two programs share prelude/build/project/pkg, so the
helpers each had accreted in whichever file first needed them — cc(),
ensure_ludicc, the string functions, title_case, cmd_version — moved to where
both can see them. The argument-shift indirection added for the `dev` namespace
is gone with the namespace, so commands read argv directly again.

`ludic-dev test` asserts the split rather than trusting it: the staged install
must build a project, and `ludic dev build` there must fail while naming
ludic-dev. install.sh keeps building older tags, whose bootstrap goes through
main.ludic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:15:12 +03:00

173 lines
5.9 KiB
JavaScript

/* ============================================================================
* The VS Code half of Ludic support.
*
* There is deliberately very little here. Highlighting comes from the TextMate
* grammar in syntaxes/ (shared, unmodified, with every other editor), and
* everything else — completion, hover, diagnostics, formatting, symbols —
* comes from `ludic-lsp`, the same binary JetBrains and Neovim talk to. This
* file's whole job is finding that binary and keeping it alive.
* ==========================================================================*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const vscode = require('vscode');
const { LanguageClient, TransportKind } = require('vscode-languageclient/node');
let client = null;
let output = null;
/** Where the language server might be, in the order we should look. */
function resolveServer(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('server.path');
if (configured) return expand(configured, folders);
const names = ['ludic-lsp', 'ludic-lsp.exe'];
for (const folder of folders) {
const root = folder.uri.fsPath;
for (const rel of ['bin', 'build', 'tools/build', '.']) {
for (const name of names) {
const p = path.join(root, rel, name);
if (isExecutable(p)) return p;
}
}
}
// Fall back to PATH; spawning by bare name lets the OS resolve it.
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
for (const name of names) {
const p = path.join(dir, name);
if (isExecutable(p)) return p;
}
}
// The install root, checked last: an editor launched from Finder or a desktop
// launcher does not inherit the PATH the installer wrote to the shell profile,
// so a perfectly good ~/.ludic install would otherwise look missing.
for (const name of names) {
const p = path.join(installRoot(), 'bin', name);
if (isExecutable(p)) return p;
}
return null;
}
/** The toolchain install root: $LUDIC_HOME, else ~/.ludic (what install.sh uses). */
function installRoot() {
return process.env.LUDIC_HOME || path.join(os.homedir(), '.ludic');
}
function resolveCompiler(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath');
if (configured) return expand(configured, folders);
for (const folder of folders) {
for (const rel of ['bin', 'build']) {
const p = path.join(folder.uri.fsPath, rel, 'ludicc');
if (isExecutable(p)) return p;
}
}
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
const p = path.join(dir, 'ludicc');
if (isExecutable(p)) return p;
}
const installed = path.join(installRoot(), 'bin', 'ludicc');
if (isExecutable(installed)) return installed;
return '';
}
/** A configured path may use `~` and `${workspaceFolder}`, so a project can point
* at a sibling checkout without hard-coding one machine's home directory. */
function expand(p, folders) {
const root = folders && folders.length ? folders[0].uri.fsPath : '';
const withRoot = p.replace(/\$\{workspaceFolder\}/g, root);
return withRoot.startsWith('~') ? path.join(os.homedir(), withRoot.slice(1)) : withRoot;
}
function isExecutable(p) {
try {
fs.accessSync(p, fs.constants.X_OK);
return fs.statSync(p).isFile();
} catch {
return false;
}
}
async function start(context) {
const cfg = vscode.workspace.getConfiguration('ludic');
if (!cfg.get('server.enabled')) return;
const folders = vscode.workspace.workspaceFolders || [];
const server = resolveServer(folders);
if (!server) {
// Not an error worth a modal: the grammar still highlights, and plenty of
// people open a .ludic file without having built the toolchain.
output.appendLine(
'ludic-lsp not found. Install the toolchain (curl -fsSL https://ludic.dev/install.sh | sh), build it from a checkout with `bin/ludic-dev tools`, or set "ludic.server.path". ' +
'Syntax highlighting works without it; completion and diagnostics do not.'
);
return;
}
output.appendLine(`starting ${server}`);
const serverOptions = {
run: { command: server, args: ['--stdio'], transport: TransportKind.stdio },
debug: { command: server, args: ['--stdio'], transport: TransportKind.stdio }
};
const clientOptions = {
// Markdown is included on purpose: ```ludic fences in a .md get the same
// diagnostics, hover and semantic highlighting as a real source file.
documentSelector: [
{ scheme: 'file', language: 'ludic' },
{ scheme: 'file', language: 'markdown' }
],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ludic'),
configurationSection: 'ludic'
},
outputChannel: output,
initializationOptions: {
compilerPath: resolveCompiler(folders),
compilerDiagnostics: cfg.get('compilerDiagnostics'),
indentSize: cfg.get('indentSize')
}
};
client = new LanguageClient('ludic', 'Ludic Language Server', serverOptions, clientOptions);
await client.start();
context.subscriptions.push(client);
}
async function stop() {
if (!client) return;
const c = client;
client = null;
await c.stop();
}
async function activate(context) {
output = vscode.window.createOutputChannel('Ludic');
context.subscriptions.push(output);
context.subscriptions.push(
vscode.commands.registerCommand('ludic.restartServer', async () => {
await stop();
await start(context);
vscode.window.setStatusBarMessage('Ludic: language server restarted', 3000);
}),
vscode.commands.registerCommand('ludic.showOutput', () => output.show())
);
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('ludic.server')) {
await stop();
await start(context);
}
})
);
await start(context);
}
function deactivate() {
return stop();
}
module.exports = { activate, deactivate };