directiva 0.1.0

A tiny, paste-friendly directive mini-language: ACTION:[<KIND>]NAME[@PATH][=NOTE]
Documentation
  • Coverage
  • 84.51%
    60 out of 71 items documented1 out of 20 items with examples
  • Size
  • Source code size: 100.86 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.21 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 2s 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 2021 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.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. =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).


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.1.0/directiva-0.1.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:

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

One line. Any tool. The reason, attached.

Made with ⚡ by @prostomarkeloff