directiva
Write the rule on one line. Match anything. Keep the reason.
A tiny, paste-friendly directive mini-language — one rule per line, the kind every linter, build tool and policy gate keeps reinventing:
ACTION : [<KIND>] NAME [@PATH] [=NOTE]
Verb the things kinded <KIND>, named NAME, located @PATH — because NOTE. It fits in a
shell flag, a CI config, or a comment. The crate knows nothing about your domain: you bring the
verbs and the things they act on; directiva brings the parser, the glob matcher, the matching
rules, and a severity ladder.
use ;
let d = parse.unwrap;
// d.action == "de-escalate", d.kind == Some("method"), d.name == "get_*", …
= "0.2" # edition 2024, MSRV 1.85
The idea
The shape selector → verb → reason has been reinvented a hundred times — .gitattributes
(glob attr=value), CODEOWNERS (glob @team), .dockerignore, Checkstyle's suppressions.xml,
rsync's --filter='- *.tmp', Snort rules (alert … (msg:…)). Each is a single-domain version
welded into one tool. directiva is the domain-agnostic substrate they each rebuild — so you can
use one grammar, one parser, and one consistent UX across all of your tools.
Two things make the shape worth extracting:
- The reason travels with the rule.
=NOTEis a first-class field. Most suppression systems (# noqa, baseline files) throw the why away; here it ships next to the match, ready for the next reviewer. - The verbs are yours. The crate has no built-in actions.
suppress/de-escalateare defined by your code, exactly like a customquarantine/allow/promote. The action token is opaque; you map it to whatever you like.
The shape
de-escalate : <method> get_* @ */tests/* = test fixtures
└────┬─────┘ └──┬───┘ └─┬──┘ └───┬────┘ └─────┬──────┘
ACTION KIND NAME PATH NOTE
(your verb, (exact (glob, (glob, any-of (free text;
opaque) filter; any-of scopes) the "why")
<*>=any) names)
| field | required | matched against | sigil |
|---|---|---|---|
ACTION |
yes | resolved by your Action::from_token (or kept as a String) |
ends at first : |
<KIND> |
no | exact equality with the target's qualifier (<*>/omitted = any) |
<…> |
NAME |
yes | glob, any of the target's names | — |
@PATH |
no | glob, any of the target's scopes | @… |
=NOTE |
no | not matched — surfaced beside the result | =… |
The grammar is context-free (the angle-bracketed <KIND> is what buys that — no vocabulary
needed to parse, and a NAME can freely contain :, so Rust paths like Foo::bar are just names).
What it does (Rust)
You implement [Target] for whatever a directive should select. Matching is pushed into the
target, so it's allocation-free and you decide what counts as a "name" (aliases, a joined form, …):
use ;
let d = parse.unwrap;
let hit = Item ;
let miss = Item ;
assert!;
assert!; // @PATH glob doesn't match
Closed-set actions catch typos; the default String action is the open set:
use ;
assert!; // typo → UnknownAction, not a silent no-op
Severity is a decoupled monoid
[Ladder<S>] is a standalone, saturating, order-independent ladder over any ordered set — it
references neither directives nor actions. Linter severities are one use; a markup workflow is
another:
use Ladder;
let sev = new; // most-severe first
assert_eq!; // saturating
assert_eq!; // clamps
let flow = new;
assert_eq!; // not just severities
The CLI use case (-D)
The headline use is many -D flags. directiva::source ingests them with no clap
dependency — you wire one call into your own arg parser. One flag, two forms:
// for each -D value clap collected:
let directives = ?;
Files get # comments, blank-line skipping, CRLF/BOM tolerance, and line-numbered parse errors.
Multiple -D (inline and/or @file) are pure concatenation — no dedup, so a duplicate honestly
compounds; that's the caller's call, not magic.
⚠️ Quote your
-Dvalues.*,{…},[…]and!are all shell metacharacters — unquoted, the shell mangles your directive before the tool sees it. Always single-quote:-D 'suppress:*@*/{test,tests}/*'.
The lint pack — batteries included
directiva::lint is a ready-made instantiation for the classic lint case: a concrete
LintAction vocabulary (suppress / de-escalate / escalate / note / set), a
Severity ladder, and a fold combine policy.
use ;
use cli;
let dirs: = ?.into_iter.map.collect;
let outcome = fold;
// outcome.severity (stepped + clamped) outcome.dropped (suppressed?) outcome.notes
let settings = extract_settings; // [("max-name-group", "256")] — you apply them
Notes accumulate from every match; escalate/de-escalate steps sum then clamp; suppress drops;
set is config (skipped by the fold). It's one pack — a tool with a different vocabulary
(allow/deny, draft<review<final) defines its own beside it.
Use it in any domain
Same grammar, your verbs — a few sketches:
# access control (KIND = resource type, PATH = tenant)
# CI / flaky tests
# feature flags / rollout
# alert routing (ladder: log < ticket < page)
# doc workflow (ladder: draft < review < final)
# data pipeline / PII
The grammar
A clean context-free grammar; the angle-bracketed <KIND> removes the only ambiguity. NAME/PATH
are globs; =NOTE is whatever follows the first unescaped = (so it may contain : @ =).
directive ::= action ":" body
body ::= [ "<" kind ">" ] name [ "@" path ] [ "=" note ]
Glob: * (any run, including / — not path-segment-aware), ? (one byte), {a,b}
alternation (one level), [a-z]/[!neg] shell-style char-classes, and one universal \ escape.
Matching is full-anchor (foo matches only foo, not foobar — use *foo*) and linear-time
(no catastrophic backtracking on untrusted patterns). Compilation is bounded too: brace groups
cross-multiply ({a,b}{c,d} → 4), so the expander caps the product at MAX_BRACE_ALTS (a public
core::glob const) — a short {a,b}×30 "brace bomb" would otherwise be 2³⁰ sub-patterns; past the
cap the braces degrade to literals. No OOM on hostile input.
Architecture
Three layers; everything but core is feature-gated but on by default.
| module | role |
|---|---|
directiva::core |
the engine — glob (Pattern), parse/parse_as, Directive<A = String> + the Action trait, the Target trait + matching, and the decoupled Ladder<S>. Domain-agnostic, always on. |
directiva::source |
ingestion — the -D overload (cli::expand) and the line-based file parser. No clap dependency. (feature source) |
directiva::lint |
the standard batteries — LintAction, Severity, fold, extract_settings. One instantiation, not part of the engine. (feature lint) |
A minimal embedder takes just core (default-features = false).
Python package
The same grammar from Python — parse, match, glob, expand -D values, and the lint fold:
=
, , , # ('de-escalate', 'method', 'get_*', 'test fixtures')
# True
# True
# list[Directive]
# Outcome(severity='warning', dropped=False, notes=['test fixtures'])
The package is typed (py.typed + stubs; Severity is a Literal), and gated behind the
python cargo feature so the pure-Rust crate keeps zero Python dependency by default. Built with
maturin (abi3 — one wheel per platform works on CPython ≥ 3.9).
No PyPI. Two ways to install:
1. Prebuilt wheel — no Rust toolchain. Grab the wheel for your platform from the Releases page and install it by URL:
2. From source — needs a Rust toolchain (pip drives maturin automatically):
Build locally into a venv with maturin develop --features python.
Correctness
The grammar's corner cases (note eats :/@/=, the ::-in-names case, char-class edges,
escaping across layers, severity clamping, file conventions) are pinned by the test suite, and the
engine's safety invariants (the parser never panics, glob compilation stays bounded, literals are
full-anchored) are exercised by proptest:
The parser and glob engine also carry cargo-fuzz
harnesses — totality and the brace-bomb cap are asserted as fuzz invariants:
One line. Any tool. The reason, attached.
Made with ⚡ by @prostomarkeloff