ppoppo-sigil-parser 0.1.0

Parser for the ppoppo `@@/` chat sigil grammar — entity references, verbs, scopes, modifiers
Documentation
use crate::types::{Modifier, NamedRef, ParseError, Scope, ScopeRef, SigilCommand, SigilRef, Verb};

/// Parse a raw message string into a [`SigilCommand`].
///
/// Returns [`ParseError::NotSigil`] if the input does not start with `@@/`.
///
/// # Examples
///
/// ```
/// use ppoppo_sigil_parser::parse;
///
/// let cmd = parse("@@/ls #*").unwrap();
/// assert_eq!(cmd.verb, ppoppo_sigil_parser::Verb::Ls);
/// ```
pub fn parse(input: &str) -> Result<SigilCommand, ParseError> {
    let body = input.strip_prefix(crate::TRIGGER).ok_or(ParseError::NotSigil)?;

    let mut tokens = body.split_whitespace();

    let verb = parse_verb(tokens.next().ok_or(ParseError::MissingVerb)?)?;
    let (target, remaining_start) = parse_target(verb, &mut tokens)?;
    let args: Vec<String> = remaining_start
        .into_iter()
        .chain(tokens.map(String::from))
        .collect();

    // find requires a search term
    if verb == Verb::Find && target.is_some() && args.is_empty() {
        return Err(ParseError::MissingSearchTerm { verb });
    }

    // save requires either a positional target or literal text args
    if verb == Verb::Save && target.is_none() && args.is_empty() {
        return Err(ParseError::MissingTarget { verb });
    }

    Ok(SigilCommand { verb, target, args })
}

fn parse_verb(s: &str) -> Result<Verb, ParseError> {
    match s {
        "ls" => Ok(Verb::Ls),
        "cat" => Ok(Verb::Cat),
        "find" => Ok(Verb::Find),
        "grep" => Ok(Verb::Grep),
        "man" => Ok(Verb::Man),
        "save" => Ok(Verb::Save),
        "set" => Ok(Verb::Set),
        "rm" => Ok(Verb::Rm),
        "lbl" => Ok(Verb::Lbl),
        other => Err(ParseError::UnknownVerb(other.to_owned())),
    }
}

/// Parse the target from the next token.
///
/// Returns `(target, leftover)` where leftover is `Some(token)` if the token
/// was consumed as a non-sigil arg (only for verbs where target is optional).
fn parse_target(
    verb: Verb,
    tokens: &mut dyn Iterator<Item = &str>,
) -> Result<(Option<SigilRef>, Option<String>), ParseError> {
    let Some(token) = tokens.next() else {
        return if verb.requires_target() {
            Err(ParseError::MissingTarget { verb })
        } else {
            Ok((None, None))
        };
    };

    if !is_ref_start(token) {
        // Token is not a reference. For target-optional verbs, treat as arg.
        return if verb.requires_target() {
            Err(ParseError::MissingTarget { verb })
        } else {
            Ok((None, Some(token.to_owned())))
        };
    }

    let target = parse_ref_token(token)?;
    Ok((Some(target), None))
}

/// Check if a token starts a reference (scope, positional, named, or ULID).
fn is_ref_start(token: &str) -> bool {
    match token.as_bytes().first() {
        Some(b'@' | b'#' | b'^' | b':') => true,
        Some(b'~') => true,  // deprecated but we still detect it to give error
        Some(b'*') => true,  // deprecated but we still detect it to give error
        _ => is_ulid(token),
    }
}

