directiva 0.1.0

A tiny, paste-friendly directive mini-language: ACTION:[<KIND>]NAME[@PATH][=NOTE]
Documentation
//! Optional Python bindings (the `python` feature, built by maturin).
//!
//! The Rust [`Target`] trait can't cross the FFI boundary, so the Python surface is the concrete
//! one a caller would build anyway: parse a directive, match it against plain
//! `(qualifier, names, scopes)` data, glob-test a string, and expand a `-D` value (inline or
//! `@file`). The pure-Rust crate keeps zero Python dependency unless this feature is on.

use pyo3::create_exception;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;

use crate::core::{self, Action, Pattern, Target};
use crate::source::cli;
use std::collections::{HashMap, HashSet};

create_exception!(
    _directiva,
    DirectiveError,
    PyValueError,
    "A directive failed to parse, or a `@file` failed to load."
);

/// An ad-hoc [`Target`] built from plain Python data.
struct PyTarget {
    qualifier: Option<String>,
    names: Vec<String>,
    scopes: Vec<String>,
}

impl Target for PyTarget {
    fn qualifier(&self) -> Option<&str> {
        self.qualifier.as_deref()
    }
    fn matches_name(&self, p: &Pattern) -> bool {
        self.names.iter().any(|n| p.matches(n))
    }
    fn matches_scope(&self, p: &Pattern) -> bool {
        self.scopes.iter().any(|s| p.matches(s))
    }
}

/// One parsed directive (`ACTION:[<KIND>]NAME[@PATH][=NOTE]`), open-set: `action` is the raw token.
#[pyclass(name = "Directive", frozen)]
struct PyDirective {
    inner: core::Directive<String>,
}

#[pymethods]
impl PyDirective {
    /// The action token, verbatim.
    #[getter]
    fn action(&self) -> String {
        self.inner.action.clone()
    }
    /// The `<KIND>` filter, or `None` for any kind.
    #[getter]
    fn kind(&self) -> Option<String> {
        self.inner.kind.clone()
    }
    /// The NAME glob, as written.
    #[getter]
    fn name(&self) -> String {
        self.inner.name.as_str().to_owned()
    }
    /// The `@PATH` glob, or `None`.
    #[getter]
    fn path(&self) -> Option<String> {
        self.inner.path.as_ref().map(|p| p.as_str().to_owned())
    }
    /// The `=NOTE` text, or `None`.
    #[getter]
    fn note(&self) -> Option<String> {
        self.inner.note.clone()
    }

    /// Does this directive apply to a target with these `names` (and optional `qualifier`/`scopes`)?
    ///
    /// KIND (if set) must equal `qualifier` exactly; NAME must glob-match one of `names`; PATH (if
    /// set) must glob-match one of `scopes`.
    #[pyo3(signature = (names, *, qualifier=None, scopes=None))]
    #[allow(clippy::needless_pass_by_value)]
    fn matches(
        &self,
        names: Vec<String>,
        qualifier: Option<String>,
        scopes: Option<Vec<String>>,
    ) -> bool {
        let t = PyTarget {
            qualifier,
            names,
            scopes: scopes.unwrap_or_default(),
        };
        self.inner.matches(&t)
    }

    fn __str__(&self) -> String {
        self.inner.to_string()
    }
    fn __repr__(&self) -> String {
        format!("Directive({})", self.inner)
    }
}

/// If `actions` is given, reject any action token outside the set (closed-set typo catching).
fn check_action(action: &str, actions: Option<&HashSet<String>>) -> PyResult<()> {
    if let Some(allowed) = actions {
        if !allowed.contains(action) {
            let mut names: Vec<&str> = allowed.iter().map(String::as_str).collect();
            names.sort_unstable();
            return Err(DirectiveError::new_err(format!(
                "unknown action {action:?}; allowed: {names:?}"
            )));
        }
    }
    Ok(())
}

