directiva 0.2.0

A tiny, paste-friendly directive mini-language: ACTION:[<KIND>]NAME[@PATH][=NOTE]
Documentation
//! The `Result`-based directive parser: greedy, escape-aware splits on the sigils `:` `<>` `@` `=`,
//! with no `process::exit`/`eprintln!`.

use crate::core::directive::{Action, Directive};
use crate::core::glob::Pattern;

/// Why a directive string failed to parse.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ParseError {
    /// No `ACTION:` separator at all.
    MissingColon,
    /// An `=` appeared before the action `:` (e.g. `a=b:c`).
    EqualsBeforeColon,
    /// The action token was empty/whitespace (e.g. `:foo`).
    EmptyAction,
    /// `Action::from_token` rejected the token (closed-set vocabularies catch typos here).
    UnknownAction(String),
    /// `<>` — an empty KIND filter, almost certainly a mistake.
    EmptyKind,
    /// The (required) NAME was empty.
    EmptyName,
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::MissingColon => f.write_str("expected `ACTION:…` (no `:` found)"),
            Self::EqualsBeforeColon => f.write_str("`=` appeared before the action `:`"),
            Self::EmptyAction => f.write_str("empty action"),
            Self::UnknownAction(t) => write!(f, "unknown action {t:?}"),
            Self::EmptyKind => f.write_str("empty `<>` kind filter"),
            Self::EmptyName => f.write_str("empty name pattern"),
        }
    }
}

impl std::error::Error for ParseError {}

/// Parse a directive with the open-set [`String`] action vocabulary.
///
/// # Errors
/// Returns [`ParseError`] if the string is malformed (see its variants).
pub fn parse(spec: &str) -> Result<Directive<String>, ParseError> {
    parse_as::<String>(spec)
}

/// Parse a directive, resolving the ACTION token via `A`'s [`Action::from_token`].
///
/// # Errors
/// Returns [`ParseError`] — including [`ParseError::UnknownAction`] when `A::from_token` returns
/// `None` (the typo-catching path for closed-set vocabularies).
pub fn parse_as<A: Action>(spec: &str) -> Result<Directive<A>, ParseError> {
    // ── ACTION ── first unescaped ':' (and no unescaped '=' before it).
    let colon = find_unescaped(spec, 0, b':').ok_or(ParseError::MissingColon)?;
    if let Some(eq) = find_unescaped(spec, 0, b'=') {
        if eq < colon {
            return Err(ParseError::EqualsBeforeColon);
        }
    }
    let action_tok = spec[..colon].trim();
    if action_tok.is_empty() {
        return Err(ParseError::EmptyAction);
    }
    let action = A::from_token(action_tok)
        .ok_or_else(|| ParseError::UnknownAction(action_tok.to_owned()))?;

    let body = &spec[colon + 1..];

    // ── NOTE ── everything after the first unescaped '=' (raw).
    let (head, note) = match find_unescaped(body, 0, b'=') {
        Some(eq) => (&body[..eq], Some(body[eq + 1..].to_owned())),
        None => (body, None),
    };
    let head = head.trim();

    // ── KIND ── optional leading `<…>`.
    let (kind, after_kind) = if head.starts_with('<') {
        if let Some(gt) = find_unescaped(head, 1, b'>') {
            let raw = head[1..gt].trim();
            if raw.is_empty() {
                return Err(ParseError::EmptyKind);
            }
            let kind = if raw == "*" {
                None
            } else {
                Some(unescape(raw))
            };
            (kind, head[gt + 1..].trim_start())
        } else {
            // No closing '>' → the '<' belongs to the NAME (lenient).
            (None, head)
        }
    } else {
        (None, head)
    };

    // ── NAME [@PATH] ──
    let (name_s, path_s) = match find_unescaped(after_kind, 0, b'@') {
        Some(at) => (after_kind[..at].trim(), Some(after_kind[at + 1..].trim())),
        None => (after_kind.trim(), None),
    };
    if name_s.is_empty() {
        return Err(ParseError::EmptyName);
    }

    Ok(Directive {
        action,
        kind,
        name: Pattern::compile(name_s),
        path: path_s.map(Pattern::compile),
        note,
    })
}

/// Byte index of the first unescaped `needle` at or after `from`, honoring `\`-escapes. `needle`
/// is always an ASCII sigil, so returned indices are valid `str` boundaries.
fn find_unescaped(s: &str, from: usize, needle: u8) -> Option<usize> {
    let b = s.as_bytes();
    let mut i = from;
    while i < b.len() {
        match b[i] {
            b'\\' => i += 2,
            c if c == needle => return Some(i),
            _ => i += 1,
        }
    }
    None
}

