#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Token {
Ident(String),
Qualified {
left: String,
right: String,
},
OpenParen,
CloseParen,
OpenBrace,
CloseBrace,
Arrow,
Comma,
StringLiteral(String),
Other(char),
}
pub(crate) fn tokenise(source: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let chars: Vec<char> = source.chars().collect();
let mut index = 0;
while index < chars.len() {
let current = chars[index];
if current == '/' && chars.get(index + 1) == Some(&'/') {
index = skip_line_comment(&chars, index);
} else if current.is_whitespace() {
index += 1;
} else if current == '"' {
let (literal, next) = read_string(&chars, index);
tokens.push(Token::StringLiteral(literal));
index = next;
} else if current == '(' {
tokens.push(Token::OpenParen);
index += 1;
} else if current == ')' {
tokens.push(Token::CloseParen);
index += 1;
} else if current == '{' {
tokens.push(Token::OpenBrace);
index += 1;
} else if current == '}' {
tokens.push(Token::CloseBrace);
index += 1;
} else if current == ',' {
tokens.push(Token::Comma);
index += 1;
} else if current == '-' && chars.get(index + 1) == Some(&'>') {
tokens.push(Token::Arrow);
index += 2;
} else if is_ident_start(current) {
let (ident, next) = read_ident(&chars, index);
index = next;
if chars.get(index) == Some(&'.')
&& chars.get(index + 1).is_some_and(|c| is_ident_start(*c))
{
let (member, after) = read_ident(&chars, index + 1);
tokens.push(Token::Qualified {
left: ident,
right: member,
});
index = after;
} else {
tokens.push(Token::Ident(ident));
}
} else {
tokens.push(Token::Other(current));
index += 1;
}
}
tokens
}
fn skip_line_comment(chars: &[char], start: usize) -> usize {
let mut index = start;
while index < chars.len() && chars[index] != '\n' {
index += 1;
}
index
}
fn read_string(chars: &[char], start: usize) -> (String, usize) {
let mut literal = String::new();
let mut index = start + 1;
while index < chars.len() {
let current = chars[index];
if current == '\\' {
if let Some(next) = chars.get(index + 1) {
literal.push(unescape(*next));
index += 2;
continue;
}
index += 1;
} else if current == '"' {
return (literal, index + 1);
} else {
literal.push(current);
index += 1;
}
}
(literal, index)
}
fn unescape(escaped: char) -> char {
match escaped {
'n' => '\n',
't' => '\t',
'r' => '\r',
other => other,
}
}
fn read_ident(chars: &[char], start: usize) -> (String, usize) {
let mut ident = String::new();
let mut index = start;
while index < chars.len() && is_ident_continue(chars[index]) {
ident.push(chars[index]);
index += 1;
}
(ident, index)
}
fn is_ident_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn is_ident_continue(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::{Token, tokenise};
#[test]
fn qualified_calls_are_recognised() {
let tokens = tokenise("workflow.run(x)");
assert_eq!(
tokens.first(),
Some(&Token::Qualified {
left: "workflow".to_owned(),
right: "run".to_owned(),
})
);
}
#[test]
fn line_comments_are_excluded() {
let tokens = tokenise("// workflow.run(should_not_match)\nworkflow.all(y)");
let qualified: Vec<_> = tokens
.iter()
.filter_map(|token| match token {
Token::Qualified { left, right } => Some((left.as_str(), right.as_str())),
_ => None,
})
.collect();
assert_eq!(qualified, vec![("workflow", "all")]);
}
#[test]
fn string_contents_do_not_match_but_are_recoverable() {
let tokens = tokenise("start_timer(\"workflow.run\")");
assert!(tokens.contains(&Token::StringLiteral("workflow.run".to_owned())));
assert!(!tokens.iter().any(|token| matches!(
token,
Token::Qualified { left, right } if left == "workflow" && right == "run"
)));
}
#[test]
fn escapes_in_strings_are_unescaped() {
let tokens = tokenise("(\"a\\nb\")");
assert!(tokens.contains(&Token::StringLiteral("a\nb".to_owned())));
}
#[test]
fn braces_arrow_and_comma_are_emitted() {
let tokens = tokenise("case x { Ok(a) -> b, Error(e) -> c }");
assert!(tokens.contains(&Token::OpenBrace));
assert!(tokens.contains(&Token::CloseBrace));
assert!(tokens.contains(&Token::Arrow));
assert!(tokens.contains(&Token::Comma));
assert!(tokens.contains(&Token::Ident("case".to_owned())));
}
#[test]
fn arrow_does_not_swallow_a_lone_minus() {
let tokens = tokenise("a - b");
assert!(tokens.contains(&Token::Other('-')));
assert!(!tokens.contains(&Token::Arrow));
}
}