/// Parse a single token into a [`SigilRef`].
fn parse_ref_token(token: &str) -> Result<SigilRef, ParseError> {
    match token.as_bytes().first() {
        Some(b'@') => parse_scope(Scope::User, &token[1..]),
        Some(b'#') => parse_channel_scope(token),
        Some(b'^') => parse_positional(token),
        Some(b':') => parse_named(&token[1..]),
        Some(b'~') => Err(ParseError::DeprecatedSigil(
            "~ is removed. Use @~ (self) or #~ (current channel)".into(),
        )),
        Some(b'*') => Err(ParseError::DeprecatedSigil(
            "Bare * is removed. Use @* or #* separately".into(),
        )),
        _ if is_ulid(token) => Ok(SigilRef::Id(token.to_owned())),
        _ => Err(ParseError::InvalidTarget {
            got: token.to_owned(),
        }),
    }
}

fn parse_scope(scope: Scope, rest: &str) -> Result<SigilRef, ParseError> {
    let modifier = match rest {
        "~" => Modifier::Current,
        "*" => Modifier::All,
        "" => {
            let sigil = match scope {
                Scope::User => "Bare @ is removed. Use @*",
                Scope::Channel => "Bare # is removed. Use #*",
            };
            return Err(ParseError::DeprecatedSigil(sigil.into()));
        }
        name => Modifier::Name(name.to_owned()),
    };
    Ok(SigilRef::Scope(ScopeRef { scope, modifier }))
}

fn parse_channel_scope(token: &str) -> Result<SigilRef, ParseError> {
    // Check for deprecated #here
    if token == "#here" {
        return Err(ParseError::DeprecatedSigil(
            "#here is removed. Use #~".into(),
        ));
    }
    parse_scope(Scope::Channel, &token[1..])
}

fn parse_positional(token: &str) -> Result<SigilRef, ParseError> {
    if token.chars().all(|c| c == '^') && !token.is_empty() {
        let Some(depth) = std::num::NonZeroU8::new(token.len() as u8) else {
            return Err(ParseError::InvalidTarget {
                got: token.to_owned(),
            });
        };
        Ok(SigilRef::Positional(depth))
    } else {
        Err(ParseError::InvalidTarget {
            got: token.to_owned(),
        })
    }
}

fn parse_named(rest: &str) -> Result<SigilRef, ParseError> {
    match rest {
        "*" => Ok(SigilRef::Named(NamedRef::All)),
        "" => Err(ParseError::InvalidTarget {
            got: ":".to_owned(),
        }),
        label => Ok(SigilRef::Named(NamedRef::Label(label.to_owned()))),
    }
}

