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.1"
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).
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:
One line. Any tool. The reason, attached.
Made with ⚡ by @prostomarkeloff