use crate::core::directive::{Action, Directive};
use crate::core::glob::Pattern;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ParseError {
MissingColon,
EqualsBeforeColon,
EmptyAction,
UnknownAction(String),
EmptyKind,
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 {}
pub fn parse(spec: &str) -> Result<Directive<String>, ParseError> {
parse_as::<String>(spec)
}
pub fn parse_as<A: Action>(spec: &str) -> Result<Directive<A>, ParseError> {
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..];
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();
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 {
(None, head)
}
} else {
(None, head)
};
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,
})
}
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
}
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::*;
#[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");
}
#[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"));
}
#[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! {
#[test]
fn parse_never_panics(s in ".*") {
let _ = parse(&s);
}
#[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());
}
}
}
}