/// `parse(spec, *, actions=None)` → a [`Directive`]. Raises `DirectiveError` on a malformed string,
/// or — when `actions` is given — on an action token outside that allowed set.
#[pyfunction]
#[pyo3(signature = (spec, *, actions=None))]
#[allow(clippy::needless_pass_by_value)]
fn parse(spec: &str, actions: Option<HashSet<String>>) -> PyResult<PyDirective> {
    let inner = core::parse(spec).map_err(|e| DirectiveError::new_err(e.to_string()))?;
    check_action(&inner.action, actions.as_ref())?;
    Ok(PyDirective { inner })
}

/// `glob_match(pattern, text)` → does the glob (`* ? {} [..] \\`) match `text` in full?
#[pyfunction]
fn glob_match(pattern: &str, text: &str) -> bool {
    Pattern::compile(pattern).matches(text)
}

/// `expand(value, *, actions=None)` → the directives a single `-D` value yields: one inline
/// directive, or every directive in a `@file` (`@-` reads stdin). Raises `DirectiveError` on a bad
/// directive/file, or — when `actions` is given — on any action outside that allowed set.
#[pyfunction]
#[pyo3(signature = (value, *, actions=None))]
#[allow(clippy::needless_pass_by_value)]
fn expand(value: &str, actions: Option<HashSet<String>>) -> PyResult<Vec<PyDirective>> {
    let sourced =
        cli::expand::<String>(value).map_err(|e| DirectiveError::new_err(e.to_string()))?;
    let mut out = Vec::with_capacity(sourced.len());
    for s in sourced {
        check_action(&s.directive.action, actions.as_ref())?;
        out.push(PyDirective { inner: s.directive });
    }
    Ok(out)
}

// ── lint pack ────────────────────────────────────────────────────────────────

use crate::lint::{self, LintAction, Severity};

fn parse_severity(s: &str) -> PyResult<Severity> {
    match s.to_ascii_lowercase().as_str() {
        "error" => Ok(Severity::Error),
        "warning" => Ok(Severity::Warning),
        "info" => Ok(Severity::Info),
        other => Err(DirectiveError::new_err(format!(
            "unknown severity {other:?}; expected \"error\", \"warning\", or \"info\""
        ))),
    }
}

fn severity_str(s: Severity) -> &'static str {
    match s {
        Severity::Error => "error",
        Severity::Warning => "warning",
        Severity::Info => "info",
    }
}

/// Re-resolve a list of open-set [`PyDirective`]s into typed `Directive<LintAction>`; directives
/// whose action isn't a lint verb are inert (dropped here).
fn to_lint_dirs(
    py: Python<'_>,
    directives: &[Py<PyDirective>],
) -> Vec<core::Directive<LintAction>> {
    directives
        .iter()
        .filter_map(|d| {
            let d = d.borrow(py);
            LintAction::from_token(&d.inner.action).map(|action| core::Directive {
                action,
                kind: d.inner.kind.clone(),
                name: d.inner.name.clone(),
                path: d.inner.path.clone(),
                note: d.inner.note.clone(),
            })
        })
        .collect()
}

/// `lint_fold(current, directives, names, *, qualifier=None, scopes=None)` → `(severity, dropped,
/// notes)`. Applies the standard lint combine policy (suppress / de-escalate / escalate / note);
/// non-lint actions are inert. `current` ∈ `"error" | "warning" | "info"`.
#[pyfunction]
#[pyo3(signature = (current, directives, names, *, qualifier=None, scopes=None))]
#[allow(clippy::needless_pass_by_value)]
fn lint_fold(
    py: Python<'_>,
    current: &str,
    directives: Vec<Py<PyDirective>>,
    names: Vec<String>,
    qualifier: Option<String>,
    scopes: Option<Vec<String>>,
) -> PyResult<(String, bool, Vec<String>)> {
    let cur = parse_severity(current)?;
    let target = PyTarget {
        qualifier,
        names,
        scopes: scopes.unwrap_or_default(),
    };
    let f = lint::fold(cur, &target, &to_lint_dirs(py, &directives));
    Ok((severity_str(f.severity).to_owned(), f.dropped, f.notes))
}

