Writing build rules
A rule module exposes factories that synchronously return exported labels.
Goals attach lazy handlers to those labels, while memoized functions and
run() perform the actual work beneath each handler:
import {
build,
extensible,
label,
memo,
output,
output_path,
run,
} from "imp:core";
const writeStampFile = memo(async function writeStampFile(stamp) {
const { output: outputPath, text } = stamp.data;
return run({
argv: [
"sh",
"-c",
'printf \'%s\\n\' "$2" > "$1"',
"imp-stamp",
output_path(outputPath),
text,
],
outputs: [output(outputPath)],
materialize: true,
display: `write ${outputPath}`,
});
});
export const stampFile = extensible(function stampFile({ output, text }) {
const stamp = label({ data: { output, text } });
build(stamp, () => writeStampFile(stamp));
return stamp;
});
Export the returned label from a BUILD.js file to give it a selectable
workspace address. The attached build handler does not run during module
evaluation; it runs only when that label is selected for imp build.
extensible() lets integrations attach additional handlers replayably.
Factories whose selectable children are only known after async metadata discovery can register them beneath a statically exported owner:
import {
build,
discoverLabels,
label,
registerLabel,
} from "imp:core";
export function generatedProject(opts) {
const project = label({ data: opts });
discoverLabels(project, async owner => {
for (const item of await discoverItems(owner.data)) {
const child = label({ data: item });
build(child, () => buildItem(child.data));
registerLabel(child, `//generated:${item.name}`);
}
}, { goals: ["build"] });
return project;
}
The discovery callback replays once per live runtime so registrations are
never cached away. Put expensive discovery beneath memo(). Addresses passed
to registerLabel() must be absolute and canonical; handlers remain lazy and
run only when their child is selected.
Every real subprocess runs through run(), hermetically sandboxed and cached by the content-addressed digest of its declared inputs, tools, and configuration. The parent directories of declared outputs (and directory outputs themselves) are created in the sandbox before the command runs, so scripts don't need to mkdir them. See the JS code reference for the full exported implementation surface.
Memoized functions use the same metadata object:
const sources = memo(async function sources(handle) {
// ...
}, {
display: "sources {0}",
level: "debug",
});
Display templates use positional placeholders. Targets render as addresses,
scalars render plainly, and collections or objects use bounded summaries such
as [8 targets] and {…}. User-facing products and toolchain acquisition
normally use info; internal source, resource, and metadata computations use
debug. Memo failures are always reported at error.
Validate memo-trace inputs
imp <goal> --trace-inputs checks that the provenance record written for
each memoized computation covers its tracked run({ inputs }) declarations.
FileSet inputs and explicit file, manifest, and directory inputs are
content-digested so --changed-since can identify stale computations.
imp build //apps/server:server --trace-inputs
This validates dependencies visible through the imp rule API. It does not trace arbitrary filesystem calls made by a subprocess or direct use of untracked JavaScript APIs.
memo() deduplicates calls only within the current process. Every new
invocation re-enters rule logic; expensive run() work is reused through the
task cache and CAS. Use --no-cache to bypass that action cache.
Parsing source with tree-sitter
loadGrammar/parseSource/treeSexp/tsQuery let rule code parse source
files with a tree-sitter grammar
— e.g. to extract use/import paths for dependency discovery, or to run
custom analysis over a target's sources:
import { loadGrammar, parseSource, tsQuery, read_file } from "imp:core";
const grammar = loadGrammar("/path/to/tree-sitter-rust.so");
const tree = parseSource(grammar, read_file(rustFilePath));
const matches = tsQuery(grammar, tree, "(use_declaration argument: (_) @import)");
const imports = matches.flatMap((m) => m.captures.map((c) => c.text));
Trust tradeoff: unlike every other native integration in this codebase
(which shells out to a subprocess via run()), a loaded grammar is dlopen'd
directly into the imp process — there is no subprocess boundary. A
malicious or buggy grammar library runs with imp's own privileges and can
corrupt or crash the whole process. Only load grammars you trust, the same
standard you'd apply to any binary passed to run().
loadGrammar currently takes a local path to an already-compiled grammar
shared library (.so/.dylib/.dll); acquiring one (downloading or
compiling it from source) is left to the caller for now.