use crate::types::{Modifier, NamedRef, ParseError, Scope, ScopeRef, SigilCommand, SigilRef, Verb};
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();
if verb == Verb::Find && target.is_some() && args.is_empty() {
return Err(ParseError::MissingSearchTerm { verb });
}
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())),
}
}
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) {
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))
}
fn is_ref_start(token: &str) -> bool {
match token.as_bytes().first() {
Some(b'@' | b'#' | b'^' | b':') => true,
Some(b'~') => true, Some(b'*') => true, _ => is_ulid(token),
}
}
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> {
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()))),
}
}
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: Option<String>,
modifier: Option<String>,
name: Option<String>,
depth: Option<u8>,
value: Option<String>,
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:?}"
),
}
}
}
}