use crate::error::{QuarbError, Result};
#[derive(Debug, PartialEq, Eq)]
pub enum Token {
Slash,
SlashSlash,
Backslash,
BackslashBackslash,
Gt,
NextSibling,
PrevSibling,
FollowingSiblings(char),
Lt,
PrecedingSiblings(char),
Question,
QuestionEq,
Bang,
Caret,
Dollar,
ColonColon,
ColonColonColon,
SemiSemiSemi,
Pipe,
PipePipe,
LBracket,
RBracket,
LParen,
RParen,
Comma,
Eq,
Ne,
Le,
Ge,
Match,
NotMatch,
Contains,
ArrowOut,
ArrowIn,
DashDash,
At,
Percent,
Amp,
AmpAmp,
Colon,
Field,
PercentPlus,
Semi,
Correlate,
Resolve,
ReverseResolve,
Quant { min: usize, max: Option<usize> },
Regex(String),
Subst {
pattern: String,
replacement: String,
mods: String,
},
Name {
text: String,
quoted: bool,
glued: bool,
},
Interp(Vec<InterpPart>),
Shell(Vec<InterpPart>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InterpPart {
Text(String),
Hole(String),
}
fn is_name_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | '*' | '+')
}
fn is_iso_datetime_prefix(text: &str) -> bool {
let b = text.as_bytes();
b.len() >= 11
&& b[..4].iter().all(u8::is_ascii_digit)
&& b[4] == b'-'
&& b[5..7].iter().all(u8::is_ascii_digit)
&& b[7] == b'-'
&& b[8..10].iter().all(u8::is_ascii_digit)
&& b[10] == b'T'
&& b[11..]
.iter()
.all(|c| c.is_ascii_digit() || matches!(c, b':' | b'.' | b'+' | b'-' | b'Z'))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Opener {
Paren,
RoundedPredicate,
RoundedTrait,
Bracket,
}
fn after_accessor(tokens: &[Token], glued: bool) -> bool {
glued && matches!(tokens.last(), Some(Token::Dollar | Token::At))
}
fn paren_unit(chars: &[char], i: usize) -> Option<(Vec<Token>, usize)> {
let name = |t: &str| Token::Name {
text: t.to_string(),
quoted: false,
glued: true,
};
let double = chars.get(i + 1) == Some(&'(');
let start = if double { i + 2 } else { i + 1 };
let mut j = start;
while chars
.get(j)
.is_some_and(|&c| is_name_char(c) || c == '@')
{
j += 1;
}
let body: String = chars[start..j].iter().collect();
if double {
if chars.get(j) != Some(&')') || chars.get(j + 1) != Some(&')') {
return None;
}
let toks = match body.as_str() {
"" => vec![Token::Caret],
"_" => vec![Token::Dollar, Token::Dollar],
"*" | "@" => vec![Token::LParen, Token::At, Token::RParen],
b if b.starts_with('*') || b.starts_with('@') => {
vec![Token::LParen, Token::At, name(&b[1..]), Token::RParen]
}
b => vec![Token::LParen, name(b), Token::RParen],
};
return Some((toks, j + 2));
}
if chars.get(j) != Some(&')') {
return None;
}
let toks = match body.as_str() {
"_" | "-" | "." => vec![Token::Dollar, name(&body)],
"*" => vec![Token::At, name("*")],
"*." => vec![Token::At, name(".")],
"*-" => vec![Token::At, name("-")],
b if b.starts_with('.') => vec![Token::Dollar, name(b)],
b if b.starts_with('*') && b[1..].bytes().all(|c| c.is_ascii_digit()) => {
vec![Token::Dollar, name(b)]
}
b if !b.is_empty() && b.bytes().all(|c| c.is_ascii_digit()) => {
vec![Token::Dollar, name(b)]
}
_ => return None,
};
Some((toks, j + 1))
}
fn after_whitespace(chars: &[char], i: usize) -> bool {
i == 0 || chars[i - 1].is_whitespace()
}
fn after_push_dot(chars: &[char], i: usize) -> bool {
let mut k = i;
while k > 0 && is_name_char(chars[k - 1]) && chars[k - 1] != '.' {
k -= 1;
}
k > 0 && chars[k - 1] == '.' && !(k > 1 && chars[k - 2] == '.')
}
fn after_axis(tokens: &[Token]) -> bool {
let axis = |t: &Token| {
matches!(
t,
Token::Slash
| Token::SlashSlash
| Token::Backslash
| Token::BackslashBackslash
| Token::Gt
| Token::Lt
| Token::NextSibling
| Token::PrevSibling
| Token::FollowingSiblings(_)
| Token::PrecedingSiblings(_)
| Token::ArrowOut
| Token::ArrowIn
| Token::DashDash
| Token::Resolve
| Token::ReverseResolve
)
};
match tokens.last() {
Some(t) if axis(t) => true,
Some(Token::Question | Token::Bang) => tokens.len() >= 2 && axis(&tokens[tokens.len() - 2]),
_ => false,
}
}
fn regex_paren(chars: &[char], i: usize, mods: bool) -> Option<(String, usize)> {
let mut body = String::new();
let mut j = i + 2;
while let Some(&c) = chars.get(j) {
match c {
'\\' if chars.get(j + 1) == Some(&'/') => {
body.push('/');
j += 2;
}
'\\' => {
body.push('\\');
if let Some(&n) = chars.get(j + 1) {
body.push(n);
}
j += 2;
}
'/' => {
let mut k = j + 1;
let mut flags = String::new();
if mods {
while let Some(&m @ ('i' | 'm' | 's' | 'x')) = chars.get(k) {
flags.push(m);
k += 1;
}
}
if chars.get(k) == Some(&')') {
let body = if flags.is_empty() {
body
} else {
format!("(?{flags}){body}")
};
return Some((body, k + 1));
}
body.push('/');
j += 1;
}
_ => {
body.push(c);
j += 1;
}
}
}
None
}
fn after_call_head(tokens: &[Token], glued: bool) -> bool {
glued
&& matches!(
tokens.last(),
Some(Token::Name { .. } | Token::Percent | Token::Amp | Token::Dollar | Token::At)
)
}
pub fn lex(input: &str) -> Result<Vec<Token>> {
let chars: Vec<char> = input.chars().collect();
let mut tokens = Vec::new();
let mut openers: Vec<Opener> = Vec::new();
let mut i = 0;
let at = |j: usize| chars.get(j).copied();
let mut spaced = true;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() {
i += 1;
spaced = true;
continue;
}
let glued = !spaced;
spaced = false;
match c {
's' if at(i + 1) == Some('/') && matches!(tokens.last(), Some(Token::Pipe)) => {
i += 2;
let mut parts: Vec<String> = vec![String::new()];
loop {
match at(i) {
Some('\\') if at(i + 1) == Some('/') => {
parts.last_mut().expect("nonempty").push('/');
i += 2;
}
Some('/') => {
i += 1;
if parts.len() == 2 {
break;
}
parts.push(String::new());
}
Some(ch) => {
parts.last_mut().expect("nonempty").push(ch);
i += 1;
}
None => {
return Err(QuarbError::Lex(
"unterminated substitution 's/pat/repl/'".into(),
));
}
}
}
let mut mods = String::new();
while let Some(ch) = at(i) {
if ch.is_ascii_alphabetic() {
mods.push(ch);
i += 1;
} else {
break;
}
}
let replacement = parts.pop().expect("two parts");
let pattern = parts.pop().expect("two parts");
tokens.push(Token::Subst {
pattern,
replacement,
mods,
});
}
'/' if matches!(tokens.last(), Some(Token::Match | Token::NotMatch)) => {
let mut body = String::new();
i += 1;
loop {
match at(i) {
Some('\\') if at(i + 1) == Some('/') => {
body.push('/');
i += 2;
}
Some('/') => {
i += 1;
break;
}
Some(ch) => {
body.push(ch);
i += 1;
}
None => {
return Err(QuarbError::Lex("unterminated regex '/…'".into()));
}
}
}
let mut flags = String::new();
while let Some(m @ ('i' | 'm' | 's' | 'x')) = at(i) {
flags.push(m);
i += 1;
}
if flags.is_empty() {
tokens.push(Token::Regex(body));
} else {
tokens.push(Token::Regex(format!("(?{flags}){body}")));
}
}
'/' if at(i + 1) == Some('/') => {
tokens.push(Token::SlashSlash);
i += 2;
}
'/' => {
tokens.push(Token::Slash);
i += 1;
}
'\\' if at(i + 1) == Some('\\') => {
tokens.push(Token::BackslashBackslash);
i += 2;
}
'\\' => {
tokens.push(Token::Backslash);
i += 1;
}
'-' if at(i + 1) == Some('>') => {
tokens.push(Token::ArrowOut);
i += 2;
}
'-' if at(i + 1) == Some('-') && at(i + 2) == Some('>') => {
tokens.push(Token::Resolve);
i += 3;
}
'_' if at(i + 1) == Some('_') && !at(i + 2).is_some_and(is_name_char) => {
tokens.push(Token::Pipe);
i += 2;
}
'*' if at(i + 1) == Some('_')
&& at(i + 2) == Some('_')
&& !at(i + 3).is_some_and(is_name_char) =>
{
tokens.push(Token::At);
tokens.push(Token::Pipe);
i += 3;
}
'*' if at(i + 1) == Some('(') && after_whitespace(&chars, i) => {
tokens.push(Token::At);
i += 1;
}
'@' if at(i + 1) == Some('(') => {
if !after_whitespace(&chars, i) {
return Err(QuarbError::Parse(
"a list literal follows whitespace: write ' @(…)'".into(),
));
}
tokens.push(Token::At);
i += 1;
}
'%' if matches!((at(i + 1), at(i + 2)), (Some('('), _) | (Some('%'), Some('('))) => {
if !after_whitespace(&chars, i) && !after_push_dot(&chars, i) {
return Err(QuarbError::Parse(
"a record sigil follows whitespace or a push dot: write ' %(…)' or '.name%(…)'".into(),
));
}
tokens.push(Token::Percent);
i += 1;
if at(i) == Some('%') {
tokens.push(Token::Percent);
i += 1;
}
}
',' if at(i + 1) == Some('_')
&& at(i + 2) == Some('_')
&& !at(i + 3).is_some_and(is_name_char) =>
{
tokens.push(Token::Dollar);
tokens.push(Token::Pipe);
i += 3;
}
':' if at(i + 1) == Some('=') && at(i + 2) == Some(':') => {
tokens.push(Token::Correlate);
i += 3;
}
'-' if at(i + 1) == Some('-')
&& at(i + 2) == Some(':')
&& at(i + 3) != Some(':')
&& !after_accessor(&tokens, glued) =>
{
tokens.push(Token::ReverseResolve);
i += 3;
}
'-' if at(i + 1) == Some('-') => {
tokens.push(Token::DashDash);
i += 2;
}
'-' if at(i + 1) == Some(':')
&& at(i + 2) != Some(':')
&& !after_accessor(&tokens, glued) =>
{
tokens.push(Token::ArrowIn);
i += 2;
}
'-' if at(i + 1) == Some(';')
&& at(i + 2) == Some(';')
&& !after_accessor(&tokens, glued) =>
{
let mark = match at(i + 3) {
Some(m @ ('?' | '!')) => {
i += 1;
m
}
_ => ' ',
};
tokens.push(Token::PrecedingSiblings(mark));
i += 3;
}
'-' if at(i + 1) == Some(';') && !after_accessor(&tokens, glued) => {
tokens.push(Token::PrevSibling);
i += 2;
}
'<' if at(i + 1) == Some('<') => {
let mark = match at(i + 2) {
Some(m @ ('?' | '!')) => {
i += 1;
m
}
_ => ' ',
};
tokens.push(Token::PrecedingSiblings(mark));
i += 2;
}
'<' if at(i + 1) == Some('-')
&& at(i + 2) == Some('-')
&& !at(i + 3).is_some_and(|c| c.is_ascii_digit()) =>
{
tokens.push(Token::ReverseResolve);
i += 3;
}
'<' if at(i + 1) == Some('-') && !at(i + 2).is_some_and(|c| c.is_ascii_digit()) => {
tokens.push(Token::ArrowIn);
i += 2;
}
'<' if at(i + 1) == Some('~') => {
tokens.push(Token::ReverseResolve);
i += 2;
}
'<' if at(i + 1) == Some('=') && at(i + 2) == Some('>') => {
tokens.push(Token::Correlate);
i += 3;
}
'<' if at(i + 1) == Some('=') => {
tokens.push(Token::Le);
i += 2;
}
'<' => {
tokens.push(Token::Lt);
i += 1;
}
'>' if at(i + 1) == Some('>') => {
let mark = match at(i + 2) {
Some(m @ ('?' | '!')) => {
i += 1;
m
}
_ => ' ',
};
tokens.push(Token::FollowingSiblings(mark));
i += 2;
}
'>' if at(i + 1) == Some('=') => {
tokens.push(Token::Ge);
i += 2;
}
'>' => {
if matches!(openers.last(), Some(Opener::RoundedTrait)) {
return Err(QuarbError::Lex(
"a '(:' trait closes with ')', not '>'".into(),
));
}
tokens.push(Token::Gt);
i += 1;
}
'?' => {
if chars.get(i + 1) == Some(&'=') {
tokens.push(Token::QuestionEq);
i += 2;
} else {
tokens.push(Token::Question);
i += 1;
}
}
'!' if at(i + 1) == Some('=') => {
tokens.push(Token::Ne);
i += 2;
}
'!' if at(i + 1) == Some('~') => {
tokens.push(Token::NotMatch);
i += 2;
}
'!' => {
tokens.push(Token::Bang);
i += 1;
}
'^' => {
tokens.push(Token::Caret);
i += 1;
}
'$' => {
tokens.push(Token::Dollar);
i += 1;
}
':' if at(i + 1) == Some('-')
&& at(i + 2) == Some('-')
&& !at(i + 3).is_some_and(|c| c.is_ascii_digit()) =>
{
tokens.push(Token::Resolve);
i += 3;
}
':' if at(i + 1) == Some('-')
&& at(i + 2) != Some('-')
&& !at(i + 2).is_some_and(|c| c.is_ascii_digit()) =>
{
tokens.push(Token::ArrowOut);
i += 2;
}
':' if at(i + 1).is_some_and(|c| c.is_alphabetic() || c == '_') => {
tokens.push(Token::Field);
i += 1;
}
':' => {
if at(i + 1) != Some(':') {
tokens.push(Token::Colon);
i += 1;
continue;
}
match at(i + 2) {
Some(':') if at(i + 3) == Some(':') => {
tokens.push(Token::SemiSemiSemi);
i += 4;
}
Some(':') => {
tokens.push(Token::ColonColonColon);
i += 3;
}
Some(';')
if at(i + 3).is_some_and(|c| {
c.is_alphanumeric() || c == '_' || c == '\'' || c == '"'
}) =>
{
tokens.push(Token::SemiSemiSemi);
i += 3;
}
_ => {
tokens.push(Token::ColonColon);
i += 2;
}
}
}
'|' if at(i + 1) == Some('|') => {
tokens.push(Token::PipePipe);
i += 2;
}
'|' => {
tokens.push(Token::Pipe);
i += 1;
}
'*' if at(i + 1) == Some('=') => {
tokens.push(Token::Contains);
i += 2;
}
'=' if at(i + 1) == Some('~') => {
tokens.push(Token::Match);
i += 2;
}
'=' if at(i + 1) == Some('>') => {
return Err(QuarbError::Unsupported(
"the pattern-search operator '=>' is not implemented yet".into(),
));
}
'=' => {
tokens.push(Token::Eq);
i += 1;
}
'[' => {
openers.push(Opener::Bracket);
tokens.push(Token::LBracket);
i += 1;
}
']' => {
match openers.last() {
Some(Opener::Bracket) => {
openers.pop();
}
Some(Opener::RoundedPredicate) => {
return Err(QuarbError::Lex(
"a '(?' predicate closes with ')', not ']'".into(),
));
}
Some(Opener::RoundedTrait) => {
return Err(QuarbError::Lex(
"a '(:' trait closes with ')', not ']'".into(),
));
}
_ => {}
}
tokens.push(Token::RBracket);
i += 1;
}
'(' if !after_call_head(&tokens, glued)
&& let Some((unit, next)) = paren_unit(&chars, i) =>
{
tokens.extend(unit);
i = next;
}
'(' if at(i + 1) == Some('/')
&& !after_call_head(&tokens, glued)
&& let Some((body, next)) = regex_paren(&chars, i, !after_axis(&tokens)) =>
{
tokens.push(Token::Regex(body));
i = next;
}
'(' if at(i + 1) == Some('?') => {
openers.push(Opener::RoundedPredicate);
tokens.push(Token::LBracket);
i += 2;
}
'(' if at(i + 1) == Some(':')
&& !matches!(at(i + 2), Some(':' | '-'))
&& !(after_call_head(&tokens, glued) && !after_axis(&tokens[..tokens.len() - 1])) =>
{
openers.push(Opener::RoundedTrait);
tokens.push(Token::Lt);
i += 2;
}
'(' if at(i + 1) == Some(';') => {
let (min, max, next) = read_quantifier(&chars, i + 2, ')')?;
tokens.push(Token::Quant { min, max });
i = next;
}
'(' => {
openers.push(Opener::Paren);
tokens.push(Token::LParen);
i += 1;
}
')' => {
match openers.last() {
Some(Opener::RoundedPredicate) => {
openers.pop();
tokens.push(Token::RBracket);
}
Some(Opener::RoundedTrait) => {
openers.pop();
tokens.push(Token::Gt);
}
Some(Opener::Paren) => {
openers.pop();
tokens.push(Token::RParen);
}
_ => tokens.push(Token::RParen),
}
i += 1;
}
',' => {
tokens.push(Token::Comma);
i += 1;
}
'@' if matches!(
tokens.last(),
Some(
Token::Slash | Token::SlashSlash | Token::Backslash | Token::BackslashBackslash
)
) && at(i + 1).is_some_and(is_name_char) =>
{
let mut text = String::from("@");
i += 1;
while let Some(ch) = at(i) {
if ch == '-' && matches!(at(i + 1), Some('>') | Some('-')) {
break;
}
if is_name_char(ch) {
text.push(ch);
i += 1;
} else {
break;
}
}
tokens.push(Token::Name {
text,
quoted: false,
glued,
});
}
'@' => {
tokens.push(Token::At);
i += 1;
}
'%' if at(i + 1) == Some('+') => {
tokens.push(Token::PercentPlus);
i += 2;
}
'%' => {
tokens.push(Token::Percent);
i += 1;
}
'&' if at(i + 1) == Some('&') => {
tokens.push(Token::AmpAmp);
i += 2;
}
'&' => {
tokens.push(Token::Amp);
i += 1;
}
';' if at(i + 1) == Some(';') && at(i + 2) == Some(';') => {
tokens.push(Token::SemiSemiSemi);
i += 3;
}
';' if at(i + 1) == Some(';') && at(i + 2) == Some('-') => {
let mark = match at(i + 3) {
Some(m @ ('?' | '!')) => {
i += 1;
m
}
_ => ' ',
};
tokens.push(Token::FollowingSiblings(mark));
i += 3;
}
';' if at(i + 1) == Some('-') => {
tokens.push(Token::NextSibling);
i += 2;
}
';' => {
tokens.push(Token::Semi);
i += 1;
}
'~' if at(i + 1) == Some('>') => {
tokens.push(Token::Resolve);
i += 2;
}
'~' => {
if at(i + 1) != Some('(') {
return Err(QuarbError::Lex(
"'~' must introduce a regex '~(...)' or a resolution '-->'".into(),
));
}
let (body, next) = read_balanced(&chars, i + 2)?;
tokens.push(Token::Regex(body));
i = next;
}
'\'' => {
let (text, next) = read_quoted(&chars, i + 1, c)?;
tokens.push(Token::Name {
text,
quoted: true,
glued,
});
i = next;
}
'`' => {
let (parts, next) = read_interpolated_until(&chars, i + 1, '`')?;
tokens.push(Token::Shell(parts));
i = next;
}
'"' => {
let (parts, next) = read_interpolated(&chars, i + 1)?;
match parts.as_slice() {
[] => tokens.push(Token::Name {
text: String::new(),
quoted: true,
glued,
}),
[InterpPart::Text(text)] => tokens.push(Token::Name {
text: text.clone(),
quoted: true,
glued,
}),
_ => tokens.push(Token::Interp(parts)),
}
i = next;
}
'.' if at(i + 1) == Some('.') && at(i + 2) == Some('/') => {
if at(i + 3) == Some('/') {
tokens.push(Token::BackslashBackslash);
i += 4;
} else {
tokens.push(Token::Backslash);
i += 3;
}
}
c if is_name_char(c) => {
let mut text = String::new();
while let Some(ch) = at(i) {
if ch == '-' && matches!(at(i + 1), Some('>') | Some('-')) {
break;
}
if ch == '-'
&& !text.is_empty()
&& (at(i + 1) == Some(';')
|| (at(i + 1) == Some(':') && at(i + 2) != Some(':')))
{
break;
}
if ch == '.'
&& !text.is_empty()
&& !text.ends_with('.')
&& at(i + 1) == Some('.')
&& at(i + 2) == Some('/')
{
break;
}
if ch == '*' && at(i + 1) == Some('=') {
break;
}
if ch == ':'
&& is_iso_datetime_prefix(&text)
&& at(i + 1).is_some_and(|c| c.is_ascii_digit())
{
text.push(ch);
i += 1;
continue;
}
if is_name_char(ch) {
text.push(ch);
i += 1;
} else {
break;
}
}
if text.len() > 1
&& text.ends_with('.')
&& !text.ends_with("..")
&& at(i) == Some('(')
{
return Err(QuarbError::Lex(format!(
"a push dot is never glued to a name: write `{} .(…)` (glued, the dot is part of the name)",
&text[..text.len() - 1]
)));
}
tokens.push(Token::Name {
text,
quoted: false,
glued,
});
}
'{' => {
let (min, max, next) = read_quantifier(&chars, i + 1, '}')?;
tokens.push(Token::Quant { min, max });
i = next;
}
other => {
return Err(QuarbError::Unsupported(format!(
"operator '{other}' is not implemented yet"
)));
}
}
}
Ok(tokens)
}
fn read_quantifier(
chars: &[char],
start: usize,
close: char,
) -> Result<(usize, Option<usize>, usize)> {
let malformed = || {
QuarbError::Lex(if close == ')' {
"malformed quantifier '(;…)': write (;n), (;m,n), or (;m,)".into()
} else {
"malformed quantifier '{…}': write {n}, {m,n}, or {m,}".into()
})
};
let mut i = start;
let number = |i: &mut usize| -> Option<usize> {
while chars.get(*i).is_some_and(|c| c.is_whitespace()) {
*i += 1;
}
let mut digits = String::new();
while let Some(c) = chars.get(*i).filter(|c| c.is_ascii_digit()) {
digits.push(*c);
*i += 1;
}
while chars.get(*i).is_some_and(|c| c.is_whitespace()) {
*i += 1;
}
digits.parse().ok()
};
let min = number(&mut i).ok_or_else(malformed)?;
match chars.get(i) {
Some(&c) if c == close => Ok((min, Some(min), i + 1)),
Some(',') => {
i += 1;
let max = number(&mut i);
match chars.get(i) {
Some(&c) if c == close => Ok((min, max, i + 1)),
_ => Err(malformed()),
}
}
_ => Err(malformed()),
}
}
fn read_interpolated(chars: &[char], start: usize) -> Result<(Vec<InterpPart>, usize)> {
read_interpolated_until(chars, start, '"')
}
fn read_interpolated_until(
chars: &[char],
start: usize,
close: char,
) -> Result<(Vec<InterpPart>, usize)> {
let mut parts = Vec::new();
let mut text = String::new();
let mut i = start;
while let Some(&ch) = chars.get(i) {
match ch {
c if c == close => {
if !text.is_empty() {
parts.push(InterpPart::Text(text));
}
return Ok((parts, i + 1));
}
'\\' if matches!(chars.get(i + 1), Some('$' | '"' | '`' | '\\')) => {
text.push(chars[i + 1]);
i += 2;
}
'$' if chars.get(i + 1) == Some(&'{') => {
if !text.is_empty() {
parts.push(InterpPart::Text(std::mem::take(&mut text)));
}
let mut hole = String::new();
i += 2;
let mut depth = 0usize;
loop {
match chars.get(i) {
Some('}') if depth == 0 => break,
Some('}') => {
depth -= 1;
hole.push('}');
i += 1;
}
Some('{') => {
depth += 1;
hole.push('{');
i += 1;
}
Some('\'') => {
hole.push('\'');
i += 1;
loop {
match chars.get(i) {
Some('\'') => {
hole.push('\'');
i += 1;
break;
}
Some(&c) => {
hole.push(c);
i += 1;
}
None => {
return Err(QuarbError::Lex(
"unterminated interpolation '${…' (missing '}')".into(),
));
}
}
}
}
Some(&c) => {
hole.push(c);
i += 1;
}
None => {
return Err(QuarbError::Lex(
"unterminated interpolation '${…' (missing '}')".into(),
));
}
}
}
i += 1;
if hole.trim().is_empty() {
return Err(QuarbError::Lex("empty interpolation '${}'".into()));
}
parts.push(InterpPart::Hole(hole));
}
_ => {
text.push(ch);
i += 1;
}
}
}
Err(QuarbError::Lex("unterminated quoted name (\"…)".into()))
}
fn read_quoted(chars: &[char], start: usize, quote: char) -> Result<(String, usize)> {
let mut text = String::new();
let mut i = start;
while let Some(&ch) = chars.get(i) {
i += 1;
if ch == quote {
return Ok((text, i));
}
text.push(ch);
}
Err(QuarbError::Lex(format!(
"unterminated quoted name ({quote}…)"
)))
}
fn read_balanced(chars: &[char], start: usize) -> Result<(String, usize)> {
let mut body = String::new();
let mut depth = 1usize;
let mut in_class = false;
let mut i = start;
while let Some(&ch) = chars.get(i) {
i += 1;
match ch {
'\\' => {
body.push('\\');
if let Some(&next) = chars.get(i) {
body.push(next);
i += 1;
}
}
'[' if !in_class => {
in_class = true;
body.push('[');
}
']' if in_class => {
in_class = false;
body.push(']');
}
'(' if !in_class => {
depth += 1;
body.push('(');
}
')' if !in_class => {
depth -= 1;
if depth == 0 {
return Ok((body, i));
}
body.push(')');
}
_ => body.push(ch),
}
}
Err(QuarbError::Lex("unterminated regex '~(…'".into()))
}
#[cfg(test)]
mod quant_tests {
use super::*;
#[test]
fn quantifier_forms() {
let quant = |src: &str| match lex(src).unwrap().as_slice() {
[Token::Quant { min, max }] => (*min, *max),
other => panic!("expected a single Quant, got {other:?}"),
};
assert_eq!(quant("{2}"), (2, Some(2)));
assert_eq!(quant("{1,3}"), (1, Some(3)));
assert_eq!(quant("{2,}"), (2, None));
assert_eq!(quant("{ 1 , 3 }"), (1, Some(3)));
assert_eq!(quant("{0,4}"), (0, Some(4)));
}
#[test]
fn quantifier_in_context() {
let toks = lex("/{2}").unwrap();
assert_eq!(
toks,
vec![
Token::Slash,
Token::Quant {
min: 2,
max: Some(2)
}
]
);
let toks = lex("(/a)+?").unwrap();
assert!(matches!(
toks.as_slice(),
[
Token::LParen,
Token::Slash,
Token::Name { .. },
Token::RParen,
Token::Name { text, glued: true, .. },
Token::Question,
] if text == "+"
));
}
#[test]
fn malformed_quantifiers() {
for src in ["{}", "{,3}", "{a}", "{1,2", "{1;2}"] {
assert!(lex(src).is_err(), "{src} should not lex");
}
assert!(lex("}").is_err());
}
}
#[cfg(test)]
mod subst_tests {
use super::*;
#[test]
fn subst_lexes_in_pipeline_position() {
let toks = lex("| s/foo/bar/g").unwrap();
assert!(
matches!(
&toks[1],
Token::Subst { pattern, replacement, mods }
if pattern == "foo" && replacement == "bar" && mods == "g"
),
"got {toks:?}"
);
let toks = lex(r"| s/a\/b/x/").unwrap();
assert!(matches!(&toks[1], Token::Subst { pattern, .. } if pattern == "a/b"));
let toks = lex("/s/x").unwrap();
assert!(toks.iter().all(|t| !matches!(t, Token::Subst { .. })));
}
}
#[cfg(test)]
mod glued_operator_tests {
use super::*;
#[test]
fn lt_before_negative_number_is_not_crosslink() {
let toks = lex("[::a<-3]").unwrap();
assert!(
matches!(
toks.as_slice(),
[
Token::LBracket,
Token::ColonColon,
Token::Name { text: a, .. },
Token::Lt,
Token::Name { text: n, .. },
Token::RBracket,
] if a == "a" && n == "-3"
),
"got {toks:?}"
);
assert!(lex("a<-b").unwrap().contains(&Token::ArrowIn));
}
#[test]
fn glued_contains_after_projection_key() {
let toks = lex("[::Name*='Countess']").unwrap();
assert!(
matches!(
toks.as_slice(),
[
Token::LBracket,
Token::ColonColon,
Token::Name { text: k, .. },
Token::Contains,
Token::Name { text: v, .. },
Token::RBracket,
] if k == "Name" && v == "Countess"
),
"got {toks:?}"
);
let toks = lex("a*b").unwrap();
assert!(matches!(toks.as_slice(), [Token::Name { text, .. }] if text == "a*b"));
}
#[test]
fn rounded_predicate_lexes_as_brackets() {
let toks = lex("/x(?(::a > 1 || ::b) && ::c)").unwrap();
assert_eq!(
toks.iter()
.filter(|t| matches!(
t,
Token::LBracket | Token::RBracket | Token::LParen | Token::RParen
))
.collect::<Vec<_>>(),
vec![
&Token::LBracket,
&Token::LParen,
&Token::RParen,
&Token::RBracket
],
"got {toks:?}"
);
let toks = lex("(?(/a[1]))").unwrap();
assert_eq!(
toks,
vec![
Token::LBracket,
Token::LParen,
Token::Slash,
Token::Name {
text: "a".into(),
quoted: false,
glued: true,
},
Token::LBracket,
Token::Name {
text: "1".into(),
quoted: false,
glued: true,
},
Token::RBracket,
Token::RParen,
Token::RBracket,
]
);
assert!(lex("/x(?::a = 1]").is_err());
assert_eq!(lex("(?1)").unwrap().len(), 3);
assert!(matches!(
lex("~(?i)bob").unwrap().first(),
Some(Token::Regex(r)) if r == "?i"
));
assert!(lex("( ?").unwrap().contains(&Token::LParen));
}
#[test]
fn rounded_family_lexes() {
let name = |t: &str| Token::Name {
text: t.into(),
quoted: false,
glued: true,
};
assert!(lex("/a-:b").unwrap().contains(&Token::ArrowIn));
assert!(lex("/a--:b").unwrap().contains(&Token::ReverseResolve));
assert_eq!(
lex("$-::w").unwrap(),
vec![Token::Dollar, name("-"), Token::ColonColon, name("w")]
);
assert_eq!(lex("@-;").unwrap(), vec![Token::At, name("-"), Token::Semi]);
assert_eq!(
lex("/x-::p").unwrap(),
vec![Token::Slash, name("x-"), Token::ColonColon, name("p")]
);
assert!(lex("/a;-b").unwrap().contains(&Token::NextSibling));
assert!(lex("/a-;b").unwrap().contains(&Token::PrevSibling));
assert!(lex("/a;;-?b").unwrap().contains(&Token::FollowingSiblings('?')));
assert!(lex("/a-;;!b").unwrap().contains(&Token::PrecedingSiblings('!')));
assert_eq!(lex("../x").unwrap(), vec![Token::Backslash, name("x")]);
assert_eq!(
lex("/a/b..//?c").unwrap(),
vec![
Token::Slash,
name("a"),
Token::Slash,
name("b"),
Token::BackslashBackslash,
Token::Question,
name("c")
]
);
assert_eq!(lex("| ...").unwrap(), vec![Token::Pipe, Token::Name { text: "...".into(), quoted: false, glued: false }]);
assert_eq!(lex("[2..3]").unwrap(), vec![Token::LBracket, name("2..3"), Token::RBracket]);
assert_eq!(
lex("/x(:admin && !banned)").unwrap(),
vec![
Token::Slash,
name("x"),
Token::Lt,
name("admin"),
Token::AmpAmp,
Token::Bang,
name("banned"),
Token::Gt
]
);
assert!(matches!(lex("(::a = 1)").unwrap().as_slice(), [Token::LParen, Token::ColonColon, ..]));
assert!(matches!(lex("(:-p)").unwrap().as_slice(), [Token::LParen, Token::ArrowOut, ..]));
assert!(lex("(:a>").is_err());
assert!(lex("(:a]").is_err());
assert_eq!(lex("(())/x").unwrap(), vec![Token::Caret, Token::Slash, name("x")]);
assert_eq!(lex("| (())").unwrap(), vec![Token::Pipe, Token::Caret]);
assert_eq!(
lex("now()").unwrap(),
vec![
Token::Name {
text: "now".into(),
quoted: false,
glued: false
},
Token::LParen,
Token::RParen
]
);
assert_eq!(lex("%()").unwrap(), vec![Token::Percent, Token::LParen, Token::RParen]);
assert_eq!(lex("(;2,3)").unwrap(), vec![Token::Quant { min: 2, max: Some(3) }]);
assert_eq!(lex("(;2)").unwrap(), vec![Token::Quant { min: 2, max: Some(2) }]);
assert_eq!(lex("(;2,)").unwrap(), vec![Token::Quant { min: 2, max: None }]);
assert!(lex("(;2,3}").is_err());
}
#[test]
fn rounded_registers_pipes_and_correlation_lex() {
let name = |t: &str| Token::Name {
text: t.into(),
quoted: false,
glued: true,
};
for (rounded, pointy) in [
("(_)", "$_"),
("(.)", "$."),
("(.name)", "$.name"),
("(.my-reg)", "$.my-reg"),
("(*)", "@*"),
("(*.)", "@."),
("(*-)", "@-"),
("(*2)", "$*2"),
("(1)", "$1"),
("(12)", "$12"),
("(-)", "$-"),
("(_)::name", "$_::name"),
] {
assert_eq!(lex(rounded).unwrap(), lex(pointy).unwrap(), "{rounded}");
}
for (rounded, pointy) in [
("((m))/x", "(m)/x"),
("((2))::x", "((2))::x"),
("((@))/e", "(@)/e"),
("((*))/e", "(@)/e"),
("((@m))/f", "(@m)/f"),
("((*m))/f", "(@m)/f"),
("((_))::id", "$$::id"),
("(())//x", "^//x"),
] {
assert_eq!(
lex(rounded).unwrap(),
lex(pointy).unwrap(),
"{rounded} vs {pointy}"
);
}
assert_eq!(
lex("((.))/d").unwrap(),
vec![Token::LParen, name("."), Token::RParen, Token::Slash, name("d")]
);
assert!(matches!(lex("((::a = 1 || ::b = 2) && ::c)").unwrap().as_slice(), [Token::LParen, Token::LParen, Token::ColonColon, ..]));
assert!(matches!(lex("(-3)").unwrap().as_slice(), [Token::LParen, Token::Name { .. }, Token::RParen]));
assert_eq!(lex("(.5)").unwrap(), lex("$.5").unwrap());
assert_eq!(lex("f((_))").unwrap()[1..], lex("f($_)").unwrap()[1..]);
assert_eq!(lex("/a __ count").unwrap(), lex("/a | count").unwrap());
assert_eq!(lex("/a *__ max").unwrap(), lex("/a @| max").unwrap());
assert_eq!(lex("/a ,__ upper").unwrap(), lex("/a $| upper").unwrap());
assert_eq!(lex("__ ...").unwrap(), lex("| ...").unwrap());
assert!(matches!(lex("x__y").unwrap().as_slice(), [Token::Name { text, .. }] if text == "x__y"));
assert!(matches!(lex("__init__").unwrap().as_slice(), [Token::Name { text, .. }] if text == "__init__"));
assert_eq!(lex("/a :=: /b").unwrap(), lex("/a <=> /b").unwrap());
assert_eq!(lex("/a :=:? /b").unwrap(), lex("/a <=>? /b").unwrap());
}
#[test]
fn regex_literal_in_parens_lexes() {
assert_eq!(lex("//(/^foo/)").unwrap(), lex("//~(^foo)").unwrap());
assert_eq!(lex("//(/(?i)^foo/)").unwrap(), lex("//~((?i)^foo)").unwrap());
assert!(matches!(
lex("/a(/src/x)+").unwrap().as_slice(),
[Token::Slash, Token::Name { .. }, Token::LParen, Token::Slash, Token::Name { .. }, Token::Slash, Token::Name { .. }, Token::RParen, ..]
));
assert!(matches!(lex("/(/a|/b)").unwrap().as_slice(), [Token::Slash, Token::LParen, ..]));
assert_eq!(lex("[::a =~ (/^bob/i)]").unwrap(), lex("[::a =~ /^bob/i]").unwrap());
assert!(matches!(lex("[::a = (/x/)]").unwrap().as_slice(), [Token::LBracket, Token::ColonColon, Token::Name { .. }, Token::Eq, Token::Regex(r), Token::RBracket] if r == "x"));
assert!(matches!(lex("//(/a\\/b/)").unwrap().as_slice(), [Token::SlashSlash, Token::Regex(r)] if r == "a/b"));
assert!(matches!(lex("[::p =~ (/a/b/)]").unwrap().as_slice(), [.., Token::Regex(r), Token::RBracket] if r == "a/b"));
assert!(matches!(lex("f(/x/)").unwrap().as_slice(), [Token::Name { .. }, Token::LParen, Token::Slash, ..]));
}
#[test]
fn push_dot_stands_after_whitespace() {
assert!(matches!(lex("/x.rs(?::n = 1)").unwrap().as_slice(), [Token::Slash, Token::Name { text, .. }, Token::LBracket, ..] if text == "x.rs"));
assert!(matches!(lex("/a/b.m").unwrap().as_slice(), [.., Token::Name { text, .. }] if text == "b.m"));
assert!(matches!(lex("/e.q(::x)").unwrap().as_slice(), [Token::Slash, Token::Name { text, .. }, Token::LParen, ..] if text == "e.q"));
assert!(matches!(lex("/a/b .m").unwrap().as_slice(), [.., Token::Name { text, glued: false, .. }] if text == ".m"));
assert!(matches!(lex("/x .(::y)").unwrap().as_slice(), [Token::Slash, Token::Name { .. }, Token::Name { text, .. }, Token::LParen, ..] if text == "."));
for q in ["CONTAINS.(::qty)", "/a(->e.($-::qty))+"] {
assert!(lex(q).unwrap_err().to_string().contains("never glued"), "{q}");
}
for q in ["/a[1].m", "(.(::q))+", "/a | .m", "$.name", "@.", "%.", "::.hidden", "/.git", "//?.git", "[::x = .5]", "[..3]", "| ...", "/a/b../c"] {
assert!(lex(q).is_ok(), "{q}: {:?}", lex(q));
}
}
#[test]
fn field_colon_and_named_captures_lex() {
assert!(matches!(lex("$.r:a").unwrap().as_slice(), [Token::Dollar, Token::Name { .. }, Token::Field, Token::Name { text, .. }] if text == "a"));
assert!(matches!(lex("| :a").unwrap().as_slice(), [Token::Pipe, Token::Field, Token::Name { .. }]));
assert!(matches!(lex("%+:year").unwrap().as_slice(), [Token::PercentPlus, Token::Field, Token::Name { .. }]));
assert!(matches!(lex("? 2 : 0").unwrap().as_slice(), [Token::Question, Token::Name { .. }, Token::Colon, Token::Name { .. }]));
assert!(matches!(lex("def &f: /x").unwrap().as_slice(), [Token::Name { .. }, Token::Amp, Token::Name { .. }, Token::Colon, ..]));
assert!(lex("::a").unwrap().contains(&Token::ColonColon));
assert!(lex("/a:-b").unwrap().contains(&Token::ArrowOut));
}
#[test]
fn constructor_sigils_follow_whitespace() {
assert!(lex("/a | %(::x)").is_ok());
assert!(lex("/a | .r%(::x)").is_ok());
assert!(lex("/a | .%%(::x)").is_ok());
assert!(lex("/a | f(%(::x))").unwrap_err().to_string().contains("whitespace"));
assert!(lex("/a | @(1; 2)").is_ok());
assert!(lex("/a | f(@(1))").unwrap_err().to_string().contains("whitespace"));
assert!(lex("/a | *(1; 2)").unwrap().contains(&Token::At));
assert!(lex("/a[:: =~ /x · y/] | %(::n)").is_ok());
assert!(lex("/a[:: = 'é'] | @(1)").is_ok());
let toks = lex("/*(?::x = 1)").unwrap();
assert!(!toks.contains(&Token::At) && toks.contains(&Token::LBracket), "{toks:?}");
}
#[test]
fn call_head_paren_is_not_a_trait() {
assert!(matches!(
lex("| sort_by(:size)").unwrap().as_slice(),
[Token::Pipe, Token::Name { .. }, Token::LParen, Token::Field, Token::Name { .. }, Token::RParen]
));
assert!(matches!(lex("@| group(:name)").unwrap().as_slice(), [Token::At, Token::Pipe, Token::Name { .. }, Token::LParen, Token::Field, ..]));
assert!(matches!(lex("| %(:name, 'x')").unwrap().as_slice(), [Token::Pipe, Token::Percent, Token::LParen, Token::Field, ..]));
for q in ["//user(:admin)", "/*(:admin)", "//?user(:admin)", "/a/'my node'(:t)"] {
assert!(lex(q).unwrap().contains(&Token::Lt), "{q}");
}
}
#[test]
fn interpolation_hole_honors_braces_and_quotes() {
let hole = |src: &str| match lex(src).unwrap().as_slice() {
[Token::Interp(parts)] => match parts.as_slice() {
[InterpPart::Hole(h)] => h.clone(),
other => panic!("expected a single hole, got {other:?}"),
},
other => panic!("expected a single Interp, got {other:?}"),
};
assert_eq!(hole("\"${(/a){2}::v}\""), "(/a){2}::v");
assert_eq!(hole("\"${(::t = '}' ? 1 : 0)}\""), "(::t = '}' ? 1 : 0)");
}
#[test]
fn regex_honors_escapes_and_char_classes() {
let body = |src: &str| match lex(src).unwrap().as_slice() {
[Token::Regex(b)] => b.clone(),
other => panic!("expected a regex, got {other:?}"),
};
assert_eq!(body("~(.*\\))"), ".*\\)");
assert_eq!(body("~([)])"), "[)]");
assert_eq!(body("~((ab)+)"), "(ab)+");
}
}
#[cfg(test)]
mod regex_and_search_tests {
use super::*;
#[test]
fn regex_modifiers_fold_into_inline_flags() {
let toks = lex("[::x =~ /admin/i]").unwrap();
assert!(
toks.contains(&Token::Regex("(?i)admin".into())),
"got {toks:?}"
);
let toks = lex("[::x =~ /a.b/ms]").unwrap();
assert!(
toks.contains(&Token::Regex("(?ms)a.b".into())),
"got {toks:?}"
);
let toks = lex("[::x =~ /plain/]").unwrap();
assert!(toks.contains(&Token::Regex("plain".into())), "got {toks:?}");
}
#[test]
fn arrow_search_operator_is_unsupported_not_eq_gt() {
let err = lex("//a => b").unwrap_err();
assert!(matches!(err, QuarbError::Unsupported(_)), "got {err:?}");
}
}
#[cfg(test)]
mod iso_instant_tests {
use super::*;
#[test]
fn full_iso_instants_lex_whole() {
let toks = lex("[::at > 2026-07-25T14:16:10Z]").unwrap();
assert!(toks.iter().any(|t| matches!(
t, Token::Name { text, .. } if text == "2026-07-25T14:16:10Z")));
let toks = lex("[::at > 2026-07-25T14:16:10.500+02:00]").unwrap();
assert!(toks.iter().any(|t| matches!(
t, Token::Name { text, .. } if text == "2026-07-25T14:16:10.500+02:00")));
let toks = lex("/x[::born > 2026-01-01]::name").unwrap();
assert!(toks.iter().any(|t| matches!(
t, Token::Name { text, .. } if text == "2026-01-01")));
assert!(toks.iter().filter(|t| matches!(t, Token::ColonColon)).count() >= 2);
assert!(lex("/entry[::k = 'a:b']").is_ok());
}
}