/**
* Loading a lexicon's plugin so completeness checks can look at what it
* actually registers (#1342).
*
* `check-lexicon`'s checks were almost all existence assertions over the
* directory: `src/lsp/completions.ts exists`, `At least 1 lint rule in
* src/lint/rules/`. A file with the right name is not the contract — the
* contract is the member the plugin exposes, because that is what core
* dispatches through. helm is the proof: it ships `src/lsp/completions.ts` and
* `src/lsp/hover.ts`, both with passing tests, exports `helmCompletions` and
* `helmHover` from its index, and never sets `completionProvider` or
* `hoverProvider` on the plugin. `cli/lsp/server.ts` dispatches through exactly
* those fields, so helm's LSP support is unreachable in an editor — while tier 1
* (the files) and tier 2 (the tests) both passed.
*
* Resolution is directory-local rather than by package name: `chant dev
* check-lexicon
` should work on a lexicon that is not installed, and
* `loadPlugin` in ../plugins.ts imports `@intentius/chant-lexicon-`,
* which requires it to be.
*/
import { existsSync, readFileSync } from "fs";
import { isAbsolute, join, resolve } from "path";
import { pathToFileURL } from "url";
import { isLexiconPlugin, type LexiconPlugin } from "../../lexicon";
export interface LoadedLexicon {
/** The plugin, when the package exported one. */
plugin?: LexiconPlugin;
/** Why loading failed, for a check's `detail`. */
error?: string;
/** The entry point that was imported, for diagnostics. */
entry?: string;
}
/**
* The module a lexicon package presents to consumers.
*
* Tier 1 requires `exports["."].default` to be `./src/index.ts`, so that is the
* first choice — it is the module core itself imports. The fallbacks keep this
* usable on a lexicon that has not reached that check yet.
*/
export function pluginEntryFor(dir: string): string | undefined {
const candidates: string[] = [];
try {
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as {
exports?: { "."?: { default?: string } };
main?: string;
};
for (const declared of [pkg.exports?.["."]?.default, pkg.main]) {
if (typeof declared === "string") candidates.push(declared);
}
} catch {
// no package.json, or unreadable — fall through to the conventional paths
}
candidates.push("./src/index.ts", "./src/plugin.ts");
for (const candidate of candidates) {
const path = isAbsolute(candidate) ? candidate : resolve(dir, candidate);
if (existsSync(path)) return path;
}
return undefined;
}
/**
* Import a lexicon directory and return the `LexiconPlugin` it exports.
*
* Never throws: a lexicon that cannot be loaded is a finding, not a crash, and
* every caller reports it as a failed check rather than aborting the run.
*/
export async function loadLexiconFromDir(dir: string): Promise {
const entry = pluginEntryFor(dir);
if (!entry) {
return { error: "no importable entry point (looked for package.json exports, src/index.ts, src/plugin.ts)" };
}
let mod: Record;
try {
mod = (await import(pathToFileURL(entry).href)) as Record;
} catch (error) {
return { entry, error: `import failed: ${error instanceof Error ? error.message : String(error)}` };
}
for (const value of Object.values(mod)) {
if (isLexiconPlugin(value)) return { plugin: value, entry };
}
// A default export whose own members are the plugin (some lexicons re-export
// a namespace rather than the object itself).
const fallback = (mod.default ?? {}) as Record;
for (const value of Object.values(fallback)) {
if (isLexiconPlugin(value)) return { plugin: value, entry };
}
return { entry, error: "the module exports no LexiconPlugin" };
}
/** Whether the plugin exposes a callable member under `name`. */
export function registers(plugin: LexiconPlugin | undefined, name: keyof LexiconPlugin): boolean {
return typeof plugin?.[name] === "function";
}
/**
* Call a plugin member that returns a list, treating a throw as an empty list.
*
* A member that throws is not a registration — it is worse than an absent one,
* and the check that counts its results should fail rather than the whole run.
*/
export function safeList(fn: (() => T[]) | undefined): { items: T[]; error?: string } {
if (typeof fn !== "function") return { items: [] };
try {
return { items: fn() ?? [] };
} catch (error) {
return { items: [], error: error instanceof Error ? error.message : String(error) };
}
}