/// Resolve `\x` → `x` (char-wise, so multibyte escapes survive). Used for KIND, which is an exact
/// literal; NAME/PATH keep their backslashes for the glob compiler.
fn unescape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            out.push(chars.next().unwrap_or('\\'));
        } else {
            out.push(c);
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::{ParseError, parse, parse_as};
    use crate::core::directive::Action;
    use proptest::prelude::*;

    // Closed-set vocab to exercise UnknownAction.
    #[derive(Debug, PartialEq)]
    enum A {
        Suppress,
    }
    impl Action for A {
        fn from_token(t: &str) -> Option<Self> {
            (t == "suppress").then_some(A::Suppress)
        }
    }

    #[test]
    fn note_eats_colon_at_equals() {
        let d = parse("note:foo=a:b@c=d").unwrap();
        assert_eq!(d.name.as_str(), "foo");
        assert_eq!(d.note.as_deref(), Some("a:b@c=d"));
        assert!(d.path.is_none());
    }

    #[test]
    fn name_keeps_colons() {
        let d = parse("suppress:Foo::bar").unwrap();
        assert_eq!(d.name.as_str(), "Foo::bar");
        assert!(d.kind.is_none());
    }

    #[test]
    fn unclosed_angle_is_name() {
        let d = parse("suppress:<unclosed").unwrap();
        assert!(d.kind.is_none());
        assert_eq!(d.name.as_str(), "<unclosed");
    }

    #[test]
    fn empty_name_errors() {
        assert_eq!(parse("suppress:@p"), Err(ParseError::EmptyName));
        assert_eq!(parse("suppress:=x"), Err(ParseError::EmptyName));
        assert_eq!(parse("suppress:"), Err(ParseError::EmptyName));
    }

    #[test]
    fn note_optional() {
        let d = parse("note:foo").unwrap();
        assert!(d.note.is_none());
    }

    #[test]
    fn equals_before_colon_and_empty_action() {
        assert_eq!(parse("a=b:c"), Err(ParseError::EqualsBeforeColon));
        assert_eq!(parse(":foo"), Err(ParseError::EmptyAction));
        assert_eq!(parse("noseparator"), Err(ParseError::MissingColon));
    }

    #[test]
    fn second_at_is_literal_path() {
        let d = parse("note:foo@a@b").unwrap();
        assert_eq!(d.name.as_str(), "foo");
        assert_eq!(d.path.as_ref().unwrap().as_str(), "a@b");
    }

    #[test]
    fn kind_any_vs_empty() {
        assert!(parse("suppress:<*>foo").unwrap().kind.is_none());
        assert_eq!(parse("suppress:<>foo"), Err(ParseError::EmptyKind));
        assert_eq!(
            parse("suppress:<fn>foo").unwrap().kind.as_deref(),
            Some("fn")
        );
    }

    #[test]
    fn open_set_accepts_unknown_closed_rejects() {
        assert_eq!(parse("wibble:foo").unwrap().action, "wibble");
        assert_eq!(
            parse_as::<A>("wibble:foo"),
            Err(ParseError::UnknownAction("wibble".to_owned()))
        );
        assert_eq!(parse_as::<A>("suppress:foo").unwrap().action, A::Suppress);
    }

    #[test]
    fn whitespace_trimmed() {
        let d = parse("  suppress : <fn> foo @ *p ").unwrap();
        assert_eq!(d.action, "suppress");
        assert_eq!(d.kind.as_deref(), Some("fn"));
        assert_eq!(d.name.as_str(), "foo");
        assert_eq!(d.path.as_ref().unwrap().as_str(), "*p");
    }

    // escaped sigils into the name; cost\=5
    #[test]
    fn escaped_sigils_in_name() {
        let d = parse(r"suppress:cost\=5").unwrap();
        assert_eq!(d.name.as_str(), r"cost\=5");
        assert!(d.note.is_none());
        assert!(d.name.matches("cost=5"));

        let d = parse(r"suppress:\<a>b").unwrap();
        assert!(d.kind.is_none());
        assert!(d.name.matches("<a>b"));
    }

    // set:k=v shape
    #[test]
    fn set_shape() {
        let d = parse("set:max-name-group=256").unwrap();
        assert_eq!(d.action, "set");
        assert_eq!(d.name.as_str(), "max-name-group");
        assert_eq!(d.note.as_deref(), Some("256"));
    }

    proptest! {
        // The parser is total over arbitrary input: it returns a `Result`, never panics.
        #[test]
        fn parse_never_panics(s in ".*") {
            let _ = parse(&s);
        }

        // Every successful parse upholds the grammar's invariants: a non-empty action and a
        // non-empty NAME (NAME is the one required field).
        #[test]
        fn ok_upholds_invariants(s in ".*") {
            if let Ok(d) = parse(&s) {
                prop_assert!(!d.action.is_empty());
                prop_assert!(!d.name.as_str().is_empty());
            }
        }
    }
}