Discovered Dependencies: Scanners¶
Some edges' real dependencies live only inside their input files. In
C++, import m; orders one compile after another. A scene file that
references another scene has to be packed after the second scene is
made up-to-date. A Fortran USE picks a .mod some other compile
writes. And sometimes those source files are generated, so they
don't exist when the build starts, and may be updated as they're
rebuilt in an incremental build. Users shouldn't have to hand-create
these dependencies in their build scripts.
A Scanner declares that a set of build edges has content-derived dependencies, and allows users to specify the command that finds them. A scanner supplies a tool that reads one edge's sources and reports what it found; pcons wires the graph and turns those reports into a Ninja dyndep file while the build runs.
Static vs. Dynamic content¶
Configure may depend only on static facts: file names, suffixes, flags, the target DAG. Content-derived facts, which may or may not exist or be up-to-date when the build starts, flow through scan → collate at build time.
- A scanned source is never read at configure time, so a generated source is treated like other sources. The scan edge takes it as an input, and ninja runs the producer first.
- Scanned sources aren't configure dependencies. Change an
importand pcons doesn't need to re-run: that one file is rescanned and recompiled directly by ninja. - Some discovered dependencies can't reach a command line directly through dyndep, which carries
only deps and outputs. Those get written to a file by the new
collatestep, to a fixed path. See Discovered flags.
Declaring a Scanner¶
Here's a simple example using "scene" files, which for this example are text files with name-value pairs like name and properties, and refs which refer to other scenes by name. The scan_scene.py tool collects provides/requires for each scene based on its name and refs. That allows pcons to dynamically build a dependency graph for the whole hierarchy. This even works for scenes that are generated during the build, as shown in example 70 (examples/70_scene_packs).
from pcons import Scanner
scene_refs = Scanner(
"scene-refs", # lowercase-hyphenated, like a preset
source_suffixes=[".scene"], # which sources get scanned
scan_command=[python, "$SRCDIR/tools/scan_scene.py", "$SOURCES", "$TARGET"],
scan_deps=["tools/scan_scene.py"], # re-scan when the tool changes
on_unresolved="error", # a ref nothing provides is a broken asset
)
scene_refs.attach(pack_common, pack_level1, pack_level2)
attach() is the only API for a Scanner, and it takes targets (not files). Each attached
target is one scope. Call it before project.resolve(); a toolchain may
also attach from its after_resolve hook.
A governed edge is any build edge of an attached target with at least one
source matching source_suffixes. Attaching a scanner that governs nothing is
an error.
Per scope, the resolver wires:
- one scan edge per governed edge: that edge's scanned sources in, one scan-info JSON file out. All the scan edges of a scanner share one ninja rule, whatever their count.
- one collate edge per target: inputs are the scan infos, a fixed manifest pcons writes at
configure time, and the exports of scanned dependency scopes. Outputs are a dyndep file,
an exports file, and any args files. It runs with
restat, so a collate that changes nothing dirties nothing. dyndep = <the scope's dyndep file>on every governed edge, with that file in order-only position. The edge waits for it to exist; the loaded dyndep supplies the real deps. Rewriting it doesn't by itself rebuild anything.
scan_depfile / scan_deps_style give the scan edge its own
dependency tracking, so a scan that reads a header re-runs when the
header changes. Styles are "gcc" for a make-style depfile, "msvc"
for /showIncludes on the scan's output.
The scan-info contract¶
The scan tool writes one JSON file per governed edge. Every field but
version is optional:
{"version": 1,
"provides": [{"name": "level1", "path": "packs/level1.pack"}],
"requires": ["common"],
"extra_deps": ["assets/shared.inc"],
"extra_outputs": ["packs/level1.index"]}
All paths are relative to the build directory, with forward slashes.
providessays where a logical name lives, so requiring edges can depend on it. Give nopathand the scanner'sprovide_template({name},{scanner},{scope}) supplies one.requireslists logical names this edge needs. Collate resolves each one to the artifact that provides it, own scope first, then imports. Anything left over is handled peron_unresolved:"ignore"(something outside the build may satisfy it),"warn", or"error".extra_depsare discovered files this edge reads.extra_outputsare files this edge writes beyond its declared outputs, and they're the only thing that becomes a dyndep implicit output.
Hence the rule collate enforces: every provide path must be backed by one
of the edge's declared outputs or by one of its extra_outputs. Anything
else is rejected.
Exports travel along dependencies¶
A scope resolves a required name against its own provides and against the
exports of the scopes it depends on. So pack_level2 needs
add_dependency(pack_level1) (or link(), for a library) before it can see
the name level1 at all.
The dependency carries the exports; content decides the order. Declaring the dependency doesn't order any particular compile or pack — it only says where to look. What gets used, and in what order, comes out of the scan.
Discovered flags¶
In Ninja, dyndep can reorder a build; it can't edit a command. Pcons uses edge_args to
close that gap. Collate writes with each governed edge a small file listing the artifacts its
requires resolved to, and the edge's command refers to that file by a path
fixed at configure time, and typically used in the compilation line as @file.
edge_args=EdgeArgsSpec(
suffix=".refs", # appended to the edge's primary output
var="SCENE_REFS", # per-edge ninja variable holding the path
token="$SCENE_REFS", # appended to the command ("@$SCENE_REFS" for a response file)
format=ArgsFormat(line="{name} {path}"),
include="requires", # or "requires+provides"
)
link_args is the same idea per scope instead of per edge, wired to the
target's final link edges. It carries extra link inputs that only collate can
know about — the standard library module's object, when some translation unit
turns out to import std;. Use link_args_target_types to keep it off
targets whose linker won't take a response file, such as an archiver.
Generated sources need no phases¶
examples/70_scene_packs builds two generations of generated scenes: it
assembles a generator from checked-in fragments, runs it to get a scanned
scene and a fragment of the next generator, assembles that one, and runs it
for another scanned scene. One pcons run, one ninja run, no staging
declarations and no existence checks anywhere.
That works because a scan edge is just an edge. Its input is the generated scene, so the producer's ordering comes from the node graph, and any number of generation stages compose for free.
A scanner that scanned the whole project in one pass can't do this: the scan of the second generated scene would have to run before the generator that writes it, which that generator's own scan would have to precede. Issue #105 is the C++ modules version of the same deadlock.
Scanner or staged generation?¶
Both discover things mid-build, and they compose. The question they answer is different:
| Build-time question | Mechanism |
|---|---|
| What does this file's content imply — deps, ordering, extra outputs, per-edge flags? | Scanner. Resolved during the build, no reconfigure. |
| What set of files or targets exists — names, counts, output paths? | Staged generation (generated_input / when_generated). pcons re-runs to describe the new targets. |
A staged pass can add scanned targets, since the scanner wiring runs at every
configure. Scans of generated files need no staging. See
Staged Generation
and examples/57_staged_generation.
Ninja only¶
Only ninja can express dyndep. The Makefile and Xcode generators
refuse a project that uses a scanner. build.ninja declares
ninja_required_version = 1.11 as soon as a scope exists: that's the
minimum version where cross-file dyndep references resolve reliably.
A complete example¶
Scenes reference each other by name. For this contrived example, "packing" one embeds a digest of every pack it references, so a referenced pack must be built first — and which one that is lives in the scene text.
def pack(name: str, *scenes: str) -> Target:
return env.Command(
target=packs_dir / f"{name}.pack",
source=list(scenes),
command=[python, "$SRCDIR/tools/pack_scene.py", "$TARGET", "$SOURCES"],
depends=["tools/pack_scene.py"],
name=f"pack_{name}",
)
pack_common = pack("common", "assets/common.scene")
pack_level1 = pack("level1", "assets/level1.scene")
pack_level1.add_dependency(pack_common) # exports, not order
scene_refs = Scanner(
"scene-refs",
source_suffixes=[".scene"],
scan_command=[python, "$SRCDIR/tools/scan_scene.py", "$SOURCES", "$TARGET"],
scan_deps=["tools/scan_scene.py"],
provide_template="packs/{name}.pack", # one scene per pack, here
on_unresolved="error",
edge_args=EdgeArgsSpec(suffix=".refs", var="SCENE_REFS", token="$SCENE_REFS",
format=ArgsFormat(line="{name} {path}"),
include="requires"),
)
scene_refs.attach(pack_common, pack_level1)
The provides carry no path, so provide_template supplies one.
info = {
"version": 1,
"provides": [{"name": n} for n in provides],
"requires": sorted(set(requires) - set(provides)),
}
Path(out).write_text(json.dumps(info))
packs/level1.pack's build statement carries no ordering at all. The ordering
arrives at build time, from what the scenes said:
# scan/scene-refs/scene_packs.pack_level1.dyndep
build packs/level1.pack: dyndep | packs/common.pack
The full version is examples/70_scene_packs: several scenes per pack (so the
scan edge gets its pack through scan_vars instead of a template), the
generated scenes, and the rebuild behavior.
How the C++ and Fortran toolchains use it¶
C++20 modules and Fortran modules use this method internally, so it should be fully transparent for users.
Every target that owns module sources gets a scanner attached automatically.
For C++ the scan edge is clang-scan-deps, cl /scanDependencies, or GCC's
own preprocess-only pass, all emitting P1689; a C++-specific collate maps
logical module names to keyed BMIs and writes each compile a modmap
(edge_args) carrying -fmodule-file=, GCC's mapper lines, or MSVC's
/reference. import std; rides link_args: configure describes dormant
standard-library edges, and nothing builds or links unless some translation
unit's scan reports the import. Fortran declares a "fortran-modules" scanner
over its MODULE/USE scan and uses the generic collate unchanged.
The examples: examples/29_cxx_modules, 30_cxx_partitions,
32_cxx_import_std, 35_cxx_modules_deps, 36_cxx_modules_multi_level_subdirs,
39_bmi_compat, 71_cxx_modules_codegen, 72_cxx_modules_codegen_interface,
and 26_fortran_modules. The user-facing rules are in
C++20 modules.