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 directiva::{parse, Pattern, Target};
let d = parse("de-escalate:<method>get_*@*/tests/*=test fixtures").unwrap();
// d.action == "de-escalate", d.kind == Some("method"), d.name == "get_*", …mytool ./src \
-D 'suppress:<function>__repr__@*=dataclass boilerplate' \
-D 'escalate:<class>*Service@*/core/*=core services must be unique' \
-D @ci/directives.txtdirectiva = "0.2" # edition 2024, MSRV 1.85The 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.
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).
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 directiva::{parse, Pattern, Target};
struct Item { kind: &'static str, name: &'static str, file: &'static str }
impl Target for Item {
fn qualifier(&self) -> Option<&str> { Some(self.kind) }
fn matches_name(&self, p: &Pattern) -> bool { p.matches(self.name) }
fn matches_scope(&self, p: &Pattern) -> bool { p.matches(self.file) }
}
let d = parse("suppress:<function>get_*@*/tests/*").unwrap();
let hit = Item { kind: "function", name: "get_user", file: "src/tests/api.rs" };
let miss = Item { kind: "function", name: "get_user", file: "src/api.rs" };
assert!(d.matches(&hit));
assert!(!d.matches(&miss)); // @PATH glob doesn't matchClosed-set actions catch typos; the default String action is the open set:
use directiva::{parse_as, Action};
enum Verb { Suppress, Quarantine }
impl Action for Verb {
fn from_token(t: &str) -> Option<Self> {
match t { "suppress" => Some(Verb::Suppress), "quarantine" => Some(Verb::Quarantine), _ => None }
}
}
assert!(parse_as::<Verb>("supress:foo").is_err()); // typo → UnknownAction, not a silent no-op[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 directiva::Ladder;
let sev = Ladder::new(vec!["error", "warning", "info"]); // most-severe first
assert_eq!(sev.step(&"error", 2), "info"); // saturating
assert_eq!(sev.step(&"info", -5), "error"); // clamps
let flow = Ladder::new(vec!["draft", "review", "final"]);
assert_eq!(flow.step(&"draft", 1), "review"); // not just severitiesThe 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:
mytool -D 'suppress:<function>spawn@*/vendor/*=vendored copy' # an inline directive
mytool -D @rules.txt # a directive file (one per line)
mytool -D @- # …or read them from stdin// for each -D value clap collected:
let directives = directiva::source::cli::expand_all::<directiva::lint::LintAction, _, _>(values)?;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}/*'.
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 directiva::lint::{self, LintAction, Severity};
use directiva::source::cli;
let dirs: Vec<_> = cli::expand_all::<LintAction, _, _>([
"de-escalate:<method>get_*@*/tests/*=test fixtures",
"suppress:<function>spawn@*/vendor/*=vendored copy",
"set:max-name-group=256",
])?.into_iter().map(|s| s.directive).collect();
let outcome = lint::fold(Severity::Error, &finding, &dirs);
// outcome.severity (stepped + clamped) outcome.dropped (suppressed?) outcome.notes
let settings = lint::extract_settings(&dirs); // [("max-name-group", "256")] — you apply themNotes 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.
Same grammar, your verbs — a few sketches:
# access control (KIND = resource type, PATH = tenant)
-D 'deny:<route>/admin/*@*=staff only' -D 'allow:<route>/public/*'
# CI / flaky tests
-D 'quarantine:test_checkout_*@*/e2e/*=JIRA-1234, flaky on CI'
# feature flags / rollout
-D 'canary:checkout-v2@*/eu/*=10% EU'
# alert routing (ladder: log < ticket < page)
-D 'silence:<alert>DiskWarn*@*/staging/*=staging noise' -D 'escalate:<alert>Oom*@*/prod/*'
# doc workflow (ladder: draft < review < final)
-D 'promote:chapter-*@drafts/q3/*=ready for review'
# data pipeline / PII
-D 'mask:<column>*_ssn@*/users/*=PII'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.
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).
The same grammar from Python — parse, match, glob, expand -D values, and the lint fold:
import directiva
from directiva import lint
d = directiva.parse("de-escalate:<method>get_*@*/tests/*=test fixtures")
d.action, d.kind, d.name, d.note # ('de-escalate', 'method', 'get_*', 'test fixtures')
d.matches(["get_user"], qualifier="method", scopes=["src/tests/api.py"]) # True
directiva.glob_match("*/{test,tests}/*", "pkg/tests/x.py") # True
directiva.expand("@ci/rules.txt") # list[Directive]
lint.fold(lint.ERROR, [d], ["get_user"], qualifier="method", scopes=["src/tests/x.py"])
# 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).
Install — prebuilt, no Rust toolchain:
pip install directiva # or: uv add directivaPrebuilt abi3 wheels are on PyPI for Linux (x86_64, aarch64), macOS (x86_64, arm64) and Windows
(x64) — pip picks the one for your platform, no compiler involved. On a platform with no matching
wheel, pip falls back to the sdist, which does need a Rust toolchain (it drives maturin
automatically). For local development, build the extension into a venv with make py-dev
(maturin develop --features python).
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:
cargo test # 64 tests: core + source + lint + property + integration + doctest
cargo clippy --all-targetsThe parser and glob engine also carry cargo-fuzz
harnesses — totality and the brace-bomb cap are asserted as fuzz invariants:
cargo +nightly fuzz run parse # the parser is total: Result, never a panic
cargo +nightly fuzz run glob # compile stays ≤ MAX_BRACE_ALTS; matches always terminatesOne line. Any tool. The reason, attached.
Made with ⚡ by @prostomarkeloff