The parser approach#
Easyconfigs are Python, but eb-stack does not run Python#
An EasyBuild easyconfig (\*.eb) is, syntactically, a Python source file.
EasyBuild’s own framework loads it by executing it and reading the resulting
module namespace. That gives EasyBuild full fidelity – conditionals,
computed values, imports, templating via %(version)s-style substitution –
at the cost of needing a Python interpreter and the EasyBuild framework
installed, and of executing arbitrary code from every recipe it reads.
eb_parse (src/eb_parse.rs) does not run a Python interpreter. It
evaluates easyconfigs as a restricted Python-like DSL with its own small
recursive-descent parser: string/int/bool/~None~ literals, lists, tuples,
dicts, name lookups (including local_* variables and the SYSTEM
constant), + string concatenation, % string formatting, and the full
EasyBuild TEMPLATE_CONSTANTS table (SOURCE_TAR_GZ, GITHUB_SOURCE,
PYPI_SOURCE, and around fifty others – see src/eb_template_constants.rs).
Unmodeled statements (if, for, def, import, and similar keywords)
are skipped rather than rejected, so a recipe that uses control flow for a
field eb-stack does not need still yields every field it does need.
What gets resolved#
resolve_easyconfig_str (and its file wrapper, resolve_easyconfig_file)
evaluates the whole assignment namespace, then extracts and template-expands:
name,version,versionsuffixtoolchain(dict form{'name': ..., 'version': ...}, tuple form, or the bareSYSTEMconstant)dependenciesandbuilddependencies: 2-, 3-, or 4-element tuples (name, version, optional versionsuffix, optional per-dependency toolchain override), plus the legacy filename form ('OpenMPI-4.1.6-foss-2025b.eb') some easyconfigs still useexts_list: name/version pairs, string-only entries, and entries with a trailing options dict
A bare dependency version ('4.1.5') maps to an exact solver requirement
(==4.1.5) – EasyBuild’s own default dependency semantics – while a
version already carrying ==, >=, <=, >, <, or != is used as-is.
%(key)s templates are resolved against a per-recipe template table built
from name~/~version~/~versionsuffix~/~toolchain (including derived keys
like namelower, version_major_minor, github_account) plus the static
TEMPLATE_CONSTANTS table, applied recursively (bounded) so a constant that
itself expands to another template still resolves.
This resolved shape feeds recipe check, stack solve, and the candidate
universe for package bump through
ResolvedEasyconfig::to_candidate(). A bump also resolves its source recipe,
materializes an EasyBuild-origin package plan, and asks Resolvo for the target
dependency selection. The targeted emitter then locates the toolchain dict,
version assignment, checksum entry, and dependency tuples in the original
source text and rewrites only those spans. Unselected content stays intact,
while the selected versions come from the profile lock rather than a separate
text-rewrite decision path. See architecture.
Correctness is checked against EasyBuild’s own parser#
fixtures/parser_hardcases/ holds small easyconfigs exercising the
constructs above – template substitution, local_* variables, the
SYSTEM toolchain, multi-element dependency tuples, exts_list – paired
with golden JSON under fixtures/parser_hardcases/resolved/ produced by
scripts/resolve_easyconfig_eb.py, which drives EasyBuild’s real
EasyConfigParser (requires an EasyBuild install; not run by cargo test). cargo test asserts the Rust parser’s output against those
checked-in goldens: a change to eb_parse that diverges from what
EasyBuild itself would produce fails a test.
Unparseable files are skipped, not fatal#
Real easyconfig trees are large (annual EasyBuild checkouts run to five
figures of .eb files) and not every file in a checkout parses – some use
constructs outside the restricted DSL (inline if~/~for, multi-statement
configopts built from several += lines with embedded control flow,
computed values). parse_easyconfig_tree walks the whole directory and
records each unparseable file in ParseTreeResult::skipped with its error;
it does not abort the walk on the first bad file. ParseTreeResult::coverage()
reports the parsed fraction. Tree-consuming commands such as recipe check,
stack solve, and package bump --easyconfigs surface coverage and sample
skips, so “not in the candidate universe” and “does not exist” stay
distinguishable to the operator reading the output.
Coverage against a real, unfiltered easybuilders/easybuild-easyconfigs
checkout (tens of thousands of files, every toolchain generation from
2021a onward) stays above 90% – see
reproduction fidelity and limitations for the exact
number and what the remaining unparsed files look like.
Why targeted evaluation instead of the alternatives#
Two more general options exist and were deliberately not taken:
Evaluate the file with a real Python interpreter, optionally driving EasyBuild’s own
EasyConfigclass for full parity with what EasyBuild itself would parse. This gives ground-truth fidelity, including computed and templated fields no restricted evaluator will ever handle, at the cost of pulling a Python runtime (and, for true parity, an EasyBuild install) into a Rust CLI’s dependency chain, and of executing recipe codeeb-stackdid not author.A full PEG or hand-written grammar for the easyconfig DSL (the
pest~/~chumsky~/~nom~/~lalrpopfamily). This stays native and dependency-free like the current approach, but is strictly more implementation effort to track EasyBuild’s own syntax evolution, for a grammar that still would not evaluate control flow or computed values any more than the current restricted evaluator does.
The restricted recursive-descent evaluator is a deliberate middle ground: real assignment/expression evaluation (not a text-scraping regex layer) for every construct the solver and the emit path need, without a Python runtime and without evaluating arbitrary code. It does not (and is not meant to) handle a field whose value depends on control flow, a function call, or an import – those recipes fail that one field’s extraction and the file is skipped, not miscounted as present with a wrong value.
Version comparison is EasyBuild-shaped, not semver#
src/version.rs tokenizes a version string into an ordered run of numeric
and alphabetic parts and compares position by position: numeric parts
compare numerically, alphabetic parts compare lexicographically, and a
trailing alphabetic part with nothing on the other side sorts as a
pre-release (2025a < 2025b, 1.0rc1 < 1.0). This matches how EasyBuild
toolchain generations (2025a, 2025b) and application pre-releases
actually sort, which semver’s MAJOR.MINOR.PATCH model does not directly
express.