directiva 0.2.0

A tiny, paste-friendly directive mini-language: ACTION:[<KIND>]NAME[@PATH][=NOTE]
Documentation
  • Coverage
  • 84.72%
    61 out of 72 items documented1 out of 20 items with examples
  • Size
  • Source code size: 123.05 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.23 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 3s Average build duration of successful builds.
  • all releases: 3s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • prostomarkeloff/directiva
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • prostomarkeloff

directiva

Write the rule on one line. Match anything. Keep the reason.

Rust 2024 MSRV 1.85 License: MIT grammar: context-free Types: pyright

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.txt
directiva = "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. =NOTE is 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-escalate are defined by your code, exactly like a custom quarantine / 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 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 match

Closed-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

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 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 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:

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 -D values. *, {…}, […] 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 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 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)
-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'

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:

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).

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:

pip install https://github.com/prostomarkeloff/directiva/releases/download/v0.2.0/directiva-0.2.0-cp39-abi3-macosx_11_0_arm64.whl

2. From source — needs a Rust toolchain (pip drives maturin automatically):

pip install git+https://github.com/prostomarkeloff/directiva

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:

cargo test                 # 64 tests: core + source + lint + property + integration + doctest
cargo clippy --all-targets

The 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 terminates

One line. Any tool. The reason, attached.

Made with ⚡ by @prostomarkeloff