Skip to content

Commit a3da145

Browse files
committed
Auto-discover driver configs + workspace cross-index (references, defs, symbols)
Two features, one shared foundation: 1. Driver config auto-discovery. Mudlib checkouts usually carry their config at config.<name> (root) or etc/config.<name> (the fluffos testsuite uses etc/config.test). lpcc.findDriverConfigs() scans both spots and validates by CONTENT ('mudlib directory' + 'master file' lines; values strip trailing '# comment #' chatter), returning {configFile, runCwd, mudlibRoot} -- runCwd is the checkout root (a relative 'mudlib directory' is driver-cwd-relative), mudlibRoot the resolved mudlib dir. Both the extension and the LSP server chain it after the explicit setting and the .lpc/config scaffold, so a plain mudlib checkout gets compiler diagnostics with zero settings; VS Code additionally offers ONCE per workspace to persist the discovered config into workspace settings, and lpc.initConfig offers the discovered config before scaffolding. Fixed along the way: debugLogPath() resolved a mudlib-relative configFile against the process cwd (same bug as includeDirs earlier) and ignored trailing config comments. 2. Workspace cross-index (server/indexer.js, vscode-free + dependency- injected). Definitions indexed eagerly (tokenize+outline of every .lpc/.c/.h under each workspace folder at 'initialized', re-indexed from live buffers); references resolved on demand (\bword\b content prefilter, then tokenize only matching files) so memory stays flat. Serves: cross-file go-to-definition (falls back from the local outline), workspace-wide find-references (live buffers beat disk), workspace/symbol (Ctrl+T), and completion items for symbols from never-opened files. Caps: 20k files / 1MB each; skips dotdirs, node_modules, .lpc/. Verification (all green): - test.mjs: discovery unit fixtures (content validation, decoys, comment stripping) + indexer units (build/update/live-buffer override; #define-body mentions deliberately don't count -- one directive token). - test-lsp.mjs (22 checks): cross-file definition into a never-opened file, 3-location cross-file references, workspace/symbol, workspace completion, and lpcc diagnostics through an AUTO-DISCOVERED etc/config.* with no configFile setting (second workspace folder). - test-nvim.lua (14 checks, real client): ASSERT_EQ resolving into include/tests.h via the 761-file testsuite index; corpus-scale workspace/symbol (100+ do_tests hits). - Full corpus sweep: 762 files, all three phases, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXqX4Kf55ArA39suS9cdCv
1 parent 37e7904 commit a3da145

10 files changed

Lines changed: 647 additions & 28 deletions

File tree

AGENTS.md

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,20 @@ Don't "fix" that validation.
109109

110110
`extension/config.js` resolves lpcc settings with defaults:
111111
`lpc.lpcc.path` unset → bundled `bin/lpcc.js` (wasm, run via node);
112-
`lpc.lpcc.configFile` unset → `<workspace>/.lpc/config`. The
112+
`lpc.lpcc.configFile` unset → `<workspace>/.lpc/config`, then
113+
AUTO-DISCOVERY (`lpcc.findDriverConfigs`): mudlib checkouts usually carry
114+
their driver config at `config.<name>` (root) or `etc/config.<name>` --
115+
naming is a convention, so CONTENT is the authority (a real config has
116+
`mudlib directory :` + `master file :` lines; values carry trailing
117+
`# comment #` chatter that must be stripped). Discovery returns
118+
`{configFile, runCwd, mudlibRoot}` -- runCwd is the checkout root (a
119+
relative `mudlib directory` is driver-cwd-relative), mudlibRoot the
120+
resolved mudlib dir; `runStage` runs lpcc from `runCwd || mudlibRoot`.
121+
Both the extension (config.js) and the server (resolveLpcc) apply the
122+
same chain; VS Code additionally shows a one-time-per-workspace prompt
123+
(`suggestAutoConfig`) offering to persist the discovered config into
124+
workspace settings, and `lpc.initConfig` offers the discovered config
125+
before falling back to scaffolding. The
113126
`lpc.initConfig` command scaffolds that config from
114127
`makeScaffoldFiles()` in lpcc.js (kept vscode-free so tests and the
115128
phase-2 LSP can use it). Scaffold facts learned empirically against a
@@ -150,12 +163,25 @@ lpcc.js and the synced lib/. Facts that bite:
150163
warnings from /std/base64.lpc publish into nvim diagnostics on save;
151164
symbols/hover/definition/completion and lpc/model work on
152165
operators/switch.lpc.
153-
* References + documentHighlight are LEXICAL and document-scope: every
154-
identifier token with the same spelling (comments/strings are other
155-
token kinds so they never pollute). A `#define NAME` declaration
156-
lives INSIDE a single directive token — `wordAtOffset()` resolves the
157-
cursor within it, and `referenceSpans()` adds the name's span in the
158-
directive as the declaration site (honoring includeDeclaration).
166+
* References + documentHighlight are LEXICAL: every identifier token
167+
with the same spelling (comments/strings are other token kinds so
168+
they never pollute). A `#define NAME` declaration lives INSIDE a
169+
single directive token — `wordAtOffset()` resolves the cursor within
170+
it, and `referenceSpans()` adds the name's span in the directive as
171+
the declaration site (honoring includeDeclaration). Mentions inside a
172+
`#define` BODY are part of that one directive token and deliberately
173+
don't count as references.
174+
* The workspace cross-index (`server/indexer.js`, vscode-free +
175+
dependency-injected) powers cross-file definition, workspace-wide
176+
references, and workspace/symbol. Definitions are indexed EAGERLY
177+
(tokenize+outline of every `.lpc`/`.c`/`.h` under each workspace
178+
folder, built once at `initialized`, re-indexed from live buffers on
179+
open/change/save); references are resolved ON DEMAND (cheap
180+
`\bword\b` content prefilter, then tokenize only the matching files)
181+
so the index stores outlines only and memory stays flat on large
182+
mudlibs. Caps: 20k files, 1MB/file; skips dotdirs, node_modules,
183+
`.lpc/`. The testsuite (761 files) indexes in ~1s and is the scale
184+
gate (test-nvim.lua asserts corpus-scale workspace/symbol).
159185
* `scripts/test-lsp.mjs` is the gate: a protocol-level harness driving
160186
the REAL server over stdio (initialize → didOpen → diagnostics /
161187
symbols / formatting / hover / definition — including `#include

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ built on the official `vscode-languageserver` library) and uses it by
6565
default (`lpc.useLanguageServer`): diagnostics (structural lint as you
6666
type + real lpcc compiler errors on save, including in `#include`d
6767
files), outline/breadcrumbs, formatting, hover, go-to-definition
68-
(functions/globals/defines, `#include` and `inherit` targets via the
69-
driver config's include dirs), find-references + document highlight
70-
(lexical, document-scope), completion, and custom `lpc/*` requests
68+
(functions/globals/defines — cross-file via the workspace index — plus
69+
`#include` and `inherit` targets via the driver config's include dirs),
70+
workspace-wide find-references + document highlight, workspace symbol
71+
search (Ctrl+T), completion, and custom `lpc/*` requests
7172
(`lpc/model`, `lpc/tokens`, `lpc/ast`, `lpc/bytecode`) that serve the
7273
Compiler Explorer's data — the webview is a pure renderer over LSP.
7374
Other editors can run it standalone: `node extension/server/main.js
@@ -81,7 +82,10 @@ When the vsix is built with a wasm `lpcc` (`LPCC_WASM_DIR=<dir> node
8182
scripts/build.mjs`, or a `build-wasm/` build inside the submodule), the
8283
extension needs **no settings at all**: with `lpc.lpcc.path` unset it runs
8384
the bundled `bin/lpcc.js` through node, and with `lpc.lpcc.configFile`
84-
unset it uses `<workspace>/.lpc/config` if present. Run **"LPC:
85+
unset it uses `<workspace>/.lpc/config` if present — or **auto-discovers
86+
the mudlib's own driver config** at the usual spots (`config.<name>` at
87+
the workspace root, `etc/config.<name>`), validated by content, and
88+
offers once per workspace to persist it into the settings. Run **"LPC:
8589
Initialize compiler config"** once in a mudlib workspace to scaffold that
8690
config (a minimal `name`/`mudlib`/`master`/`include` template plus a tiny
8791
master object under `.lpc/` — never overwrites existing files), and

extension/config.js

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,27 @@
1616
const vscode = require('vscode');
1717
const path = require('path');
1818
const fs = require('fs');
19-
const { makeScaffoldFiles } = require('./lpcc.js');
19+
const { makeScaffoldFiles, findDriverConfigs } = require('./lpcc.js');
20+
21+
// Discovery cache: workspace root -> findDriverConfigs() result.
22+
const discoveryCache = new Map();
23+
function discover(rootDir) {
24+
if (!discoveryCache.has(rootDir)) {
25+
try { discoveryCache.set(rootDir, findDriverConfigs(rootDir)); }
26+
catch (_e) { discoveryCache.set(rootDir, []); }
27+
}
28+
return discoveryCache.get(rootDir);
29+
}
2030

2131
// Resolve effective lpcc settings for a document, applying the zero-setup
22-
// defaults. Returns {lpccPath, configFile, mudlibRoot, relPath, available}.
32+
// defaults. Returns {lpccPath, configFile, mudlibRoot, runCwd, relPath,
33+
// available}.
2334
function resolveLpccSettings(ctx, doc) {
2435
const cfg = vscode.workspace.getConfiguration('lpc', doc.uri);
2536
let lpccPath = cfg.get('lpcc.path', '');
2637
let configFile = cfg.get('lpcc.configFile', '');
2738
let mudlibRoot = cfg.get('mudlibRoot', '');
39+
let runCwd = null;
2840
if (!mudlibRoot) {
2941
const folder = vscode.workspace.getWorkspaceFolder(doc.uri);
3042
mudlibRoot = folder ? folder.uri.fsPath : path.dirname(doc.uri.fsPath);
@@ -37,9 +49,73 @@ function resolveLpccSettings(ctx, doc) {
3749
const scaffold = path.join(mudlibRoot, '.lpc', 'config');
3850
if (fs.existsSync(scaffold)) configFile = scaffold;
3951
}
52+
if (!configFile) {
53+
// Zero-setup: a real driver config at a well-known spot (config.* at
54+
// the root, etc/config.*) -- see lpcc.findDriverConfigs().
55+
const found = discover(mudlibRoot);
56+
if (found.length > 0) {
57+
configFile = found[0].configFile;
58+
runCwd = found[0].runCwd;
59+
mudlibRoot = found[0].mudlibRoot;
60+
}
61+
}
4062
const relPath = path.relative(mudlibRoot, doc.uri.fsPath).split(path.sep).join('/');
4163
const available = !!(lpccPath && configFile) && !relPath.startsWith('..');
42-
return { lpccPath, configFile, mudlibRoot, relPath, available };
64+
return { lpccPath, configFile, mudlibRoot, runCwd, relPath, available };
65+
}
66+
67+
// One-time (per workspace) suggestion: when no config is set and a real
68+
// driver config exists at a well-known spot, offer to persist it into the
69+
// workspace settings. Resolution already falls back to it silently -- the
70+
// prompt makes the choice explicit/visible, and lets the user pick when a
71+
// checkout carries several configs.
72+
async function suggestAutoConfig(ctx) {
73+
const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0];
74+
if (!folder) return;
75+
const root = folder.uri.fsPath;
76+
const cfg = vscode.workspace.getConfiguration('lpc');
77+
if (cfg.get('lpcc.configFile', '')) return; // already configured
78+
if (fs.existsSync(path.join(root, '.lpc', 'config'))) return; // scaffold present
79+
const stateKey = 'lpc.autoConfigSuggested:' + root;
80+
if (ctx.workspaceState.get(stateKey)) return; // asked before
81+
const found = discover(root);
82+
if (found.length === 0) return;
83+
84+
const rel = (abs) => {
85+
const r = path.relative(root, abs).split(path.sep).join('/');
86+
return r.startsWith('..') ? abs : r;
87+
};
88+
let choice = found[0];
89+
const pickLabel = found.length === 1
90+
? `Use ${rel(choice.configFile)}`
91+
: 'Choose config…';
92+
const answer = await vscode.window.showInformationMessage(
93+
`LPC: found FluffOS driver config ${rel(found[0].configFile)}` +
94+
(found.length > 1 ? ` (and ${found.length - 1} more)` : '') +
95+
' — use it for compiler diagnostics and the Compiler Explorer?',
96+
pickLabel, 'Not now');
97+
if (answer === undefined) return; // dismissed: ask again later
98+
await ctx.workspaceState.update(stateKey, true);
99+
if (answer === 'Not now') return;
100+
if (found.length > 1) {
101+
const picked = await vscode.window.showQuickPick(
102+
found.map((f) => ({ label: rel(f.configFile), description: f.name, f })),
103+
{ placeHolder: 'Driver config to use for this workspace' });
104+
if (!picked) return;
105+
choice = picked.f;
106+
}
107+
// Store workspace-relative when the mudlib root IS the workspace root
108+
// (portable if settings.json is checked in; a relative configFile
109+
// resolves against the lpcc cwd = mudlibRoot). If the config points the
110+
// mudlib elsewhere, store both absolute so they can't drift apart.
111+
const sameRoot = path.resolve(choice.mudlibRoot) === path.resolve(root);
112+
await cfg.update('lpcc.configFile', sameRoot ? rel(choice.configFile) : choice.configFile,
113+
vscode.ConfigurationTarget.Workspace);
114+
if (!sameRoot) {
115+
await cfg.update('mudlibRoot', choice.mudlibRoot, vscode.ConfigurationTarget.Workspace);
116+
}
117+
vscode.window.showInformationMessage(
118+
`LPC: workspace configured with ${rel(choice.configFile)}.`);
43119
}
44120

45121
async function initConfigCommand(ctx) {
@@ -49,6 +125,20 @@ async function initConfigCommand(ctx) {
49125
return;
50126
}
51127
const root = folder.uri.fsPath;
128+
// A real driver config beats a scaffold: offer it first.
129+
discoveryCache.delete(root);
130+
const found = discover(root);
131+
if (found.length > 0 && !vscode.workspace.getConfiguration('lpc').get('lpcc.configFile', '')) {
132+
const relCfg = path.relative(root, found[0].configFile).split(path.sep).join('/');
133+
const use = await vscode.window.showQuickPick(
134+
[`Use discovered driver config (${relCfg})`, 'Scaffold a minimal .lpc/ config'],
135+
{ placeHolder: 'This workspace already has a FluffOS driver config' });
136+
if (use === undefined) return;
137+
if (use.startsWith('Use discovered')) {
138+
await ctx.workspaceState.update('lpc.autoConfigSuggested:' + root, false);
139+
return suggestAutoConfig(ctx);
140+
}
141+
}
52142
const files = makeScaffoldFiles(root);
53143
const written = [];
54144
for (const [rel, content] of Object.entries(files)) {
@@ -74,4 +164,4 @@ function register(ctx) {
74164
return vscode.commands.registerCommand('lpc.initConfig', () => initConfigCommand(ctx));
75165
}
76166

77-
module.exports = { makeScaffoldFiles, resolveLpccSettings, register };
167+
module.exports = { makeScaffoldFiles, resolveLpccSettings, suggestAutoConfig, register };

extension/extension.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ function activate(ctx) {
129129
vscode.commands.registerCommand('lpc.openExplorer', () => explorer.openExplorer(ctx)),
130130
lpcConfig.register(ctx));
131131

132+
// One-time offer to persist a discovered driver config (config.* /
133+
// etc/config.*) into the workspace settings. Fire-and-forget.
134+
lpcConfig.suggestAutoConfig(ctx).catch(() => {});
135+
132136
if (lspClient.enabled()) {
133137
// The language server owns diagnostics, symbols, formatting, hover,
134138
// definition and completion; nothing else to register in-process.

extension/lpcc.js

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function runStage(opts, relPath, stage) {
7171
return new Promise((resolve) => {
7272
cp.execFile(
7373
exe, [...baseArgs, ...flags, opts.configFile, relPath],
74-
{ cwd: opts.mudlibRoot, timeout: 30000, maxBuffer: 32 * 1024 * 1024 },
74+
{ cwd: opts.runCwd || opts.mudlibRoot, timeout: 30000, maxBuffer: 32 * 1024 * 1024 },
7575
(err, stdout, stderr) => {
7676
resolve({
7777
ok: !err,
@@ -86,15 +86,18 @@ function runStage(opts, relPath, stage) {
8686
// Where the driver's debug log lives for a config (compile diagnostics land
8787
// THERE, not on stderr, when the mudlib's master has no log_error apply).
8888
function debugLogPath(opts) {
89+
const path = require('path');
8990
let logDir = 'log', logFile = 'debug.log';
9091
try {
91-
const cfg = require('fs').readFileSync(opts.configFile, 'utf8');
92+
// configFile may be relative to where lpcc runs (runCwd/mudlibRoot).
93+
const cfgPath = path.isAbsolute(opts.configFile)
94+
? opts.configFile : path.join(opts.runCwd || opts.mudlibRoot, opts.configFile);
95+
const cfg = require('fs').readFileSync(cfgPath, 'utf8');
9296
const d = /^log directory\s*:\s*(.+)$/m.exec(cfg);
9397
const f = /^debug log file\s*:\s*(.+)$/m.exec(cfg);
94-
if (d) logDir = d[1].trim();
95-
if (f) logFile = f[1].trim();
98+
if (d) logDir = d[1].replace(/#.*$/, '').trim();
99+
if (f) logFile = f[1].replace(/#.*$/, '').trim();
96100
} catch (_e) { /* unreadable config: defaults */ }
97-
const path = require('path');
98101
const dir = path.isAbsolute(logDir) ? logDir : path.join(opts.mudlibRoot, logDir);
99102
return path.join(dir, logFile);
100103
}
@@ -473,6 +476,58 @@ function stripNoise(raw) {
473476
.replace(/^\n+/, '').replace(/\n+$/, '\n');
474477
}
475478

479+
// --- driver config discovery ------------------------------------------------------
480+
//
481+
// Mudlib checkouts usually carry their driver config at a well-known spot:
482+
// config.<name> at the root, or etc/config.<name> (the fluffos testsuite
483+
// itself uses etc/config.test). Naming is only a convention, so CONTENT is
484+
// the authority: a real driver config has "mudlib directory :" and
485+
// "master file :" lines. Values may carry trailing "# comment #" chatter
486+
// (config.test: "mudlib directory : ./ # test #") -- stripped here.
487+
//
488+
// Returns [{configFile, mudlibRoot, runCwd, name}], scan order root then
489+
// etc/, alphabetical within each:
490+
// configFile -- absolute path to the config
491+
// runCwd -- where lpcc must RUN from (the checkout root: a relative
492+
// "mudlib directory" in the config is driver-cwd-relative)
493+
// mudlibRoot -- the resolved mudlib directory (base for mudlib-relative
494+
// paths: file relPaths, diagnostics, include dirs)
495+
function findDriverConfigs(rootDir) {
496+
const fs = require('fs');
497+
const path = require('path');
498+
const out = [];
499+
const val = (text, key) => {
500+
const m = new RegExp('^' + key + '\\s*:\\s*(.+)$', 'm').exec(text);
501+
return m ? m[1].replace(/#.*$/, '').trim() : null;
502+
};
503+
for (const sub of ['', 'etc']) {
504+
const dir = path.join(rootDir, sub);
505+
let entries;
506+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_e) { continue; }
507+
entries.sort((a, b) => a.name.localeCompare(b.name));
508+
for (const e of entries) {
509+
if (!e.isFile()) continue;
510+
if (!/^config(\.|$)/i.test(e.name) && !/\.(cfg|conf)$/i.test(e.name)) continue;
511+
const abs = path.join(dir, e.name);
512+
let text;
513+
try {
514+
if (fs.statSync(abs).size > 256 * 1024) continue;
515+
text = fs.readFileSync(abs, 'utf8');
516+
} catch (_e) { continue; }
517+
const mudlib = val(text, 'mudlib directory');
518+
if (!mudlib || !val(text, 'master file')) continue;
519+
const mudlibRoot = path.resolve(rootDir, mudlib);
520+
out.push({
521+
configFile: abs,
522+
runCwd: rootDir,
523+
mudlibRoot: fs.existsSync(mudlibRoot) ? mudlibRoot : rootDir,
524+
name: val(text, 'name') || e.name,
525+
});
526+
}
527+
}
528+
return out;
529+
}
530+
476531
// Pure: the scaffold file set for a mudlib root (absolute path).
477532
// Returned paths are relative to the mudlib root.
478533
function makeScaffoldFiles(mudlibAbs) {
@@ -611,4 +666,5 @@ module.exports = {
611666
stripNoise,
612667
outline,
613668
makeScaffoldFiles,
669+
findDriverConfigs,
614670
};

0 commit comments

Comments
 (0)