/// ULID: exactly 26 chars, all ASCII alphanumeric.
fn is_ulid(token: &str) -> bool {
    token.len() == 26 && token.bytes().all(|b| b.is_ascii_alphanumeric())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Deserialize;

    #[derive(Deserialize)]
    struct TestCase {
        input: String,
        verb: Option<String>,
        target: Option<TargetSpec>,
        args: Option<Vec<String>>,
        error: Option<ErrorSpec>,
        #[serde(rename = "_comment")]
        _comment: Option<String>,
    }

    #[derive(Deserialize)]
    struct TargetSpec {
        #[serde(rename = "type")]
        kind: String,
        // scope sigil fields
        scope: Option<String>,
        modifier: Option<String>,
        name: Option<String>,
        // positional
        depth: Option<u8>,
        // id
        value: Option<String>,
        // named
        label: Option<String>,
    }

    #[derive(Deserialize)]
    struct ErrorSpec {
        #[serde(rename = "type")]
        kind: String,
        verb: Option<String>,
        got: Option<String>,
        message: Option<String>,
    }

    fn load_cases() -> Vec<TestCase> {
        let json = include_str!("../fixtures/cases.json");
        serde_json::from_str(json).expect("fixtures/cases.json should be valid")
    }

    fn expected_verb(s: &str) -> Verb {
        match s {
            "ls" => Verb::Ls,
            "cat" => Verb::Cat,
            "find" => Verb::Find,
            "grep" => Verb::Grep,
            "man" => Verb::Man,
            "save" => Verb::Save,
            "set" => Verb::Set,
            "rm" => Verb::Rm,
            "lbl" => Verb::Lbl,
            other => panic!("unknown verb in fixture: {other}"),
        }
    }

    fn expected_target(spec: &TargetSpec) -> SigilRef {
        match spec.kind.as_str() {
            "scope" => {
                let scope = match spec.scope.as_deref().unwrap() {
                    "user" => Scope::User,
                    "channel" => Scope::Channel,
                    s => panic!("unknown scope: {s}"),
                };
                let modifier = match spec.modifier.as_deref().unwrap() {
                    "current" => Modifier::Current,
                    "all" => Modifier::All,
                    "name" => Modifier::Name(spec.name.clone().unwrap()),
                    m => panic!("unknown modifier: {m}"),
                };
                SigilRef::Scope(ScopeRef { scope, modifier })
            }
            "positional" => SigilRef::Positional(std::num::NonZeroU8::new(spec.depth.unwrap()).unwrap()),
            "id" => SigilRef::Id(spec.value.clone().unwrap()),
            "named" => {
                if spec.modifier.as_deref() == Some("all") {
                    SigilRef::Named(NamedRef::All)
                } else {
                    SigilRef::Named(NamedRef::Label(spec.label.clone().unwrap()))
                }
            }
            other => panic!("unknown target type: {other}"),
        }
    }

    fn expected_error(spec: &ErrorSpec) -> ParseError {
        match spec.kind.as_str() {
            "not_sigil" => ParseError::NotSigil,
            "missing_verb" => ParseError::MissingVerb,
            "unknown_verb" => {
                ParseError::UnknownVerb(spec.got.clone().unwrap())
            }
            "missing_target" => ParseError::MissingTarget {
                verb: expected_verb(spec.verb.as_deref().unwrap()),
            },
            "deprecated_sigil" => {
                ParseError::DeprecatedSigil(spec.message.clone().unwrap())
            }
            "missing_search_term" => ParseError::MissingSearchTerm {
                verb: expected_verb(spec.verb.as_deref().unwrap()),
            },
            other => panic!("unknown error type: {other}"),
        }
    }

    #[test]
    fn fixture_cases() {
        let cases = load_cases();
        assert!(!cases.is_empty(), "fixture file should not be empty");

        for (i, case) in cases.iter().enumerate() {
            let result = parse(&case.input);

            if let Some(ref err_spec) = case.error {
                let err = result.unwrap_err_or_else(i, &case.input);
                let expected = expected_error(err_spec);
                assert_eq!(err, expected, "case {i}: input={:?}", case.input);
            } else {
                let cmd = result.unwrap_or_else(|e| {
                    panic!("case {i}: input={:?} should parse, got {e}", case.input)
                });

                let verb_str = case.verb.as_deref().expect("success case needs verb");
                assert_eq!(cmd.verb, expected_verb(verb_str), "case {i}: verb mismatch for {:?}", case.input);

                match (&cmd.target, &case.target) {
                    (Some(actual), Some(spec)) => {
                        assert_eq!(*actual, expected_target(spec), "case {i}: target mismatch for {:?}", case.input);
                    }
                    (None, None) => {}
                    _ => panic!(
                        "case {i}: target presence mismatch for {:?}: got {:?}, expected {:?}",
                        case.input, cmd.target, case.target.as_ref().map(|t| &t.kind)
                    ),
                }

                let expected_args = case.args.as_deref().unwrap_or(&[]);
                assert_eq!(cmd.args, expected_args, "case {i}: args mismatch for {:?}", case.input);
            }
        }
    }

    trait UnwrapErr<T> {
        fn unwrap_err_or_else(self, case_idx: usize, input: &str) -> T;
    }

    impl UnwrapErr<ParseError> for Result<SigilCommand, ParseError> {
        fn unwrap_err_or_else(self, case_idx: usize, input: &str) -> ParseError {
            match self {
                Err(e) => e,
                Ok(cmd) => panic!(
                    "case {case_idx}: input={input:?} should fail, got {cmd:?}"
                ),
            }
        }
    }
}