#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub line: u32,
pub column: u32,
}
impl std::fmt::Display for Span {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}, column {}", self.line, self.column)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tok {
Ident(String),
Str(String),
Equals,
OpenBrace,
CloseBrace,
OpenBracket,
CloseBracket,
Comma,
Semicolon,
Arrow,
}
impl Tok {
pub fn describe(&self) -> String {
match self {
Tok::Ident(name) => format!("`{name}`"),
Tok::Str(_) => "a quoted string".into(),
Tok::Equals => "`=`".into(),
Tok::OpenBrace => "`{`".into(),
Tok::CloseBrace => "`}`".into(),
Tok::OpenBracket => "`[`".into(),
Tok::CloseBracket => "`]`".into(),
Tok::Comma => "`,`".into(),
Tok::Semicolon => "`;`".into(),
Tok::Arrow => "`->`".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub tok: Tok,
pub span: Span,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum LexError {
#[error("unterminated string starting at {span}")]
UnterminatedString {
span: Span,
},
#[error("unexpected character `{ch}` at {span}")]
UnexpectedChar {
ch: char,
span: Span,
},
#[error("unknown escape `\\{ch}` at {span}")]
UnknownEscape {
ch: char,
span: Span,
},
}
pub fn lex(src: &str) -> Result<Vec<Token>, LexError> {
let mut tokens = Vec::new();
let mut chars = src.chars().peekable();
let (mut line, mut column) = (1u32, 1u32);
macro_rules! bump {
() => {{
let c = chars.next();
match c {
Some('\n') => {
line += 1;
column = 1;
}
Some(_) => column += 1,
None => {}
}
c
}};
}
while let Some(&c) = chars.peek() {
let span = Span { line, column };
macro_rules! skip_to_end_of_line {
() => {{
while let Some(&c) = chars.peek() {
if c == '\n' {
break;
}
bump!();
}
}};
}
match c {
c if c.is_whitespace() => {
bump!();
}
'#' => {
skip_to_end_of_line!();
}
'/' if chars.clone().nth(1) == Some('/') => {
skip_to_end_of_line!();
}
'"' => {
bump!();
let mut value = String::new();
loop {
match bump!() {
None => return Err(LexError::UnterminatedString { span }),
Some('"') => break,
Some('\\') => {
let escape_span = Span { line, column };
match bump!() {
Some('"') => value.push('"'),
Some('\\') => value.push('\\'),
Some('n') => value.push('\n'),
Some('t') => value.push('\t'),
Some(other) => {
return Err(LexError::UnknownEscape {
ch: other,
span: escape_span,
})
}
None => return Err(LexError::UnterminatedString { span }),
}
}
Some(other) => value.push(other),
}
}
tokens.push(Token {
tok: Tok::Str(value),
span,
});
}
'-' => {
let mut la = chars.clone();
la.next();
if la.peek() == Some(&'>') {
bump!();
bump!();
tokens.push(Token {
tok: Tok::Arrow,
span,
});
} else {
return Err(LexError::UnexpectedChar { ch: '-', span });
}
}
c if c.is_alphanumeric() || c == '_' || c == '.' || c == '/' => {
let mut word = String::new();
while let Some(&c) = chars.peek() {
if c.is_alphanumeric() || matches!(c, '_' | '.' | '/' | ':') {
word.push(c);
bump!();
} else {
break;
}
}
tokens.push(Token {
tok: Tok::Ident(word),
span,
});
}
_ => {
let tok = match c {
'=' => Tok::Equals,
'{' => Tok::OpenBrace,
'}' => Tok::CloseBrace,
'[' => Tok::OpenBracket,
']' => Tok::CloseBracket,
',' => Tok::Comma,
';' => Tok::Semicolon,
other => return Err(LexError::UnexpectedChar { ch: other, span }),
};
bump!();
tokens.push(Token { tok, span });
}
}
}
Ok(tokens)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_arrow_lexes_as_one_token() {
let tokens = lex(r#""pdf" -> handle_pdf"#).unwrap();
assert_eq!(
tokens.iter().map(|t| t.tok.clone()).collect::<Vec<_>>(),
vec![
Tok::Str("pdf".into()),
Tok::Arrow,
Tok::Ident("handle_pdf".into()),
]
);
}
#[test]
fn a_lone_hyphen_is_still_an_error() {
assert!(lex("- foo").is_err());
}
}
#[cfg(test)]
mod comment_tests {
use super::*;
#[test]
fn both_comment_syntaxes_run_to_end_of_line() {
let hash = lex("# note\nspec").expect("`#` comments must lex");
let slash = lex("// note\nspec").expect("`//` comments must lex");
assert_eq!(
hash.len(),
slash.len(),
"the two syntaxes must produce identical token streams"
);
lex("// call infer(prompt, 32) here\nspec").expect("prose in a comment must be skipped");
lex("# call infer(prompt, 32) here\nspec").unwrap();
}
#[test]
fn a_comment_ends_at_the_newline_and_not_before_or_after() {
let tokens = lex("# gone\nkept").unwrap();
assert_eq!(tokens.len(), 1, "{tokens:?}");
assert!(lex("kept # gone").is_ok());
}
#[test]
fn a_single_slash_is_still_an_ordinary_identifier_character() {
let tokens = lex("team/cat").expect("a slash inside an identifier must still lex");
assert_eq!(tokens.len(), 1, "{tokens:?}");
assert_eq!(lex("a/b c").unwrap().len(), 2);
}
#[test]
fn the_spec_that_failed_in_the_field_now_parses() {
let src = r#"spec demo = {
description = "Use when testing.";
model = Stub "unused";
// the model is called via infer(prompt, 32) inside the block
data_policy = Local_only;
capabilities = [ ];
nodes = { check = { block = "./check.rhai"; }; };
}
"#;
crate::spec::parse_spec(src).expect("a spec with `//` comments must parse");
}
}