use crate::error::{QuarbError, Result};
#[derive(Debug, PartialEq, Eq)]
pub enum Token {
Slash,
SlashSlash,
Backslash,
BackslashBackslash,
Gt { spaced: bool, before: bool },
NextSibling,
PrevSibling,
FollowingSiblings(char),
Lt { spaced: bool, before: bool },
PrecedingSiblings(char),
Question { spaced: bool },
QuestionEq,
Bang,
Caret,
Dollar,
ColonColon,
ColonColonColon,
SemiSemiSemi,
Pipe,
PipePipe,
LBracket,
RBracket,
LParen,
MarkOpen,
IdAnchorOpen,
Driver,
RParen,
Comma,
Eq,
Ne,
Le,
Ge,
MatchEq,
NotMatchEq,
Contains,
ArrowOut,
ArrowIn,
DashDash,
At,
Percent,
Amp,
AmpAmp,
Colon { spaced: bool },
Field,
Capsa(String),
PercentPlus,
Semi,
Correlate,
Resolve,
ValueMatch,
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;
if !double && matches!(chars.get(j), Some('=') | Some(',')) {
j += 1;
}
while chars.get(j).is_some_and(|&c| is_name_char(c) || c == '@') {
j += 1;
}
if !double
&& chars.get(start) == Some(&'=')
&& chars.get(j) == Some(&'!')
&& chars.get(j + 1) == Some(&')')
{
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],
"0" => vec![Token::Driver],
b if !b.is_empty() && b.bytes().all(|c| c.is_ascii_digit()) => {
vec![Token::Dollar, Token::Dollar, name(b)]
}
b if b.starts_with('.')
&& b.len() > 1
&& b[1..].bytes().all(|c| c.is_ascii_digit()) =>
{
vec![Token::MarkOpen, name(&b[1..]), Token::RParen]
}
"*" | "@" => vec![Token::MarkOpen, Token::At, Token::RParen],
b if b.starts_with('*') || b.starts_with('@') => {
vec![Token::MarkOpen, Token::At, name(&b[1..]), Token::RParen]
}
b => vec![Token::MarkOpen, 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.is_empty() && b.bytes().all(|c| c.is_ascii_digit()) => {
vec![Token::Dollar, name(b)]
}
b if b.starts_with('=') && b.len() > 1 => {
let stem = b[1..].trim_end_matches('!');
if stem.is_empty() || !stem.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
let mut t = vec![Token::Amp, name(stem)];
if b.ends_with('!') {
t.push(Token::Bang);
}
t
}
b if b.starts_with(',') && b.len() > 1 => {
let stem = &b[1..];
if !stem.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
vec![Token::Dollar, name(stem)]
}
_ => 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 is_digit_run(text: &str) -> bool {
text.starts_with(|c: char| c.is_ascii_digit())
&& text
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '_' | '.' | ','))
&& !text.ends_with(['_', '.', ','])
}
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
| Token::ValueMatch
)
};
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 {
'\n' => return None,
'\\' 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 trait_body_ahead(chars: &[char], from: usize) -> bool {
let mut depth = 0usize;
let mut end = None;
for (j, &c) in chars.iter().enumerate().skip(from) {
match c {
'(' => depth += 1,
')' if depth == 0 => {
end = Some(j);
break;
}
')' => depth -= 1,
_ => {}
}
}
let Some(end) = end else { return true };
let mut j = from;
while j < end {
match chars[j] {
'|' if chars.get(j + 1) == Some(&'|') => j += 2,
'-' if j > from && chars[j - 1].is_whitespace() => return false,
'|' | '*' | '+' | '/' | '=' | '<' | '>' | '$' | '"' | '\'' | ',' | ';' | '[' | ']'
| '{' | '}' | '@' | '%' | '^' | '~' | '?' | '#' => return false,
_ => j += 1,
}
}
true
}
fn after_call_head(tokens: &[Token], glued: bool) -> bool {
glued
&& (matches!(
tokens.last(),
Some(Token::Name { .. } | Token::Percent | Token::Amp | Token::Dollar | Token::At)
)
|| (matches!(tokens.last(), Some(Token::Bang))
&& matches!(tokens.get(tokens.len().wrapping_sub(2)), Some(Token::Name { .. }))))
}
fn binary(op: &str, glued: bool, next: Option<char>) -> Result<()> {
if glued || !next.is_none_or(char::is_whitespace) {
return Err(QuarbError::lex(format!(
"'{op}' is an operator and takes a space on both sides: write ' {op} '"
)));
}
Ok(())
}
pub fn lex(input: &str) -> Result<Vec<Token>> {
lex_spanned(input).map(|(tokens, _)| tokens)
}
pub fn lex_spanned(input: &str) -> Result<(Vec<Token>, Vec<(usize, usize)>)> {
let chars: Vec<char> = input.chars().collect();
let mut tokens = Vec::new();
let mut spans: Vec<(usize, usize)> = 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;
let mut start = 0;
while i < chars.len() {
while spans.len() < tokens.len() {
spans.push((start, i));
}
start = i;
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 = lex_subst(&chars, i, &mut tokens)?
}
'/' if at(i + 1) == Some('/') => {
tokens.push(Token::SlashSlash);
i += 2;
}
'/' if at(i + 1) == Some('.')
&& at(i + 2) == Some('.')
&& at(i + 3) == Some('.')
&& !(at(i + 4).is_some_and(is_name_char)
&& !matches!((at(i + 4), at(i + 5)), (Some('-'), Some('>' | '-')))) =>
{
tokens.push(Token::BackslashBackslash);
i += 4;
}
'/' if at(i + 1) == Some('.')
&& at(i + 2) == Some('.')
&& !(at(i + 3).is_some_and(is_name_char)
&& !matches!((at(i + 3), at(i + 4)), (Some('-'), Some('>' | '-')))) =>
{
tokens.push(Token::Backslash);
i += 3;
}
'/' => {
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)
&& !matches!(tokens.last(), Some(Token::IdAnchorOpen))
{
return Err(QuarbError::lex(
"a list literal follows whitespace: write ' @(…)'",
));
}
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::lex(
"a record sigil follows whitespace or a push dot: write ' %(…)' or '.name%(…)'",
));
}
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('~') => {
return Err(QuarbError::lex("'<~' is retired: write '<--'"));
}
'<' if at(i + 1) == Some('=') && at(i + 2) == Some('>') => {
tokens.push(Token::Correlate);
i += 3;
}
'<' if at(i + 1) == Some('=') => {
binary("<=", glued, at(i + 2))?;
tokens.push(Token::Le);
i += 2;
}
'<' => {
let spaced = at(i + 1).is_none_or(char::is_whitespace);
tokens.push(Token::Lt {
spaced,
before: !glued,
});
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('=') => {
binary(">=", glued, at(i + 2))?;
tokens.push(Token::Ge);
i += 2;
}
'>' => {
if matches!(openers.last(), Some(Opener::RoundedTrait)) {
return Err(QuarbError::lex("a '(:' trait closes with ')', not '>'"));
}
let spaced = at(i + 1).is_none_or(char::is_whitespace);
tokens.push(Token::Gt {
spaced,
before: !glued,
});
i += 1;
}
'?' => {
if chars.get(i + 1) == Some(&'=') {
binary("?=", glued, at(i + 2))?;
tokens.push(Token::QuestionEq);
i += 2;
} else {
let spaced = !glued && at(i + 1).is_none_or(char::is_whitespace);
tokens.push(Token::Question { spaced });
i += 1;
}
}
'!' if at(i + 1) == Some('=') && at(i + 2) == Some('=') => {
binary("!==", glued, at(i + 3))?;
tokens.push(Token::NotMatchEq);
i += 3;
}
'!' if at(i + 1) == Some('=') => {
binary("!=", glued, at(i + 2))?;
tokens.push(Token::Ne);
i += 2;
}
'!' if at(i + 1) == Some('~') => {
return Err(QuarbError::lex(
"'!~' is retired: the pattern comparison is '!==' ('!== (/…/)')",
));
}
'!' => {
tokens.push(Token::Bang);
i += 1;
}
'^' => {
tokens.push(Token::Caret);
i += 1;
}
'$' if at(i + 1) == Some('$')
&& at(i + 2) == Some('_')
&& !at(i + 3).is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-') =>
{
return Err(QuarbError::lex(
"'$$_' is retired: the served node is '_' ('_::prop', '_/child')",
));
}
'$' if at(i + 1) == Some('$') && matches!(at(i + 2), Some(':' | '/')) => {
return Err(QuarbError::lex(
"a bare '$$' is retired: the served node is '_' ('_::prop', '_/child')",
));
}
'$' if at(i + 1) == Some('$')
&& at(i + 2) == Some('_')
&& at(i + 3).is_some_and(|c| c.is_ascii_digit()) =>
{
return Err(QuarbError::lex(
"'$$_N' is retired: the join's N-th node is '$$N'",
));
}
'_' if !at(i + 1).is_some_and(|c| is_name_char(c) || c == '(')
&& !(glued && matches!(tokens.last(), Some(Token::Dollar | Token::At))) =>
{
tokens.push(Token::Driver);
i += 1;
}
'$' if at(i + 1) == Some('$') && at(i + 2) == Some('.') => {
let mut k = i + 3;
while at(k).is_some_and(is_name_char)
&& !(at(k) == Some('-') && matches!(at(k + 1), Some('>' | '-')))
{
k += 1;
}
let spec: String = chars[i + 3..k].iter().collect();
let body = if spec.is_empty() {
".".to_string()
} else {
spec
};
tokens.push(Token::MarkOpen);
tokens.push(Token::Name {
text: body,
quoted: false,
glued: true,
});
tokens.push(Token::RParen);
i = k;
}
'$' => {
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 i > 0
&& (is_name_char(chars[i - 1])
|| matches!(chars[i - 1], ')' | ']' | '}' | '"' | '\'' | '\\'))
&& at(i + 1) == Some('(')
&& at(i + 2) == Some('*')
&& at(i + 3) == Some('.')
&& at(i + 4) == Some(')') =>
{
tokens.push(Token::Capsa("@.".to_string()));
i += 5;
}
':' if i > 0
&& (is_name_char(chars[i - 1])
|| matches!(chars[i - 1], ')' | ']' | '}' | '"' | '\'' | '\\'))
&& (at(i + 1) == Some('.')
|| (at(i + 1) == Some('@') && at(i + 2) == Some('.'))
|| (at(i + 1) == Some('%')
&& (at(i + 2) == Some('.')
|| (at(i + 2) == Some('%') && at(i + 3) == Some('.'))))) =>
{
let start = i + 1;
let mut j = start;
while j < chars.len() && matches!(chars[j], '@' | '%') {
j += 1;
}
j += 1; while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
j += 1;
}
tokens.push(Token::Capsa(chars[start..j].iter().collect()));
i = j;
}
':' => i = lex_colons(&chars, i, glued, &mut tokens)?,
'|' if at(i + 1) == Some('|') => {
binary("||", glued, at(i + 2))?;
tokens.push(Token::PipePipe);
i += 2;
}
'|' => {
tokens.push(Token::Pipe);
i += 1;
}
'*' if at(i + 1) == Some('=') => {
binary("*=", glued, at(i + 2))?;
tokens.push(Token::Contains);
i += 2;
}
'=' if at(i + 1) == Some('=') => {
binary("==", glued, at(i + 2))?;
tokens.push(Token::MatchEq);
i += 2;
}
'=' if at(i + 1) == Some('~') => {
return Err(QuarbError::lex(
"'=~' is retired: the pattern comparison is '==' ('== (/…/)')",
));
}
'=' if at(i + 1) == Some('>') => {
tokens.push(Token::ValueMatch);
i += 2;
}
'=' => {
if !tokens.is_empty() {
binary("=", glued, at(i + 1))?;
}
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 ']'"));
}
Some(Opener::RoundedTrait) => {
return Err(QuarbError::lex("a '(:' trait closes with ')', not ']'"));
}
_ => {}
}
tokens.push(Token::RBracket);
i += 1;
}
'(' if at(i + 1) == Some('(')
&& at(i + 2) == Some('_')
&& (at(i + 3) == Some(')')
|| (at(i + 3).is_some_and(|c| c.is_ascii_digit())
&& chars[i + 3..]
.iter()
.take_while(|c| c.is_ascii_digit())
.count()
> 0
&& {
let k = i
+ 3
+ chars[i + 3..]
.iter()
.take_while(|c| c.is_ascii_digit())
.count();
at(k) == Some(')') && at(k + 1) == Some(')')
})) =>
{
return Err(QuarbError::lex(
"'((_))' and '((_N))' are retired: the served node is '((0))', the joined '((N))'",
));
}
'(' if !after_call_head(&tokens, glued)
&& at(i + 1) == Some('(')
&& at(i + 2) == Some('!') =>
{
tokens.push(Token::IdAnchorOpen);
i += 3;
}
'(' if !after_call_head(&tokens, glued)
&& let Some((unit, next)) = paren_unit(&chars, i) =>
{
tokens.extend(unit);
i = next;
}
'(' if at(i + 1) == Some('/')
&& at(i + 2) != 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(':' | '-'))
&& trait_body_ahead(&chars, i + 2)
&& !(after_call_head(&tokens, glued)
&& !after_axis(&tokens[..tokens.len() - 1])) =>
{
openers.push(Opener::RoundedTrait);
tokens.push(Token::Lt {
spaced: false,
before: !glued,
});
i += 2;
}
'(' if at(i + 1) == Some('+') && quantifier_body(&chars, i + 2, ')') => {
let (min, max, next) = read_quantifier(&chars, i + 2, ')')?;
tokens.push(Token::Quant { min, max });
i = next;
}
'(' if at(i + 1) == Some(';') && !after_call_head(&tokens, glued) => {
let (body, next) = read_shell_paren(&chars, i + 2)?;
if body.iter().any(|c| c.is_ascii_digit())
&& body.iter().all(|c| c.is_ascii_digit() || *c == ';')
{
return Err(QuarbError::lex(
"the rounded quantifier is (+m;n) — (;…) is the shell literal",
));
}
let mut tmp = body;
tmp.push('\0');
let (parts, _) = read_interpolated_until(&tmp, 0, '\0')?;
tokens.push(Token::Shell(parts));
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 {
spaced: false,
before: false,
});
}
Some(Opener::Paren) => {
openers.pop();
tokens.push(Token::RParen);
}
_ => tokens.push(Token::RParen),
}
i += 1;
}
',' => {
if at(i + 1).is_some_and(|c| !c.is_whitespace()) {
return Err(QuarbError::lex(
"a comma separator is followed by a space — 'f(a, b)' — \
or write the separator ';' ('f(a; b)')",
));
}
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('&') => {
binary("&&", glued, at(i + 2))?;
tokens.push(Token::AmpAmp);
i += 2;
}
'&' => {
tokens.push(Token::Amp);
i += 1;
}
';' if at(i + 1) == Some(';') && at(i + 2) == Some(';') => {
return Err(QuarbError::lex(
"';;;' is retired: adapter metadata is '::::'",
));
}
';' 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('>') => {
return Err(QuarbError::lex("'~>' is retired: write '-->'"));
}
'~' if at(i + 1) == Some('(') => {
return Err(QuarbError::lex(
"'~(…)' is retired: the regex literal is '(/…/)'",
));
}
'~' => {
return Err(QuarbError::lex(
"'~' is not an operator: the regex literal is '(/…/)', the resolution '-->'",
));
}
'\'' => {
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;
}
c if is_name_char(c) => i = lex_name(&chars, i, glued, &mut tokens)?,
'{' => {
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"
)));
}
}
}
while spans.len() < tokens.len() {
spans.push((start, i));
}
Ok((tokens, spans))
}
fn lex_name(chars: &[char], mut i: usize, glued: bool, tokens: &mut Vec<Token>) -> Result<usize> {
let at = |j: usize| chars.get(j).copied();
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 ch == ',' && is_digit_run(&text) && at(i + 1).is_some_and(|c| c.is_ascii_digit()) {
if text.contains(['.', ',']) {
return Err(QuarbError::lex(format!(
"'{text},…': a thousands separator is written '_' (1_000_000); '.' and ',' are the decimal point"
)));
}
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,
});
Ok(i)
}
fn lex_colons(chars: &[char], mut i: usize, glued: bool, tokens: &mut Vec<Token>) -> Result<usize> {
let at = |j: usize| chars.get(j).copied();
if at(i + 1) != Some(':') {
let spaced = !glued && at(i + 1).is_none_or(char::is_whitespace);
tokens.push(Token::Colon { spaced });
i += 1;
return Ok(i);
}
match at(i + 2) {
Some(':') if at(i + 3) == Some(':') => {
tokens.push(Token::SemiSemiSemi);
i += 4;
}
Some(':') => {
tokens.push(Token::ColonColonColon);
i += 3;
if matches!(at(i), Some('%' | '@')) && at(i + 1).is_some_and(|c| c.is_alphabetic()) {
let start = i;
i += 1;
while at(i).is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-') {
i += 1;
}
tokens.push(Token::Name {
text: chars[start..i].iter().collect(),
quoted: false,
glued: true,
});
}
}
Some(';')
if at(i + 3)
.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '\'' || c == '"') =>
{
return Err(QuarbError::lex(
"'::;' is retired: adapter metadata is '::::'",
));
}
_ => {
tokens.push(Token::ColonColon);
i += 2;
}
}
Ok(i)
}
fn lex_subst(chars: &[char], mut i: usize, tokens: &mut Vec<Token>) -> Result<usize> {
let at = |j: usize| chars.get(j).copied();
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/'"));
}
}
}
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,
});
Ok(i)
}
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;)"
} else {
"malformed quantifier '{…}': write {n}, {m;n}, or {m;}"
})
};
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(',') => Err(QuarbError::lex(
"the comma is retired in a quantifier: write {m;n} (rounded (+m;n))",
)),
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 quantifier_body(chars: &[char], start: usize, close: char) -> bool {
let mut k = start;
let mut digit = false;
let mut semis = 0;
while let Some(&c) = chars.get(k) {
if c == close {
return digit && semis == 1;
}
if c.is_ascii_digit() {
digit = true;
} else if c == ';' {
semis += 1;
} else {
return false;
}
k += 1;
}
false
}
fn read_shell_paren(chars: &[char], start: usize) -> Result<(Vec<char>, usize)> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut quote: Option<char> = None;
let mut k = start;
while let Some(&c) = chars.get(k) {
if let Some(q) = quote {
out.push(c);
if c == q {
quote = None;
}
k += 1;
continue;
}
match c {
'\\' if chars.get(k + 1) == Some(&')') => {
out.push(')');
k += 2;
}
'\'' | '"' => {
quote = Some(c);
out.push(c);
k += 1;
}
'(' => {
depth += 1;
out.push(c);
k += 1;
}
')' if depth == 0 => return Ok((out, k + 1)),
')' => {
depth -= 1;
out.push(c);
k += 1;
}
_ => {
out.push(c);
k += 1;
}
}
}
Err(QuarbError::lex(
"unterminated shell literal '(;…)': no balancing ')'",
))
}
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 ')')",
));
}
}
}
}
Some(&c) => {
hole.push(c);
i += 1;
}
None => {
return Err(QuarbError::lex(
"unterminated interpolation '(,…' (missing ')')",
));
}
}
}
parts.push(InterpPart::Hole(hole));
i += 1;
}
'$' 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 '}')",
));
}
}
}
}
Some(&c) => {
hole.push(c);
i += 1;
}
None => {
return Err(QuarbError::lex(
"unterminated interpolation '${…' (missing '}')",
));
}
}
}
i += 1;
if hole.trim().is_empty() {
return Err(QuarbError::lex("empty interpolation '${}'"));
}
parts.push(InterpPart::Hole(hole));
}
_ => {
text.push(ch);
i += 1;
}
}
}
Err(QuarbError::lex("unterminated quoted name (\"…)"))
}
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}…)"
)))
}
#[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!(lex("{1,3}").unwrap_err().to_string().contains("retired"));
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", "{1:2}"] {
assert!(lex(src).is_err(), "{src} should !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 e = lex("[::Name*='Countess']").unwrap_err().to_string();
assert!(e.contains("write ' *= '"), "{e}");
let toks = lex("a*b").unwrap();
assert!(matches!(toks.as_slice(), [Token::Name { text, .. }] if text == "a*b"));
}
#[test]
fn operators_take_a_space_on_both_sides() {
for (q, op) in [
("[::v=2]", "="),
("[::v =2]", "="),
("[::v= 2]", "="),
("[::n!=1]", "!="),
("[::n<=1]", "<="),
("[::n>=1]", ">="),
("[::n==\"x\"]", "=="),
("[::n!==\"x\"]", "!=="),
("[::v = 2&&::n = 1]", "&&"),
("[::v = 2 ||::n = 1]", "||"),
("%(k=::n)", "="),
("(::n ?=1 ? 2 : 3)", "?="),
] {
let e = lex(q).unwrap_err().to_string();
assert!(e.contains(&format!("write ' {op} '")), "{q}: {e}");
}
assert!(lex("= 1 + 2").is_ok());
assert!(lex("/chapter-02.md").is_ok());
assert!(lex("//h2>p").is_ok());
assert!(lex("[::d = 2024-02-15]").is_ok());
assert!(lex("[::n = -1]").is_ok());
}
#[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, Token::Slash, name("x")]
);
assert_eq!(lex("/..").unwrap(), vec![Token::Backslash]);
assert_eq!(lex("/...").unwrap(), vec![Token::BackslashBackslash]);
assert_eq!(
lex("/..->lbl").unwrap(),
vec![Token::Backslash, Token::ArrowOut, name("lbl")]
);
assert_eq!(lex("/..x").unwrap(), vec![Token::Slash, name("..x")]);
assert_eq!(
lex("/a/b/...?c").unwrap(),
vec![
Token::Slash,
name("a"),
Token::Slash,
name("b"),
Token::BackslashBackslash,
Token::Question { spaced: false },
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 {
spaced: false,
before: false
},
name("admin"),
Token::AmpAmp,
Token::Bang,
name("banned"),
Token::Gt {
spaced: false,
before: false
}
]
);
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!(matches!(
lex("(:t | ngrams(3))").unwrap().as_slice(),
[
Token::LParen,
Token::Field,
Token::Name { .. },
Token::Pipe,
..
]
));
assert!(matches!(
lex("(:n * 2)").unwrap().as_slice(),
[Token::LParen, Token::Field, Token::Name { .. }, ..]
));
assert!(matches!(
lex("(:n - :back)").unwrap().as_slice(),
[Token::LParen, Token::Field, Token::Name { .. }, ..]
));
assert!(matches!(
lex("(:stage-direction)").unwrap().as_slice(),
[Token::Lt { spaced: false, .. }, Token::Name { .. }, ..]
));
assert!(matches!(
lex("(:word and not punct)").unwrap().as_slice(),
[Token::Lt { spaced: false, .. }, Token::Name { .. }, ..]
));
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;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());
let e = lex("(;2;3)").unwrap_err().to_string();
assert!(e.contains("(+m;n)"), "{e}");
assert!(matches!(
lex("(+2)").unwrap().as_slice(),
[Token::LParen, ..]
));
assert!(matches!(
lex("| (;wc -l)").unwrap().as_slice(),
[Token::Pipe, Token::Shell(_)]
));
assert!(matches!(
lex("| (;echo \"(\" $(date))").unwrap().as_slice(),
[Token::Pipe, Token::Shell(_)]
));
assert!(matches!(
lex("| (;echo \\))").unwrap().as_slice(),
[Token::Pipe, Token::Shell(_)]
));
}
#[test]
fn cross_capsa_reads_lex() {
for (q, body) in [
("<-link:.v", ".v"),
("/*:.", "."),
("/*:.3", ".3"),
("\\/*:@.", "@."),
("\\/*:(*.)", "@."),
("->ref:%.", "%."),
("->ref:%%.", "%%."),
("(->ref):.v", ".v"),
("/a[1]:.v", ".v"),
] {
let toks = lex(q).unwrap();
assert!(
toks.iter()
.any(|t| matches!(t, Token::Capsa(b) if b == body)),
"{q}: {toks:?}"
);
}
assert!(
!lex("(::x ? 1 : .5)")
.unwrap()
.iter()
.any(|t| matches!(t, Token::Capsa(_)))
);
assert!(
!lex("? 1 :.5")
.unwrap()
.iter()
.any(|t| matches!(t, Token::Capsa(_)))
);
}
#[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"),
("((2))::x", "$$2::x"),
("(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::MarkOpen,
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!(lex("f(_)").unwrap().contains(&Token::Driver));
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 angle_brackets_carry_their_spacing() {
assert!(lex("[::n > 3]").unwrap().contains(&Token::Gt {
spaced: true,
before: true
}));
assert!(lex("//h2>p").unwrap().contains(&Token::Gt {
spaced: false,
before: false
}));
assert!(lex("[::n>3]").unwrap().contains(&Token::Gt {
spaced: false,
before: false
}));
assert!(lex("//a<b").unwrap().contains(&Token::Lt {
spaced: false,
before: false
}));
assert!(lex("[::n < 3]").unwrap().contains(&Token::Lt {
spaced: true,
before: true
}));
assert!(lex("/*<function>").unwrap().contains(&Token::Lt {
spaced: false,
before: false
}));
}
#[test]
fn descendant_group_is_never_a_regex() {
let toks = lex("def &1: //dir | %(n = (//page @| count)) ;\n//p[:: == (/a.*b/i)]").unwrap();
let regexes: Vec<&Token> = toks
.iter()
.filter(|t| matches!(t, Token::Regex(_)))
.collect();
assert!(
matches!(regexes.as_slice(), [Token::Regex(r)] if r == "(?i)a.*b"),
"{toks:?}"
);
assert!(matches!(
lex("(/a\nb/)").unwrap().as_slice(),
[Token::LParen, ..]
));
}
#[test]
fn regex_literal_in_parens_lexes() {
assert_eq!(lex("//(/^foo/)").unwrap(), lex("//(/^foo/)").unwrap());
assert!(
lex("//~((?i)^foo)")
.unwrap_err()
.to_string()
.contains("retired")
);
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::MatchEq, 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 rounded_hole_in_a_string() {
assert_eq!(
lex(r#""a (,::n) b""#).unwrap(),
lex(r#""a ${::n} b""#).unwrap()
);
assert_eq!(
lex(r#""(,(::n | upper)) x""#).unwrap(),
lex(r#""${(::n | upper)} x""#).unwrap()
);
assert!(
matches!(lex(r#""(,(.n))""#).unwrap().as_slice(), [Token::Interp(parts)] if matches!(parts.as_slice(), [InterpPart::Hole(h)] if h == "(.n)"))
);
let t = lex(r#""\(,see""#).unwrap();
assert!(t.len() == 1 && !matches!(t[0], Token::Interp(_)), "{t:?}");
assert!(lex(r#""(,::n""#).is_err());
}
#[test]
fn decimal_comma_and_group_underscores() {
assert!(
matches!(lex("1,5").unwrap().as_slice(), [Token::Name { text, .. }] if text == "1,5")
);
assert!(
matches!(lex("f(1, 5)").unwrap().as_slice(), [Token::Name { .. }, Token::LParen, Token::Name { text, .. }, Token::Comma, Token::Name { .. }, Token::RParen] if text == "1")
);
assert!(
matches!(lex("1_000,5").unwrap().as_slice(), [Token::Name { text, .. }] if text == "1_000,5")
);
let e = lex("1,000,000").unwrap_err().to_string();
assert!(e.contains("1_000_000"), "{e}");
assert!(lex("x,1").is_err());
assert!(lex("x, 1").unwrap().contains(&Token::Comma));
assert!(matches!(
lex("f(a; b)").unwrap().as_slice(),
[
Token::Name { .. },
Token::LParen,
Token::Name { .. },
Token::Semi,
Token::Name { .. },
Token::RParen
]
));
assert!(lex("(+2;5)").is_ok());
}
#[test]
fn fragment_and_parameter_units() {
assert!(
matches!(lex("| (=load)").unwrap().as_slice(), [Token::Pipe, Token::Amp, Token::Name { text, .. }] if text == "load")
);
assert!(matches!(
lex("| (=tok!)(1)").unwrap().as_slice(),
[
Token::Pipe,
Token::Amp,
Token::Name { .. },
Token::Bang,
Token::LParen,
..
]
));
assert!(
matches!(lex("| (=3)").unwrap().as_slice(), [Token::Pipe, Token::Amp, Token::Name { text, .. }] if text == "3")
);
assert!(
matches!(lex("| (,col)").unwrap().as_slice(), [Token::Pipe, Token::Dollar, Token::Name { text, .. }] if text == "col")
);
assert!(lex("$$.m/x").unwrap().starts_with(&[Token::MarkOpen]));
assert!(lex("(m)/x").unwrap().starts_with(&[Token::LParen]));
}
#[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()
.iter()
.any(|t| matches!(t, 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 value_match_lexes_as_one_token() {
let toks = lex("//a::x=>(/b/*::y)").unwrap();
assert!(toks.contains(&Token::ValueMatch), "got {toks:?}");
assert!(!toks.contains(&Token::Eq), "got {toks:?}");
}
}
#[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());
}
}