/// `lint_settings(directives)` → `[(key, value)]` from every `set:KEY=VALUE` directive, in order.
#[pyfunction]
#[allow(clippy::needless_pass_by_value)]
fn lint_settings(py: Python<'_>, directives: Vec<Py<PyDirective>>) -> Vec<(String, String)> {
    lint::extract_settings(&to_lint_dirs(py, &directives))
}

// ── generic ladder ───────────────────────────────────────────────────────────

/// `Ladder(levels)` — a saturating, order-independent ladder over any ordered set (severities,
/// `["draft", "review", "final"]`, …). Levels are most-significant first.
#[pyclass(name = "Ladder", frozen)]
struct PyLadder {
    inner: core::Ladder<String>,
}

#[pymethods]
impl PyLadder {
    #[new]
    #[allow(clippy::needless_pass_by_value)]
    fn new(levels: Vec<String>) -> Self {
        Self {
            inner: core::Ladder::new(levels),
        }
    }

    /// All levels, most-significant first.
    #[getter]
    fn levels(&self) -> Vec<String> {
        self.inner.levels().to_vec()
    }

    /// The rung index of `level`, or `None` if it isn't on this ladder.
    #[allow(clippy::needless_pass_by_value)]
    fn position(&self, level: String) -> Option<usize> {
        self.inner.position(&level)
    }

    /// Move `level` by `delta` rungs, saturating at both ends (`delta>0` → later in the list).
    #[allow(clippy::needless_pass_by_value)]
    fn step(&self, level: String, delta: i32) -> String {
        self.inner.step(&level, delta)
    }

    /// Fold matching directives onto a target with *your* verb vocabulary, returning
    /// `(level, dropped, notes)`: notes accumulate from every match, `current` steps by the summed
    /// `steps[action]` (missing actions = 0), and `dropped` is set if any matching action is in
    /// `drop`. Custom verbs + custom ladder, same combine shape as the built-in lint fold.
    #[pyo3(signature = (current, directives, names, *, qualifier=None, scopes=None, steps=None, drop=None, notes=true))]
    #[allow(
        clippy::needless_pass_by_value,
        clippy::too_many_arguments,
        clippy::fn_params_excessive_bools
    )]
    fn fold(
        &self,
        py: Python<'_>,
        current: String,
        directives: Vec<Py<PyDirective>>,
        names: Vec<String>,
        qualifier: Option<String>,
        scopes: Option<Vec<String>>,
        steps: Option<HashMap<String, i32>>,
        drop: Option<HashSet<String>>,
        notes: bool,
    ) -> (String, bool, Vec<String>) {
        let steps = steps.unwrap_or_default();
        let drop = drop.unwrap_or_default();
        let target = PyTarget {
            qualifier,
            names,
            scopes: scopes.unwrap_or_default(),
        };
        let (mut delta, mut dropped, mut out) = (0i32, false, Vec::new());
        for d in &directives {
            let d = d.borrow(py);
            if !d.inner.matches(&target) {
                continue;
            }
            if notes {
                if let Some(n) = &d.inner.note {
                    out.push(n.clone());
                }
            }
            if let Some(s) = steps.get(&d.inner.action) {
                delta += *s;
            }
            if drop.contains(&d.inner.action) {
                dropped = true;
            }
        }
        (self.inner.step(&current, delta), dropped, out)
    }
}

#[pymodule]
fn _directiva(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(parse, m)?)?;
    m.add_function(wrap_pyfunction!(glob_match, m)?)?;
    m.add_function(wrap_pyfunction!(expand, m)?)?;
    m.add_function(wrap_pyfunction!(lint_fold, m)?)?;
    m.add_function(wrap_pyfunction!(lint_settings, m)?)?;
    m.add_class::<PyDirective>()?;
    m.add_class::<PyLadder>()?;
    m.add("DirectiveError", m.py().get_type::<DirectiveError>())?;
    Ok(())
}