use logos::{Logos, Span};
use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::UNIX_EPOCH;
use kaish_types::clock::system_now;
static MARKER_COUNTER: AtomicU64 = AtomicU64::new(0);
const MAX_PAREN_DEPTH: usize = 256;
fn unique_marker_id() -> String {
let timestamp = system_now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let counter = MARKER_COUNTER.fetch_add(1, Ordering::Relaxed);
#[cfg(target_family = "wasm")]
let pid = 0u32;
#[cfg(not(target_family = "wasm"))]
let pid = std::process::id();
format!("{:x}_{:x}_{:x}", timestamp, counter, pid)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Spanned<T> {
pub token: T,
pub span: Span,
}
impl<T> Spanned<T> {
pub fn new(token: T, span: Span) -> Self {
Self { token, span }
}
}
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub enum LexerError {
#[default]
UnexpectedCharacter,
UnterminatedString,
UnterminatedVarRef,
InvalidEscape,
InvalidNumber,
InvalidFloatNoLeading,
InvalidFloatNoTrailing,
NestingTooDeep,
UnterminatedCommandSubst,
UnterminatedArithmetic,
UnterminatedHeredoc { delimiter: String },
BackticksNotSupported,
ArithmeticInVarRef,
NonAsciiName { kind: &'static str, text: String },
HashInsideWord,
}
impl fmt::Display for LexerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LexerError::UnexpectedCharacter => write!(f, "unexpected character"),
LexerError::UnterminatedString => write!(f, "unterminated string"),
LexerError::UnterminatedVarRef => write!(f, "unterminated variable reference"),
LexerError::UnterminatedCommandSubst => {
write!(f, "unterminated command substitution: missing `)`")
}
LexerError::InvalidEscape => write!(f, "invalid escape sequence"),
LexerError::InvalidNumber => write!(f, "invalid number"),
LexerError::InvalidFloatNoLeading => write!(f, "float must have leading digit"),
LexerError::InvalidFloatNoTrailing => write!(f, "float must have trailing digit"),
LexerError::NestingTooDeep => write!(f, "nesting depth exceeded (max {})", MAX_PAREN_DEPTH),
LexerError::UnterminatedArithmetic => {
write!(f, "unterminated arithmetic expansion, expected closing `))`")
}
LexerError::UnterminatedHeredoc { delimiter } => {
write!(f, "unterminated heredoc, expected closing delimiter `{}` on its own line", delimiter)
}
LexerError::BackticksNotSupported => {
write!(f, "backticks are not supported in kaish; use $(cmd) instead")
}
LexerError::ArithmeticInVarRef => {
write!(
f,
"arithmetic expansion inside ${{...}} is not supported; \
assign it to a variable first, e.g. N=$((expr)); ${{X:-$N}}"
)
}
LexerError::NonAsciiName { kind, text } => write!(
f,
"{kind} `{text}` has a non-ASCII character; {kind}s are ASCII-only — \
quote it to use as a literal word instead"
),
LexerError::HashInsideWord => write!(
f,
"`#` starts a comment only at the start of a word — quote the whole \
word to keep `#` inside it, e.g. \"$x#3\", or put a space before `#` \
to start a comment."
),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct HereDocData {
pub content: String,
pub source_body: String,
pub delimiter: String,
pub literal: bool,
pub strip_tabs: bool,
pub body_start_offset: usize,
}
#[derive(Logos, Debug, Clone, PartialEq)]
#[logos(error = LexerError)]
#[logos(skip r"[ \t]+")]
#[non_exhaustive]
pub enum Token {
#[token("set")]
Set,
#[token("local")]
Local,
#[token("if")]
If,
#[token("then")]
Then,
#[token("else")]
Else,
#[token("elif")]
Elif,
#[token("fi")]
Fi,
#[token("for")]
For,
#[token("while")]
While,
#[token("in")]
In,
#[token("do")]
Do,
#[token("done")]
Done,
#[token("case")]
Case,
#[token("esac")]
Esac,
#[token("function")]
Function,
#[token("break")]
Break,
#[token("continue")]
Continue,
#[token("return")]
Return,
#[token("exit")]
Exit,
#[token("true")]
True,
#[token("false")]
False,
#[token("string")]
TypeString,
#[token("int")]
TypeInt,
#[token("float")]
TypeFloat,
#[token("bool")]
TypeBool,
#[token("&&")]
And,
#[token("||")]
Or,
#[token("==")]
EqEq,
#[token("!=")]
NotEq,
#[token("=~")]
Match,
#[token("!~")]
NotMatch,
#[token(">=")]
GtEq,
#[token("<=")]
LtEq,
#[token(">>")]
GtGt,
#[token("2>&1")]
StderrToStdout,
#[token("1>&2")]
StdoutToStderr,
#[token(">&2")]
StdoutToStderr2,
#[token("2>")]
Stderr,
#[token("&>")]
Both,
#[token("<<<")]
HereString,
#[token("<<")]
HereDocStart,
#[token(";;")]
DoubleSemi,
#[token("=")]
Eq,
#[token("|")]
Pipe,
#[token("&")]
Amp,
#[token(">")]
Gt,
#[token("<")]
Lt,
#[token(";")]
Semi,
#[token(":")]
Colon,
#[token(",")]
Comma,
#[token("...")]
DotDotDot,
#[token("..")]
DotDot,
#[token(".")]
Dot,
#[regex(r"~[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]+", lex_tilde_path, priority = 3)]
TildePath(String),
#[token("~")]
Tilde,
#[regex(r"\.\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_relative_path, priority = 3)]
#[regex(r"[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*/[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]*", lex_relative_path, priority = 3)]
RelativePath(String),
#[regex(r"\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_dot_slash_path, priority = 3)]
DotSlashPath(String),
#[regex(r"\.[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*", lex_dotted_ident, priority = 3)]
DottedIdent(String),
#[token("{")]
LBrace,
#[token("}")]
RBrace,
#[token("[")]
LBracket,
#[token("]")]
RBracket,
#[token("(")]
LParen,
#[token(")")]
RParen,
#[token("*")]
Star,
#[token("!")]
Bang,
#[token("?")]
Question,
GlobWord(String),
Arithmetic(String),
#[token("$(")]
CmdSubstStart,
#[regex(r"--[a-zA-Z][a-zA-Z0-9\-\u{80}-\u{10FFFF}]*", lex_long_flag, priority = 3)]
LongFlag(String),
#[regex(r"-[a-zA-Z][a-zA-Z0-9\-\u{80}-\u{10FFFF}]*", lex_short_flag, priority = 3)]
ShortFlag(String),
#[regex(r"\+[a-zA-Z][a-zA-Z0-9\u{80}-\u{10FFFF}]*", lex_plus_flag, priority = 3)]
PlusFlag(String),
#[token("--")]
DoubleDash,
#[regex(r"--[^a-zA-Z\s()|&;<>][^\s()|&;<>]*", lex_double_dash_bare, priority = 2)]
DoubleDashBare(String),
#[regex(r"\+[^a-zA-Z\s()|&;<>][^\s()|&;<>]*", lex_plus_bare, priority = 2)]
PlusBare(String),
#[regex(r"-[^a-zA-Z0-9\s\-()|&;<>][^\s()|&;<>]*", lex_minus_bare, priority = 1)]
MinusBare(String),
#[regex(r"%[0-9]+", lex_job_spec)]
JobSpec(String),
#[token("-")]
MinusAlone,
#[regex(r#"""#, lex_string)]
String(String),
#[regex(r"'[^']*'", lex_single_string)]
SingleString(String),
#[regex(r"\$\{", lex_varref)]
VarRef(String),
#[regex(r"\$[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_\u{80}-\u{10FFFF}]*", lex_simple_varref)]
SimpleVarRef(String),
#[regex(r"\$[0-9]", lex_positional)]
Positional(usize),
#[token("$@")]
AllArgs,
#[token("$#")]
ArgCount,
#[token("$?")]
LastExitCode,
#[token("$$")]
CurrentPid,
#[regex(r"\$\{#[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_\u{80}-\u{10FFFF}]*(\[[^\]]*\])*\}", lex_var_length)]
VarLength(String),
HereDoc(HereDocData),
#[regex(r"-?[0-9]+", lex_int, priority = 2)]
Int(i64),
#[regex(r"-?[0-9]+\.[0-9]+", lex_float)]
Float(f64),
#[regex(r"[0-9]+[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*", lex_number_ident, priority = 3)]
NumberIdent(String),
#[regex(r"[0-9]+(\.[0-9]+)?(-[0-9a-zA-Z._\u{80}-\u{10FFFF}]+)+", lex_slice_word, priority = 3)]
#[regex(r"-[0-9]+[a-zA-Z_\u{80}-\u{10FFFF}][0-9a-zA-Z._\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)]
DashNumWord(String),
#[regex(r"@[a-zA-Z0-9_./@\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)]
AtWord(String),
#[regex(r"\.[0-9]+", lex_invalid_float_no_leading, priority = 3)]
InvalidFloatNoLeading,
#[regex(r"[0-9]+\.", lex_invalid_float_no_trailing, priority = 2)]
InvalidFloatNoTrailing,
#[regex(r"/[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]*", lex_path)]
Path(String),
#[regex(r"[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.@#\-\u{80}-\u{10FFFF}]*", lex_ident)]
Ident(String),
#[regex(r"#[^\n\r]*", lex_comment, allow_greedy = true)]
Comment,
#[regex(r"\n|\r\n")]
Newline,
#[regex(r"\\[ \t]*(\n|\r\n)")]
LineContinuation,
#[token("`", reject_backtick)]
BacktickRejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TokenCategory {
Keyword,
Operator,
String,
Number,
Variable,
Comment,
Punctuation,
Command,
Path,
Flag,
Error,
}
impl Token {
pub fn category(&self) -> TokenCategory {
match self {
Token::If
| Token::Then
| Token::Else
| Token::Elif
| Token::Fi
| Token::For
| Token::In
| Token::Do
| Token::Done
| Token::While
| Token::Case
| Token::Esac
| Token::Function
| Token::Return
| Token::Break
| Token::Continue
| Token::Exit
| Token::Set
| Token::Local
| Token::True
| Token::False
| Token::TypeString
| Token::TypeInt
| Token::TypeFloat
| Token::TypeBool => TokenCategory::Keyword,
Token::Pipe
| Token::And
| Token::Or
| Token::Amp
| Token::Eq
| Token::EqEq
| Token::NotEq
| Token::Match
| Token::NotMatch
| Token::Lt
| Token::Gt
| Token::LtEq
| Token::GtEq
| Token::GtGt
| Token::Stderr
| Token::Both
| Token::HereDocStart
| Token::HereString
| Token::StderrToStdout
| Token::StdoutToStderr
| Token::StdoutToStderr2 => TokenCategory::Operator,
Token::String(_) | Token::SingleString(_) | Token::HereDoc(_) => TokenCategory::String,
Token::Int(_) | Token::Float(_) | Token::Arithmetic(_) => TokenCategory::Number,
Token::VarRef(_)
| Token::SimpleVarRef(_)
| Token::Positional(_)
| Token::AllArgs
| Token::ArgCount
| Token::VarLength(_)
| Token::LastExitCode
| Token::CurrentPid => TokenCategory::Variable,
Token::LongFlag(_)
| Token::ShortFlag(_)
| Token::PlusFlag(_)
| Token::DoubleDash => TokenCategory::Flag,
Token::Semi
| Token::DoubleSemi
| Token::Colon
| Token::Comma
| Token::Dot
| Token::LParen
| Token::RParen
| Token::LBrace
| Token::RBrace
| Token::LBracket
| Token::RBracket
| Token::Bang
| Token::Question
| Token::Star
| Token::Newline
| Token::LineContinuation
| Token::CmdSubstStart
| Token::DotDotDot => TokenCategory::Punctuation,
Token::GlobWord(_) => TokenCategory::Path,
Token::Comment => TokenCategory::Comment,
Token::Path(_)
| Token::TildePath(_)
| Token::RelativePath(_)
| Token::Tilde
| Token::DotDot
| Token::DotSlashPath(_) => TokenCategory::Path,
Token::Ident(_)
| Token::PlusBare(_)
| Token::MinusBare(_)
| Token::DoubleDashBare(_)
| Token::MinusAlone
| Token::NumberIdent(_)
| Token::DashNumWord(_)
| Token::AtWord(_)
| Token::DottedIdent(_)
| Token::JobSpec(_) => TokenCategory::Command,
Token::InvalidFloatNoLeading
| Token::InvalidFloatNoTrailing
| Token::BacktickRejected => TokenCategory::Error,
}
}
}
fn lex_string(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
enum Region {
Quoted,
Substitution,
}
let mut stack = vec![Region::Quoted];
let mut extra = 0usize;
let mut chars = lex.remainder().chars().peekable();
while let Some(c) = chars.next() {
extra += c.len_utf8();
match c {
'\\' => {
if let Some(next) = chars.next() {
extra += next.len_utf8();
}
}
'$' if chars.peek() == Some(&'(') => {
chars.next();
extra += 1;
stack.push(Region::Substitution);
}
'"' => match stack.last() {
Some(Region::Quoted) => {
stack.pop();
if stack.is_empty() {
lex.bump(extra);
return parse_string_literal(lex.slice());
}
}
Some(Region::Substitution) => stack.push(Region::Quoted),
None => break,
},
'\'' if matches!(stack.last(), Some(Region::Substitution)) => {
for c in chars.by_ref() {
extra += c.len_utf8();
if c == '\'' {
break;
}
}
}
'(' if matches!(stack.last(), Some(Region::Substitution)) => {
stack.push(Region::Substitution);
}
')' if matches!(stack.last(), Some(Region::Substitution)) => {
stack.pop();
}
_ => {}
}
}
if stack.iter().any(|r| matches!(r, Region::Substitution)) {
return Err(LexerError::UnterminatedCommandSubst);
}
Err(LexerError::UnterminatedString)
}
fn lex_single_string(lex: &mut logos::Lexer<Token>) -> String {
let s = lex.slice();
s[1..s.len() - 1].to_string()
}
fn lex_varref(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
let mut depth = 1usize;
let mut extra = 0usize;
for c in lex.remainder().chars() {
extra += c.len_utf8();
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
if extra == 1 {
return Err(LexerError::UnexpectedCharacter);
}
lex.bump(extra);
return Ok(lex.slice().to_string());
}
}
_ => {}
}
}
Err(LexerError::UnterminatedVarRef)
}
fn lex_simple_varref(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
Ok(lex.slice()[1..].to_string())
}
fn lex_positional(lex: &mut logos::Lexer<Token>) -> usize {
lex.slice()[1..].parse().unwrap_or(0)
}
fn lex_var_length(lex: &mut logos::Lexer<Token>) -> String {
let s = lex.slice();
s[3..s.len() - 1].to_string()
}
fn lex_int(lex: &mut logos::Lexer<Token>) -> Result<i64, LexerError> {
lex.slice().parse().map_err(|_| LexerError::InvalidNumber)
}
fn lex_float(lex: &mut logos::Lexer<Token>) -> Result<f64, LexerError> {
lex.slice().parse().map_err(|_| LexerError::InvalidNumber)
}
fn lex_number_ident(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_dotted_ident(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_slice_word(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_invalid_float_no_leading(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
Err(LexerError::InvalidFloatNoLeading)
}
fn reject_backtick(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
Err(LexerError::BackticksNotSupported)
}
fn opens_a_word(c: char) -> bool {
c.is_whitespace() || matches!(c, ';' | '|' | '&' | '<' | '>' | '(')
}
fn lex_comment(lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
match lex.source()[..lex.span().start].chars().next_back() {
None => Ok(()),
Some(c) if opens_a_word(c) => Ok(()),
Some(_) => Err(LexerError::HashInsideWord),
}
}
fn lex_invalid_float_no_trailing(_lex: &mut logos::Lexer<Token>) -> Result<(), LexerError> {
Err(LexerError::InvalidFloatNoTrailing)
}
fn lex_ident(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
Ok(lex.slice().to_string())
}
fn lex_long_flag(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
let s = lex.slice();
if !s.is_ascii() {
return Err(LexerError::NonAsciiName { kind: "flag", text: s.to_string() });
}
Ok(s[2..].to_string())
}
fn lex_short_flag(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
let s = lex.slice();
if !s.is_ascii() {
return Err(LexerError::NonAsciiName { kind: "flag", text: s.to_string() });
}
Ok(s[1..].to_string())
}
fn lex_plus_flag(lex: &mut logos::Lexer<Token>) -> Result<String, LexerError> {
let s = lex.slice();
if !s.is_ascii() {
return Err(LexerError::NonAsciiName { kind: "flag", text: s.to_string() });
}
Ok(s[1..].to_string())
}
fn lex_plus_bare(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_minus_bare(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_double_dash_bare(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_job_spec(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_path(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_tilde_path(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_relative_path(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
fn lex_dot_slash_path(lex: &mut logos::Lexer<Token>) -> String {
lex.slice().to_string()
}
impl fmt::Display for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Token::Set => write!(f, "set"),
Token::Local => write!(f, "local"),
Token::If => write!(f, "if"),
Token::Then => write!(f, "then"),
Token::Else => write!(f, "else"),
Token::Elif => write!(f, "elif"),
Token::Fi => write!(f, "fi"),
Token::For => write!(f, "for"),
Token::While => write!(f, "while"),
Token::In => write!(f, "in"),
Token::Do => write!(f, "do"),
Token::Done => write!(f, "done"),
Token::Case => write!(f, "case"),
Token::Esac => write!(f, "esac"),
Token::Function => write!(f, "function"),
Token::Break => write!(f, "break"),
Token::Continue => write!(f, "continue"),
Token::Return => write!(f, "return"),
Token::Exit => write!(f, "exit"),
Token::True => write!(f, "true"),
Token::False => write!(f, "false"),
Token::TypeString => write!(f, "string"),
Token::TypeInt => write!(f, "int"),
Token::TypeFloat => write!(f, "float"),
Token::TypeBool => write!(f, "bool"),
Token::And => write!(f, "&&"),
Token::Or => write!(f, "||"),
Token::EqEq => write!(f, "=="),
Token::NotEq => write!(f, "!="),
Token::Match => write!(f, "=~"),
Token::NotMatch => write!(f, "!~"),
Token::GtEq => write!(f, ">="),
Token::LtEq => write!(f, "<="),
Token::GtGt => write!(f, ">>"),
Token::StderrToStdout => write!(f, "2>&1"),
Token::StdoutToStderr => write!(f, "1>&2"),
Token::StdoutToStderr2 => write!(f, ">&2"),
Token::Stderr => write!(f, "2>"),
Token::Both => write!(f, "&>"),
Token::HereDocStart => write!(f, "<<"),
Token::HereString => write!(f, "<<<"),
Token::DoubleSemi => write!(f, ";;"),
Token::Eq => write!(f, "="),
Token::Pipe => write!(f, "|"),
Token::Amp => write!(f, "&"),
Token::Gt => write!(f, ">"),
Token::Lt => write!(f, "<"),
Token::Semi => write!(f, ";"),
Token::Colon => write!(f, ":"),
Token::Comma => write!(f, ","),
Token::Dot => write!(f, "."),
Token::DotDot => write!(f, ".."),
Token::DotDotDot => write!(f, "..."),
Token::Tilde => write!(f, "~"),
Token::TildePath(s) => write!(f, "{}", s),
Token::RelativePath(s) => write!(f, "{}", s),
Token::DotSlashPath(s) => write!(f, "{}", s),
Token::LBrace => write!(f, "{{"),
Token::RBrace => write!(f, "}}"),
Token::LBracket => write!(f, "["),
Token::RBracket => write!(f, "]"),
Token::LParen => write!(f, "("),
Token::RParen => write!(f, ")"),
Token::Star => write!(f, "*"),
Token::Bang => write!(f, "!"),
Token::Question => write!(f, "?"),
Token::GlobWord(s) => write!(f, "GLOB({})", s),
Token::Arithmetic(s) => write!(f, "ARITHMETIC({})", s),
Token::CmdSubstStart => write!(f, "$("),
Token::LongFlag(s) => write!(f, "--{}", s),
Token::ShortFlag(s) => write!(f, "-{}", s),
Token::PlusFlag(s) => write!(f, "+{}", s),
Token::DoubleDash => write!(f, "--"),
Token::DoubleDashBare(s) => write!(f, "{}", s),
Token::PlusBare(s) => write!(f, "{}", s),
Token::MinusBare(s) => write!(f, "{}", s),
Token::JobSpec(s) => write!(f, "{}", s),
Token::MinusAlone => write!(f, "-"),
Token::String(s) => write!(f, "STRING({:?})", s),
Token::SingleString(s) => write!(f, "SINGLESTRING({:?})", s),
Token::HereDoc(d) => write!(f, "HEREDOC({:?}, literal={})", d.content, d.literal),
Token::VarRef(v) => write!(f, "VARREF({})", v),
Token::SimpleVarRef(v) => write!(f, "SIMPLEVARREF({})", v),
Token::Positional(n) => write!(f, "${}", n),
Token::AllArgs => write!(f, "$@"),
Token::ArgCount => write!(f, "$#"),
Token::LastExitCode => write!(f, "$?"),
Token::CurrentPid => write!(f, "$$"),
Token::VarLength(v) => write!(f, "${{#{}}}", v),
Token::Int(n) => write!(f, "INT({})", n),
Token::Float(n) => write!(f, "FLOAT({})", n),
Token::Path(s) => write!(f, "PATH({})", s),
Token::Ident(s) => write!(f, "IDENT({})", s),
Token::NumberIdent(s) => write!(f, "NUMIDENT({})", s),
Token::DashNumWord(s) => write!(f, "DASHNUM({})", s),
Token::AtWord(s) => write!(f, "ATWORD({})", s),
Token::DottedIdent(s) => write!(f, "DOTIDENT({})", s),
Token::Comment => write!(f, "COMMENT"),
Token::Newline => write!(f, "NEWLINE"),
Token::LineContinuation => write!(f, "LINECONT"),
Token::InvalidFloatNoLeading => write!(f, "INVALID_FLOAT_NO_LEADING"),
Token::InvalidFloatNoTrailing => write!(f, "INVALID_FLOAT_NO_TRAILING"),
Token::BacktickRejected => write!(f, "BACKTICK_REJECTED"),
}
}
}
impl Token {
pub fn is_keyword(&self) -> bool {
matches!(
self,
Token::Set
| Token::Local
| Token::If
| Token::Then
| Token::Else
| Token::Elif
| Token::Fi
| Token::For
| Token::In
| Token::Do
| Token::Done
| Token::While
| Token::Case
| Token::Esac
| Token::Function
| Token::Return
| Token::Break
| Token::Continue
| Token::Exit
| Token::True
| Token::False
)
}
pub fn is_type(&self) -> bool {
matches!(
self,
Token::TypeString
| Token::TypeInt
| Token::TypeFloat
| Token::TypeBool
)
}
pub fn starts_statement(&self) -> bool {
matches!(
self,
Token::Set
| Token::Local
| Token::Function
| Token::If
| Token::For
| Token::While
| Token::Case
| Token::Ident(_)
| Token::LBracket
)
}
pub fn is_value(&self) -> bool {
matches!(
self,
Token::String(_)
| Token::SingleString(_)
| Token::HereDoc(_)
| Token::Arithmetic(_)
| Token::Int(_)
| Token::Float(_)
| Token::True
| Token::False
| Token::VarRef(_)
| Token::SimpleVarRef(_)
| Token::CmdSubstStart
| Token::Path(_)
| Token::GlobWord(_)
| Token::LastExitCode
| Token::CurrentPid
)
}
}
#[derive(Debug, Clone)]
struct Replacement {
orig_start: usize,
orig_len: usize,
new_start: usize,
new_len: usize,
kind: ReplacementKind,
}
#[derive(Debug, Clone, PartialEq)]
enum ReplacementKind {
Arith(usize),
HeredocIntro(usize),
Elision,
}
fn map_position(p: usize, is_end: bool, replacements: &[Replacement]) -> usize {
let mut delta: isize = 0;
for r in replacements {
let r_end = r.new_start + r.new_len;
let past = if is_end {
p >= r_end && p > r.new_start
} else {
p >= r_end
};
if past {
delta += r.orig_len as isize - r.new_len as isize;
} else if p > r.new_start {
return r.orig_start + (p - r.new_start).min(r.orig_len);
} else {
break; }
}
((p as isize) + delta).max(0) as usize
}
fn map_span(span: &Span, replacements: &[Replacement]) -> Span {
let start = map_position(span.start, false, replacements);
let end = map_position(span.end, true, replacements).max(start);
start..end
}
#[derive(Debug, Clone)]
struct HeredocExtract {
body: String,
source_body: String,
delimiter: String,
literal: bool,
strip_tabs: bool,
body_start_offset: usize,
}
struct PendingHeredoc {
delimiter: String,
literal: bool,
strip_tabs: bool,
intro_span: Span,
}
struct ScanOutput {
text: String,
arithmetics: Vec<(String, String)>,
heredocs: Vec<HeredocExtract>,
replacements: Vec<Replacement>,
}
fn scan(source: &str) -> Result<ScanOutput, Spanned<LexerError>> {
let chars: Vec<(usize, char)> = source.char_indices().collect();
let n = chars.len();
let total_len = source.len();
let byte_at = |i: usize| -> usize {
if i < n { chars[i].0 } else { total_len }
};
let mut out = String::with_capacity(source.len());
let mut arithmetics: Vec<(String, String)> = Vec::new();
let mut heredocs: Vec<HeredocExtract> = Vec::new();
let mut replacements: Vec<Replacement> = Vec::new();
let mut pending: Vec<PendingHeredoc> = Vec::new();
let mut i = 0;
while i < n {
let (pos, ch) = chars[i];
if ch == '\\' && i + 1 < n {
out.push(ch);
out.push(chars[i + 1].1);
i += 2;
continue;
}
match ch {
'\'' => {
out.push(ch);
i += 1;
while i < n && chars[i].1 != '\'' {
out.push(chars[i].1);
i += 1;
}
if i < n {
out.push('\''); i += 1;
}
}
'"' => {
out.push(ch);
i += 1;
while i < n {
let (dpos, dch) = chars[i];
if dch == '\\' && i + 1 < n {
let next = chars[i + 1].1;
if next == '"' || next == '\\' || next == '$' || next == '`' {
out.push(dch);
out.push(next);
i += 2;
continue;
}
}
if dch == '"' {
out.push(dch);
i += 1;
break;
}
if dch == '$'
&& i + 2 < n
&& chars[i + 1].1 == '('
&& chars[i + 2].1 == '('
{
extract_arithmetic(
&chars,
&mut i,
dpos,
total_len,
&mut out,
&mut arithmetics,
&mut replacements,
)?;
continue;
}
if dch == '$' && i + 1 < n && chars[i + 1].1 == '(' {
copy_substitution_verbatim(&chars, &mut i, &mut out);
continue;
}
out.push(dch);
i += 1;
}
}
'#' if out.chars().next_back().is_none_or(opens_a_word) => {
while i < n && chars[i].1 != '\n' && chars[i].1 != '\r' {
out.push(chars[i].1);
i += 1;
}
}
'<' if i + 1 < n && chars[i + 1].1 == '<' => {
if i + 2 < n && chars[i + 2].1 == '<' {
out.push_str("<<<");
i += 3;
continue;
}
let heredoc_index = heredocs.len() + pending.len();
scan_heredoc_introducer(
&chars,
&mut i,
pos,
&mut out,
&mut pending,
&mut replacements,
heredoc_index,
);
}
'$' if i + 2 < n && chars[i + 1].1 == '(' && chars[i + 2].1 == '(' => {
extract_arithmetic(
&chars,
&mut i,
pos,
total_len,
&mut out,
&mut arithmetics,
&mut replacements,
)?;
}
'$' if i + 1 < n && chars[i + 1].1 == '{' => {
out.push('$');
out.push('{');
i += 2;
let mut depth = 1usize;
while i < n && depth > 0 {
let (vpos, vch) = chars[i];
if vch == '$'
&& i + 2 < n
&& chars[i + 1].1 == '('
&& chars[i + 2].1 == '('
{
return Err(Spanned::new(
LexerError::ArithmeticInVarRef,
vpos..(byte_at(i + 3)),
));
}
match vch {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
out.push(vch);
i += 1;
}
}
'\n' => {
out.push('\n');
i += 1;
if !pending.is_empty() {
collect_heredoc_bodies(
&chars,
&mut i,
total_len,
out.len(),
&mut pending,
&mut heredocs,
&mut replacements,
)?;
}
}
'\r' if !pending.is_empty()
&& chars.get(i + 1).map(|c| c.1) != Some('\n') =>
{
out.push('\n');
i += 1;
collect_heredoc_bodies(
&chars,
&mut i,
total_len,
out.len(),
&mut pending,
&mut heredocs,
&mut replacements,
)?;
}
_ => {
out.push(ch);
i += 1;
}
}
}
if let Some(p) = pending.first() {
return Err(Spanned::new(
LexerError::UnterminatedHeredoc {
delimiter: p.delimiter.clone(),
},
p.intro_span.clone(),
));
}
Ok(ScanOutput {
text: out,
arithmetics,
heredocs,
replacements,
})
}
fn copy_substitution_verbatim(chars: &[(usize, char)], i: &mut usize, out: &mut String) {
enum Region {
Quoted,
Substitution,
}
let n = chars.len();
out.push('$');
out.push('(');
*i += 2;
let mut stack = vec![Region::Substitution];
while *i < n {
let c = chars[*i].1;
if c == '\\' {
out.push(c);
*i += 1;
if *i < n {
out.push(chars[*i].1);
*i += 1;
}
continue;
}
if c == '$' && *i + 1 < n && chars[*i + 1].1 == '(' {
out.push('$');
out.push('(');
*i += 2;
stack.push(Region::Substitution);
continue;
}
match c {
'"' => match stack.last() {
Some(Region::Quoted) => {
stack.pop();
}
Some(Region::Substitution) => stack.push(Region::Quoted),
None => {}
},
'\'' if matches!(stack.last(), Some(Region::Substitution)) => {
out.push(c);
*i += 1;
while *i < n {
let q = chars[*i].1;
out.push(q);
*i += 1;
if q == '\'' {
break;
}
}
continue;
}
'(' if matches!(stack.last(), Some(Region::Substitution)) => {
stack.push(Region::Substitution);
}
')' if matches!(stack.last(), Some(Region::Substitution)) => {
stack.pop();
if stack.is_empty() {
out.push(c);
*i += 1;
return;
}
}
_ => {}
}
out.push(c);
*i += 1;
}
}
fn extract_arithmetic(
chars: &[(usize, char)],
i: &mut usize,
start_pos: usize,
total_len: usize,
out: &mut String,
arithmetics: &mut Vec<(String, String)>,
replacements: &mut Vec<Replacement>,
) -> Result<(), Spanned<LexerError>> {
let n = chars.len();
*i += 3;
let mut expr = String::new();
let mut depth = 0usize;
let mut closed = false;
while *i < n {
let c = chars[*i].1;
match c {
'(' => {
depth += 1;
if depth > MAX_PAREN_DEPTH {
return Err(Spanned::new(
LexerError::NestingTooDeep,
start_pos..chars[*i].0,
));
}
expr.push('(');
*i += 1;
}
')' => {
if depth > 0 {
depth -= 1;
expr.push(')');
*i += 1;
} else if *i + 1 < n && chars[*i + 1].1 == ')' {
*i += 2;
closed = true;
break;
} else if *i + 1 == n {
break;
} else {
expr.push(')');
*i += 1;
}
}
_ => {
expr.push(c);
*i += 1;
}
}
}
if !closed {
return Err(Spanned::new(
LexerError::UnterminatedArithmetic,
start_pos..total_len,
));
}
let end_pos = if *i < n { chars[*i].0 } else { total_len };
let marker = format!("__KAISH_ARITH_{}__", unique_marker_id());
replacements.push(Replacement {
orig_start: start_pos,
orig_len: end_pos - start_pos,
new_start: out.len(),
new_len: marker.len(),
kind: ReplacementKind::Arith(arithmetics.len()),
});
arithmetics.push((marker.clone(), expr));
out.push_str(&marker);
Ok(())
}
fn scan_heredoc_introducer(
chars: &[(usize, char)],
i: &mut usize,
intro_start: usize,
out: &mut String,
pending: &mut Vec<PendingHeredoc>,
replacements: &mut Vec<Replacement>,
heredoc_index: usize,
) {
let n = chars.len();
*i += 2;
let strip_tabs = *i < n && chars[*i].1 == '-';
if strip_tabs {
*i += 1;
}
while *i < n && (chars[*i].1 == ' ' || chars[*i].1 == '\t') {
*i += 1;
}
let mut delimiter = String::new();
let mut literal = false;
while *i < n {
let c = chars[*i].1;
match c {
'\'' | '"' => {
literal = true;
let quote = c;
*i += 1;
while *i < n && chars[*i].1 != quote {
delimiter.push(chars[*i].1);
*i += 1;
}
if *i < n {
*i += 1; }
}
c if c.is_whitespace() => break,
c => {
delimiter.push(c);
*i += 1;
}
}
}
let word_end = if *i < n {
chars[*i].0
} else {
chars
.last()
.map(|(pos, c)| pos + c.len_utf8())
.unwrap_or(intro_start + 2)
};
if delimiter.is_empty() {
out.push_str("<<");
if strip_tabs {
out.push('-');
}
return;
}
let marker = format!("__KAISH_HEREDOC_{}__", unique_marker_id());
out.push_str("<<");
replacements.push(Replacement {
orig_start: intro_start + 2,
orig_len: word_end - (intro_start + 2),
new_start: out.len(),
new_len: marker.len(),
kind: ReplacementKind::HeredocIntro(heredoc_index),
});
out.push_str(&marker);
pending.push(PendingHeredoc {
delimiter,
literal,
strip_tabs,
intro_span: intro_start..word_end,
});
}
fn collect_heredoc_bodies(
chars: &[(usize, char)],
i: &mut usize,
total_len: usize,
out_len: usize,
pending: &mut Vec<PendingHeredoc>,
heredocs: &mut Vec<HeredocExtract>,
replacements: &mut Vec<Replacement>,
) -> Result<(), Spanned<LexerError>> {
let n = chars.len();
for p in pending.drain(..) {
let body_start = if *i < n { chars[*i].0 } else { total_len };
let mut body = String::new();
let mut found = false;
while !found {
if *i >= n {
return Err(Spanned::new(
LexerError::UnterminatedHeredoc {
delimiter: p.delimiter.clone(),
},
p.intro_span.clone(),
));
}
let mut line = String::new();
let mut terminator = "";
let mut at_eof = false;
loop {
if *i >= n {
at_eof = true;
break;
}
let c = chars[*i].1;
if c == '\n' {
*i += 1;
terminator = "\n";
break;
}
if c == '\r' {
*i += 1;
if *i < n && chars[*i].1 == '\n' {
*i += 1;
terminator = "\r\n";
} else {
terminator = "\r";
}
break;
}
line.push(c);
*i += 1;
}
let compare = if p.strip_tabs {
line.trim_start_matches('\t')
} else {
line.as_str()
};
if compare == p.delimiter {
found = true;
} else if at_eof {
return Err(Spanned::new(
LexerError::UnterminatedHeredoc {
delimiter: p.delimiter.clone(),
},
p.intro_span.clone(),
));
} else {
body.push_str(&line);
body.push_str(terminator);
}
}
let elide_end = if *i < n { chars[*i].0 } else { total_len };
replacements.push(Replacement {
orig_start: body_start,
orig_len: elide_end - body_start,
new_start: out_len,
new_len: 0,
kind: ReplacementKind::Elision,
});
let content = if p.literal {
body.clone()
} else {
rewrite_body_arithmetic(&body, &p)?
};
heredocs.push(HeredocExtract {
body: content,
source_body: body,
delimiter: p.delimiter.clone(),
literal: p.literal,
strip_tabs: p.strip_tabs,
body_start_offset: body_start,
});
}
Ok(())
}
fn rewrite_body_arithmetic(
body: &str,
p: &PendingHeredoc,
) -> Result<String, Spanned<LexerError>> {
if !body.contains("$((") {
return Ok(body.to_string());
}
let chars: Vec<char> = body.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(body.len());
let mut i = 0;
while i < n {
if chars[i] == '\\' && i + 1 < n {
out.push(chars[i]);
out.push(chars[i + 1]);
i += 2;
continue;
}
if chars[i] == '$' && i + 2 < n && chars[i + 1] == '(' && chars[i + 2] == '(' {
i += 3;
let mut expr = String::new();
let mut depth = 0usize;
let mut closed = false;
while i < n {
let c = chars[i];
match c {
'(' => {
depth += 1;
expr.push('(');
i += 1;
}
')' => {
if depth > 0 {
depth -= 1;
expr.push(')');
i += 1;
} else if i + 1 < n && chars[i + 1] == ')' {
i += 2;
closed = true;
break;
} else {
expr.push(')');
i += 1;
}
}
_ => {
expr.push(c);
i += 1;
}
}
}
if !closed {
return Err(Spanned::new(
LexerError::UnterminatedArithmetic,
p.intro_span.clone(),
));
}
out.push_str(&format!("${{__ARITH:{}__}}", expr));
continue;
}
out.push(chars[i]);
i += 1;
}
Ok(out)
}
fn resolve_markers(
tokens: Vec<Spanned<Token>>,
scan: &ScanOutput,
) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
let markers: Vec<&Replacement> = scan
.replacements
.iter()
.filter(|r| !matches!(r.kind, ReplacementKind::Elision))
.collect();
let mut result = Vec::with_capacity(tokens.len());
let mut mi = 0usize;
for spanned in tokens {
let span = spanned.span.clone();
while mi < markers.len() && markers[mi].new_start + markers[mi].new_len <= span.start {
mi += 1;
}
let mut contained = Vec::new();
let mut mj = mi;
while mj < markers.len() {
let m = markers[mj];
if m.new_start >= span.end {
break;
}
if m.new_start >= span.start && m.new_start + m.new_len <= span.end {
contained.push(m);
}
mj += 1;
}
if contained.is_empty() {
result.push(spanned);
continue;
}
match (&spanned.token, contained.as_slice()) {
(Token::Ident(_), [m])
if matches!(m.kind, ReplacementKind::Arith(_))
&& m.new_start == span.start
&& m.new_start + m.new_len == span.end =>
{
let ReplacementKind::Arith(idx) = m.kind else {
unreachable!("guarded by matches! above")
};
result.push(Spanned::new(
Token::Arithmetic(scan.arithmetics[idx].1.clone()),
span,
));
}
(Token::Ident(_), [m])
if matches!(m.kind, ReplacementKind::HeredocIntro(_))
&& m.new_start == span.start
&& m.new_start + m.new_len == span.end =>
{
let ReplacementKind::HeredocIntro(idx) = m.kind else {
unreachable!("guarded by matches! above")
};
let hd = &scan.heredocs[idx];
result.push(Spanned::new(
Token::HereDoc(HereDocData {
content: hd.body.clone(),
source_body: hd.source_body.clone(),
delimiter: hd.delimiter.clone(),
literal: hd.literal,
strip_tabs: hd.strip_tabs,
body_start_offset: hd.body_start_offset,
}),
span,
));
}
(Token::String(s), ms) => {
let mut content = s.clone();
for m in ms {
let ReplacementKind::Arith(idx) = m.kind else {
unreachable!("heredoc marker inside string content")
};
let (marker, expr) = &scan.arithmetics[idx];
content =
content.replacen(marker, &format!("${{__ARITH:{}__}}", expr), 1);
}
result.push(Spanned::new(Token::String(content), span));
}
_ => {
let mut cursor = span.start;
for m in &contained {
if m.new_start > cursor {
relex_fragment(
&scan.text[cursor..m.new_start],
cursor,
&mut result,
)?;
}
match m.kind {
ReplacementKind::Arith(idx) => {
result.push(Spanned::new(
Token::Arithmetic(scan.arithmetics[idx].1.clone()),
m.new_start..m.new_start + m.new_len,
));
}
ReplacementKind::HeredocIntro(_) | ReplacementKind::Elision => {
unreachable!("heredoc marker glued into word token")
}
}
cursor = m.new_start + m.new_len;
}
if cursor < span.end {
relex_fragment(&scan.text[cursor..span.end], cursor, &mut result)?;
}
}
}
mi = mj;
}
Ok(result)
}
fn relex_fragment(
fragment: &str,
base: usize,
result: &mut Vec<Spanned<Token>>,
) -> Result<(), Vec<Spanned<LexerError>>> {
if fragment.starts_with('#') {
return Err(vec![Spanned::new(
LexerError::HashInsideWord,
base..base + fragment.len(),
)]);
}
let mut errors = Vec::new();
for (tok, span) in Token::lexer(fragment).spanned() {
let span = base + span.start..base + span.end;
match tok {
Ok(t) => result.push(Spanned::new(t, span)),
Err(e) => errors.push(Spanned::new(e, span)),
}
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
#[derive(Clone, Copy, Default)]
struct ValueContext {
in_literal: bool,
in_brace: bool,
push_target: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum PushTarget {
None,
AwaitingRoot,
Root(usize),
RootSubscript(usize),
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Frame {
Test,
List,
Record,
Subst,
Paren,
Case { awaiting_pattern: bool },
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum StmtHead {
Start,
AfterLocal,
Lvalue(usize),
LvalueSubscript(usize),
Value,
Argv,
}
pub(crate) fn is_statement_boundary(token: &Token) -> bool {
matches!(
token,
Token::Newline
| Token::Semi
| Token::DoubleSemi
| Token::And
| Token::Or
| Token::Pipe
| Token::Amp
| Token::If
| Token::Then
| Token::Elif
| Token::Else
| Token::Fi
| Token::While
| Token::Do
| Token::Done
| Token::For
| Token::Case
| Token::Esac
| Token::In
)
}
fn compute_value_context(tokens: &[Spanned<Token>]) -> Vec<ValueContext> {
let mut ctx = vec![ValueContext::default(); tokens.len()];
let mut frames: Vec<Frame> = Vec::new();
let mut scopes: Vec<StmtHead> = vec![StmtHead::Start];
let mut expect_value = false;
let mut skip_paired_bracket = false;
let mut scope_floors: Vec<usize> = vec![0];
let mut push_target = PushTarget::None;
for i in 0..tokens.len() {
let tok = &tokens[i].token;
let span = &tokens[i].span;
let floor = *scope_floors.last().unwrap_or(&0);
let top = frames.last().copied();
let in_open_literal = frames.len() > floor
&& matches!(top, Some(Frame::List) | Some(Frame::Record));
ctx[i] = ValueContext {
in_literal: expect_value || in_open_literal,
in_brace: matches!(top, Some(Frame::Record)),
push_target: false, };
let in_test = frames[floor..].contains(&Frame::Test);
let opens_value = expect_value;
expect_value = false;
if skip_paired_bracket {
skip_paired_bracket = false;
continue;
}
let stmt_head_is_start = matches!(scopes.last(), Some(StmtHead::Start));
push_target = if is_statement_boundary(tok) {
PushTarget::None
} else {
match (push_target, tok) {
(PushTarget::None, Token::Ident(s)) if stmt_head_is_start && s == "push" => {
PushTarget::AwaitingRoot
}
(PushTarget::AwaitingRoot, Token::Ident(_)) => PushTarget::Root(span.end),
(PushTarget::Root(end), Token::LBracket) if span.start == end => {
PushTarget::RootSubscript(1)
}
(PushTarget::RootSubscript(d), Token::LBracket) => {
PushTarget::RootSubscript(d + 1)
}
(PushTarget::RootSubscript(d), Token::RBracket) => {
if d == 1 {
PushTarget::Root(span.end)
} else {
PushTarget::RootSubscript(d - 1)
}
}
(PushTarget::RootSubscript(d), _) => PushTarget::RootSubscript(d),
_ => PushTarget::None,
}
};
ctx[i].push_target =
matches!(push_target, PushTarget::Root(_) | PushTarget::RootSubscript(_));
match tok {
Token::LBracket => {
let next_adjacent_lbracket = tokens.get(i + 1).is_some_and(|t| {
matches!(t.token, Token::LBracket) && t.span.start == span.end
});
let dfa = scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
if let StmtHead::Lvalue(end) = *dfa {
if span.start == end {
*dfa = StmtHead::LvalueSubscript(1);
continue;
}
}
if let StmtHead::LvalueSubscript(depth) = *dfa {
*dfa = StmtHead::LvalueSubscript(depth + 1);
continue;
}
if opens_value || in_open_literal {
frames.push(Frame::List);
} else if next_adjacent_lbracket {
frames.push(Frame::Test);
skip_paired_bracket = true;
}
}
Token::RBracket => {
let dfa = scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
if let StmtHead::LvalueSubscript(depth) = *dfa {
*dfa = if depth == 1 {
StmtHead::Lvalue(span.end)
} else {
StmtHead::LvalueSubscript(depth - 1)
};
continue;
}
let next_adjacent_rbracket = tokens.get(i + 1).is_some_and(|t| {
matches!(t.token, Token::RBracket) && t.span.start == span.end
});
if frames.len() > floor && top == Some(Frame::List) {
frames.pop();
if frames.len() == floor {
let dfa = scopes
.last_mut()
.unwrap_or_else(|| unreachable!("scopes never empty"));
if *dfa == StmtHead::Value {
*dfa = StmtHead::Start;
}
}
} else if frames.len() > floor
&& top == Some(Frame::Test)
&& next_adjacent_rbracket
{
frames.pop();
skip_paired_bracket = true;
}
}
Token::LBrace => {
if opens_value || in_open_literal {
frames.push(Frame::Record);
} else {
*scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
StmtHead::Start;
}
}
Token::RBrace => {
if frames.len() > floor && top == Some(Frame::Record) {
frames.pop();
if frames.len() == floor {
let dfa = scopes
.last_mut()
.unwrap_or_else(|| unreachable!("scopes never empty"));
if *dfa == StmtHead::Value {
*dfa = StmtHead::Start;
}
}
} else {
*scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
StmtHead::Start;
}
}
Token::CmdSubstStart => {
frames.push(Frame::Subst);
scope_floors.push(frames.len());
scopes.push(StmtHead::Start);
}
Token::LParen => {
frames.push(Frame::Paren);
}
Token::Case => {
let is_argv_key =
matches!(tokens.get(i + 1).map(|t| &t.token), Some(Token::Eq));
if !is_argv_key {
frames.push(Frame::Case { awaiting_pattern: true });
}
*scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
StmtHead::Start;
}
Token::DoubleSemi => {
if let Some(Frame::Case { awaiting_pattern }) = frames.last_mut() {
*awaiting_pattern = true;
}
*scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
StmtHead::Start;
while frames.len() > floor
&& matches!(frames.last(), Some(Frame::Test) | Some(Frame::List) | Some(Frame::Record))
{
frames.pop();
}
}
Token::Esac => {
if matches!(frames.last(), Some(Frame::Case { awaiting_pattern: true })) {
frames.pop();
}
*scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
StmtHead::Start;
}
Token::RParen => {
while let Some(f) = frames.last().copied() {
match f {
Frame::Subst => {
frames.pop();
scope_floors.pop();
scopes.pop();
if scopes.is_empty() {
scopes.push(StmtHead::Start);
}
if scope_floors.is_empty() {
scope_floors.push(0);
}
let enclosing_floor = *scope_floors.last().unwrap_or(&0);
if frames.len() == enclosing_floor {
let dfa = scopes
.last_mut()
.unwrap_or_else(|| unreachable!("scopes never empty"));
if *dfa == StmtHead::Value {
*dfa = StmtHead::Start;
}
}
break;
}
Frame::Paren => {
frames.pop();
if let Some(Frame::Case { awaiting_pattern }) = frames.last_mut() {
*awaiting_pattern = false;
}
break;
}
Frame::Case { .. } => {
if let Some(Frame::Case { awaiting_pattern }) = frames.last_mut() {
*awaiting_pattern = false;
}
break;
}
_ => {
frames.pop();
}
}
}
}
Token::Eq => {
let dfa = scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
if matches!(*dfa, StmtHead::Lvalue(_)) && !in_test {
expect_value = true;
*dfa = StmtHead::Value;
} else if matches!(*dfa, StmtHead::Value) {
*dfa = StmtHead::Argv;
}
}
Token::In if in_test => {
expect_value = true;
}
t if is_statement_boundary(t) => {
*scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty")) =
StmtHead::Start;
match t {
Token::Newline => {
while frames.len() > floor && frames.last() == Some(&Frame::Test) {
frames.pop();
}
}
Token::Semi
| Token::DoubleSemi
| Token::Pipe
| Token::Amp
| Token::And
| Token::Or => {
while frames.len() > floor
&& matches!(
frames.last(),
Some(Frame::Test) | Some(Frame::List) | Some(Frame::Record)
)
{
frames.pop();
}
}
_ => {}
}
}
_ => {
if !in_open_literal {
let dfa =
scopes.last_mut().unwrap_or_else(|| unreachable!("scopes never empty"));
*dfa = match (*dfa, tok) {
(StmtHead::Start, Token::Local) => StmtHead::AfterLocal,
(StmtHead::Start, Token::Ident(_)) => StmtHead::Lvalue(span.end),
(StmtHead::AfterLocal, Token::Ident(_)) => StmtHead::Lvalue(span.end),
(StmtHead::LvalueSubscript(d), _) => StmtHead::LvalueSubscript(d),
(StmtHead::Value, _) => StmtHead::Start,
_ => StmtHead::Argv,
};
}
}
}
}
ctx
}
fn is_colon_mergeable(token: &Token) -> bool {
matches!(
token,
Token::Ident(_)
| Token::NumberIdent(_)
| Token::DashNumWord(_)
| Token::AtWord(_)
| Token::DottedIdent(_)
| Token::Colon
| Token::Int(_)
| Token::Path(_)
| Token::Float(_)
)
}
fn merge_colon_adjacent(tokens: Vec<Spanned<Token>>, source: &str) -> Vec<Spanned<Token>> {
if tokens.is_empty() {
return tokens;
}
let value_ctx = compute_value_context(&tokens);
let mut result = Vec::with_capacity(tokens.len());
let mut run: Vec<&Spanned<Token>> = Vec::new();
let mut run_start = 0usize;
for (idx, token) in tokens.iter().enumerate() {
if run.is_empty() {
if is_colon_mergeable(&token.token) {
run.push(token);
run_start = idx;
} else {
result.push(token.clone());
}
continue;
}
let Some(last) = run.last() else { unreachable!() };
let adjacent = last.span.end == token.span.start;
if adjacent && is_colon_mergeable(&token.token) {
run.push(token);
} else {
flush_colon_run(&mut run, &mut result, value_ctx[run_start].in_brace, source);
if is_colon_mergeable(&token.token) {
run.push(token);
run_start = idx;
} else {
result.push(token.clone());
}
}
}
flush_colon_run(&mut run, &mut result, value_ctx[run_start].in_brace, source);
result
}
fn flush_colon_run(
run: &mut Vec<&Spanned<Token>>,
result: &mut Vec<Spanned<Token>>,
suppress: bool,
source: &str,
) {
if run.is_empty() {
return;
}
let has_colon = run.iter().any(|t| matches!(t.token, Token::Colon));
if !suppress && run.len() >= 2 && has_colon {
let start = run.first().map(|t| t.span.start).unwrap_or(0);
let end = run.last().map(|t| t.span.end).unwrap_or(0);
let text = source.get(start..end).unwrap_or_default().to_string();
result.push(Spanned::new(Token::Ident(text), start..end));
} else {
for t in run.iter() {
result.push((*t).clone());
}
}
run.clear();
}
fn is_glob_mergeable(token: &Token) -> bool {
matches!(
token,
Token::Star
| Token::Question
| Token::Dot
| Token::DotDot
| Token::Ident(_)
| Token::NumberIdent(_)
| Token::DashNumWord(_)
| Token::AtWord(_)
| Token::DottedIdent(_)
| Token::Path(_)
| Token::Int(_)
| Token::LBracket
| Token::RBracket
| Token::Bang
| Token::DotSlashPath(_)
| Token::RelativePath(_)
| Token::TildePath(_)
| Token::Tilde
| Token::LBrace
| Token::RBrace
| Token::Comma
)
}
fn merge_flag_metachar_adjacent(tokens: Vec<Spanned<Token>>) -> Vec<Spanned<Token>> {
if tokens.len() < 2 {
return tokens;
}
let mut result = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
let token = &tokens[i];
if let Token::ShortFlag(flag_name) = &token.token {
let mut fused = flag_name.clone();
let mut end_span = token.span.end;
let mut j = i + 1;
while let Some(next) = tokens.get(j) {
if next.span.start == end_span {
if let Token::Colon = &next.token {
fused.push(':');
end_span = next.span.end;
j += 1;
continue;
}
}
break;
}
if j > i + 1 {
let span = token.span.start..end_span;
result.push(Spanned::new(Token::ShortFlag(fused), span));
i = j;
continue;
}
}
result.push(token.clone());
i += 1;
}
result
}
fn merge_glob_adjacent(tokens: Vec<Spanned<Token>>, source: &str) -> Vec<Spanned<Token>> {
if tokens.is_empty() {
return tokens;
}
let value_ctx = compute_value_context(&tokens);
let bracket_depth = compute_bracket_depth(&tokens);
let mut result = Vec::with_capacity(tokens.len());
let mut run: Vec<&Spanned<Token>> = Vec::new();
let mut run_start = 0usize;
for (idx, token) in tokens.iter().enumerate() {
if run.is_empty() {
if is_glob_mergeable(&token.token) {
run.push(token);
run_start = idx;
} else {
result.push(token.clone());
}
continue;
}
let Some(last) = run.last() else { unreachable!() };
let adjacent = last.span.end == token.span.start;
if adjacent && is_glob_mergeable(&token.token) {
run.push(token);
} else {
let followed_by_eq = matches!(token.token, Token::Eq);
flush_glob_run(
&mut run,
&mut result,
value_ctx[run_start].in_literal,
followed_by_eq,
value_ctx[run_start].push_target,
bracket_depth[run_start],
source,
);
if is_glob_mergeable(&token.token) {
run.push(token);
run_start = idx;
} else {
result.push(token.clone());
}
}
}
flush_glob_run(
&mut run,
&mut result,
value_ctx[run_start].in_literal,
false,
value_ctx[run_start].push_target,
bracket_depth[run_start],
source,
);
result
}
fn compute_bracket_depth(tokens: &[Spanned<Token>]) -> Vec<usize> {
let mut depths = Vec::with_capacity(tokens.len());
let mut depth: i32 = 0;
for t in tokens {
if is_statement_boundary(&t.token) {
depth = 0;
}
depths.push(depth.max(0) as usize);
match &t.token {
Token::LBracket | Token::LBrace => depth += 1,
Token::RBracket | Token::RBrace => depth = (depth - 1).max(0),
_ => {}
}
}
depths
}
fn run_has_bare_comma(run: &[&Spanned<Token>], start_depth: usize) -> bool {
let mut depth = start_depth as i32;
let mut found = false;
for t in run.iter() {
match &t.token {
Token::LBracket | Token::LBrace => depth += 1,
Token::RBracket | Token::RBrace => depth = (depth - 1).max(0),
Token::Comma if depth == 0 => found = true,
_ => {}
}
}
found
}
fn flush_glob_run(
run: &mut Vec<&Spanned<Token>>,
result: &mut Vec<Spanned<Token>>,
value_position_suppress: bool,
followed_by_eq: bool,
push_target: bool,
bracket_depth_at_start: usize,
source: &str,
) {
if run.is_empty() {
return;
}
let has_bracket_pair = run.iter().any(|t| matches!(t.token, Token::LBracket))
&& run.iter().any(|t| matches!(t.token, Token::RBracket));
let has_star_or_question = run
.iter()
.any(|t| matches!(t.token, Token::Star | Token::Question));
let has_glob = has_star_or_question || has_bracket_pair;
let run_starts_with_ident = matches!(run.first().map(|t| &t.token), Some(Token::Ident(_)));
let lvalue_suppress =
followed_by_eq && has_bracket_pair && !has_star_or_question && run_starts_with_ident;
let push_target_suppress =
push_target && has_bracket_pair && !has_star_or_question && run_starts_with_ident;
let suppress = (value_position_suppress && has_bracket_pair) || lvalue_suppress;
let has_bare_comma =
!value_position_suppress && run_has_bare_comma(run, bracket_depth_at_start);
if push_target_suppress && run.len() >= 2 {
let start = run.first().map(|t| t.span.start).unwrap_or(0);
let end = run.last().map(|t| t.span.end).unwrap_or(0);
let text = source.get(start..end).unwrap_or_default().to_string();
result.push(Spanned::new(Token::Ident(text), start..end));
} else if !suppress && run.len() >= 2 && has_glob {
let start = run.first().map(|t| t.span.start).unwrap_or(0);
let end = run.last().map(|t| t.span.end).unwrap_or(0);
let text = source.get(start..end).unwrap_or_default().to_string();
result.push(Spanned::new(Token::GlobWord(text), start..end));
} else if run.len() >= 2 && has_bare_comma {
let start = run.first().map(|t| t.span.start).unwrap_or(0);
let end = run.last().map(|t| t.span.end).unwrap_or(0);
let text = source.get(start..end).unwrap_or_default().to_string();
result.push(Spanned::new(Token::Ident(text), start..end));
} else {
for t in run.iter() {
result.push((*t).clone());
}
}
run.clear();
}
pub fn tokenize(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
tokenize_impl(source, false)
}
pub fn tokenize_with_comments(source: &str) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
tokenize_impl(source, true)
}
fn tokenize_impl(
source: &str,
keep_comments: bool,
) -> Result<Vec<Spanned<Token>>, Vec<Spanned<LexerError>>> {
let scan_output = scan(source).map_err(|e| vec![e])?;
debug_assert!(
scan_output
.replacements
.windows(2)
.all(|w| w[0].new_start <= w[1].new_start),
"replacement table must be ordered by new_start"
);
let mut tokens = Vec::new();
let mut errors = Vec::new();
for (result, span) in Token::lexer(&scan_output.text).spanned() {
const MAX_LEXER_ERRORS: usize = 64;
if errors.len() >= MAX_LEXER_ERRORS {
break;
}
match result {
Ok(token) => {
if !keep_comments
&& matches!(token, Token::Comment | Token::LineContinuation)
{
continue;
}
tokens.push(Spanned::new(token, span));
}
Err(err) => {
errors.push(Spanned::new(err, map_span(&span, &scan_output.replacements)));
}
}
}
if !errors.is_empty() {
return Err(errors);
}
let resolved = resolve_markers(tokens, &scan_output).map_err(|errs| {
errs.into_iter()
.map(|e| Spanned::new(e.token, map_span(&e.span, &scan_output.replacements)))
.collect::<Vec<_>>()
})?;
let mapped: Vec<Spanned<Token>> = resolved
.into_iter()
.map(|s| {
let span = map_span(&s.span, &scan_output.replacements);
Spanned::new(s.token, span)
})
.collect();
Ok(merge_glob_adjacent(
merge_colon_adjacent(merge_flag_metachar_adjacent(mapped), source),
source,
))
}
pub fn parse_string_literal(source: &str) -> Result<String, LexerError> {
if source.len() < 2 || !source.starts_with('"') || !source.ends_with('"') {
return Err(LexerError::UnterminatedString);
}
let inner = &source[1..source.len() - 1];
let mut result = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some('\\') => result.push('\\'),
Some('"') => result.push('"'),
Some('$') => result.push_str("__KAISH_ESCAPED_DOLLAR__"),
Some('u') => {
let mut hex = String::with_capacity(4);
for _ in 0..4 {
match chars.next() {
Some(h) if h.is_ascii_hexdigit() => hex.push(h),
_ => return Err(LexerError::InvalidEscape),
}
}
let codepoint = u32::from_str_radix(&hex, 16)
.map_err(|_| LexerError::InvalidEscape)?;
let ch = char::from_u32(codepoint)
.ok_or(LexerError::InvalidEscape)?;
result.push(ch);
}
Some(next) => {
result.push('\\');
result.push(next);
}
None => return Err(LexerError::InvalidEscape),
}
} else {
result.push(ch);
}
}
Ok(result)
}
pub fn parse_var_ref(source: &str) -> Result<Vec<String>, LexerError> {
if source.len() < 4 || !source.starts_with("${") || !source.ends_with('}') {
return Err(LexerError::UnterminatedVarRef);
}
let inner = &source[2..source.len() - 1];
if inner == "?" {
return Ok(vec!["?".to_string()]);
}
let mut segments = Vec::new();
let mut current = String::new();
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'.' => {
if !current.is_empty() {
segments.push(current.clone());
current.clear();
}
}
'[' => {
if !current.is_empty() {
segments.push(current.clone());
current.clear();
}
let mut index = String::from("[");
if let Some("e) = chars.peek() {
if quote == '"' || quote == '\'' {
if let Some(q) = chars.next() {
index.push(q);
}
for c in chars.by_ref() {
index.push(c);
if c == quote {
break;
}
}
}
}
while let Some(&c) = chars.peek() {
if let Some(c) = chars.next() {
index.push(c);
}
if c == ']' {
break;
}
}
segments.push(index);
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
segments.push(current);
}
Ok(segments)
}
pub fn parse_int(source: &str) -> Result<i64, LexerError> {
source.parse().map_err(|_| LexerError::InvalidNumber)
}
pub fn parse_float(source: &str) -> Result<f64, LexerError> {
source.parse().map_err(|_| LexerError::InvalidNumber)
}
#[cfg(test)]
#[allow(clippy::approx_constant)]
mod tests {
use super::*;
fn lex(source: &str) -> Vec<Token> {
tokenize(source)
.expect("lexer should succeed")
.into_iter()
.map(|s| s.token)
.collect()
}
#[test]
fn keywords() {
assert_eq!(lex("set"), vec![Token::Set]);
assert_eq!(lex("if"), vec![Token::If]);
assert_eq!(lex("then"), vec![Token::Then]);
assert_eq!(lex("else"), vec![Token::Else]);
assert_eq!(lex("elif"), vec![Token::Elif]);
assert_eq!(lex("fi"), vec![Token::Fi]);
assert_eq!(lex("for"), vec![Token::For]);
assert_eq!(lex("in"), vec![Token::In]);
assert_eq!(lex("do"), vec![Token::Do]);
assert_eq!(lex("done"), vec![Token::Done]);
assert_eq!(lex("case"), vec![Token::Case]);
assert_eq!(lex("esac"), vec![Token::Esac]);
assert_eq!(lex("function"), vec![Token::Function]);
assert_eq!(lex("true"), vec![Token::True]);
assert_eq!(lex("false"), vec![Token::False]);
}
#[test]
fn double_semicolon() {
assert_eq!(lex(";;"), vec![Token::DoubleSemi]);
assert_eq!(lex("echo \"hi\";;"), vec![
Token::Ident("echo".to_string()),
Token::String("hi".to_string()),
Token::DoubleSemi,
]);
}
#[test]
fn type_keywords() {
assert_eq!(lex("string"), vec![Token::TypeString]);
assert_eq!(lex("int"), vec![Token::TypeInt]);
assert_eq!(lex("float"), vec![Token::TypeFloat]);
assert_eq!(lex("bool"), vec![Token::TypeBool]);
}
#[test]
fn single_char_operators() {
assert_eq!(lex("="), vec![Token::Eq]);
assert_eq!(lex("|"), vec![Token::Pipe]);
assert_eq!(lex("&"), vec![Token::Amp]);
assert_eq!(lex(">"), vec![Token::Gt]);
assert_eq!(lex("<"), vec![Token::Lt]);
assert_eq!(lex(";"), vec![Token::Semi]);
assert_eq!(lex(":"), vec![Token::Colon]);
assert_eq!(lex(","), vec![Token::Comma]);
assert_eq!(lex("."), vec![Token::Dot]);
}
#[test]
fn multi_char_operators() {
assert_eq!(lex("&&"), vec![Token::And]);
assert_eq!(lex("||"), vec![Token::Or]);
assert_eq!(lex("=="), vec![Token::EqEq]);
assert_eq!(lex("!="), vec![Token::NotEq]);
assert_eq!(lex("=~"), vec![Token::Match]);
assert_eq!(lex("!~"), vec![Token::NotMatch]);
assert_eq!(lex(">="), vec![Token::GtEq]);
assert_eq!(lex("<="), vec![Token::LtEq]);
assert_eq!(lex(">>"), vec![Token::GtGt]);
assert_eq!(lex("2>"), vec![Token::Stderr]);
assert_eq!(lex("&>"), vec![Token::Both]);
}
#[test]
fn brackets() {
assert_eq!(lex("{"), vec![Token::LBrace]);
assert_eq!(lex("}"), vec![Token::RBrace]);
assert_eq!(lex("["), vec![Token::LBracket]);
assert_eq!(lex("]"), vec![Token::RBracket]);
assert_eq!(lex("("), vec![Token::LParen]);
assert_eq!(lex(")"), vec![Token::RParen]);
}
#[test]
fn integers() {
assert_eq!(lex("0"), vec![Token::Int(0)]);
assert_eq!(lex("42"), vec![Token::Int(42)]);
assert_eq!(lex("-1"), vec![Token::Int(-1)]);
assert_eq!(lex("999999"), vec![Token::Int(999999)]);
}
#[test]
fn floats() {
assert_eq!(lex("3.14"), vec![Token::Float(3.14)]);
assert_eq!(lex("-0.5"), vec![Token::Float(-0.5)]);
assert_eq!(lex("123.456"), vec![Token::Float(123.456)]);
}
#[test]
fn strings() {
assert_eq!(lex(r#""hello""#), vec![Token::String("hello".to_string())]);
assert_eq!(lex(r#""hello world""#), vec![Token::String("hello world".to_string())]);
assert_eq!(lex(r#""""#), vec![Token::String("".to_string())]); assert_eq!(lex(r#""with \"quotes\"""#), vec![Token::String("with \"quotes\"".to_string())]);
assert_eq!(lex(r#""with\nnewline""#), vec![Token::String("with\nnewline".to_string())]);
}
#[test]
fn var_refs() {
assert_eq!(lex("${X}"), vec![Token::VarRef("${X}".to_string())]);
assert_eq!(lex("${VAR}"), vec![Token::VarRef("${VAR}".to_string())]);
assert_eq!(lex("${VAR.field}"), vec![Token::VarRef("${VAR.field}".to_string())]);
assert_eq!(lex("${VAR[0]}"), vec![Token::VarRef("${VAR[0]}".to_string())]);
}
#[test]
fn var_ref_nested_default_is_one_token() {
assert_eq!(
lex("${X:-${Y}}"),
vec![Token::VarRef("${X:-${Y}}".to_string())]
);
assert_eq!(
lex("${A:-${B:-${C}}}"),
vec![Token::VarRef("${A:-${B:-${C}}}".to_string())]
);
assert_eq!(lex("${#X}"), vec![Token::VarLength("X".to_string())]);
}
#[test]
fn var_ref_unterminated_and_empty_are_errors() {
assert!(tokenize("${X:-${Y}").is_err(), "unbalanced nesting is loud");
assert!(tokenize("${a{b}").is_err(), "extra open brace is loud");
assert!(tokenize("${}").is_err(), "empty reference is loud");
}
#[test]
fn var_ref_closes_at_first_balanced_brace() {
assert_eq!(
lex("${a}b}"),
vec![
Token::VarRef("${a}".to_string()),
Token::Ident("b".to_string()),
Token::RBrace,
]
);
}
#[test]
fn identifiers() {
assert_eq!(lex("foo"), vec![Token::Ident("foo".to_string())]);
assert_eq!(lex("foo_bar"), vec![Token::Ident("foo_bar".to_string())]);
assert_eq!(lex("foo-bar"), vec![Token::Ident("foo-bar".to_string())]);
assert_eq!(lex("_private"), vec![Token::Ident("_private".to_string())]);
assert_eq!(lex("cmd123"), vec![Token::Ident("cmd123".to_string())]);
}
#[test]
fn keyword_prefix_identifiers() {
assert_eq!(lex("setup"), vec![Token::Ident("setup".to_string())]);
assert_eq!(lex("kaish-tools"), vec![Token::Ident("kaish-tools".to_string())]);
assert_eq!(lex("iffy"), vec![Token::Ident("iffy".to_string())]);
assert_eq!(lex("forked"), vec![Token::Ident("forked".to_string())]);
assert_eq!(lex("done-with-it"), vec![Token::Ident("done-with-it".to_string())]);
}
#[test]
fn assignment() {
assert_eq!(
lex("set X = 5"),
vec![Token::Set, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
);
}
#[test]
fn command_simple() {
assert_eq!(lex("echo"), vec![Token::Ident("echo".to_string())]);
assert_eq!(
lex(r#"echo "hello""#),
vec![Token::Ident("echo".to_string()), Token::String("hello".to_string())]
);
}
#[test]
fn command_with_args() {
assert_eq!(
lex("cmd arg1 arg2"),
vec![Token::Ident("cmd".to_string()), Token::Ident("arg1".to_string()), Token::Ident("arg2".to_string())]
);
}
#[test]
fn command_with_named_args() {
assert_eq!(
lex("cmd key=value"),
vec![Token::Ident("cmd".to_string()), Token::Ident("key".to_string()), Token::Eq, Token::Ident("value".to_string())]
);
}
#[test]
fn pipeline() {
assert_eq!(
lex("a | b | c"),
vec![Token::Ident("a".to_string()), Token::Pipe, Token::Ident("b".to_string()), Token::Pipe, Token::Ident("c".to_string())]
);
}
#[test]
fn if_statement() {
assert_eq!(
lex("if true; then echo; fi"),
vec![
Token::If,
Token::True,
Token::Semi,
Token::Then,
Token::Ident("echo".to_string()),
Token::Semi,
Token::Fi
]
);
}
#[test]
fn for_loop() {
assert_eq!(
lex("for X in items; do echo; done"),
vec![
Token::For,
Token::Ident("X".to_string()),
Token::In,
Token::Ident("items".to_string()),
Token::Semi,
Token::Do,
Token::Ident("echo".to_string()),
Token::Semi,
Token::Done
]
);
}
#[test]
fn whitespace_ignored() {
assert_eq!(lex(" set X = 5 "), lex("set X = 5"));
}
#[test]
fn newlines_preserved() {
let tokens = lex("a\nb");
assert_eq!(
tokens,
vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
);
}
#[test]
fn multiple_newlines() {
let tokens = lex("a\n\n\nb");
assert_eq!(
tokens,
vec![Token::Ident("a".to_string()), Token::Newline, Token::Newline, Token::Newline, Token::Ident("b".to_string())]
);
}
#[test]
fn comments_skipped() {
assert_eq!(lex("# comment"), vec![]);
assert_eq!(lex("a # comment"), vec![Token::Ident("a".to_string())]);
assert_eq!(
lex("a # comment\nb"),
vec![Token::Ident("a".to_string()), Token::Newline, Token::Ident("b".to_string())]
);
}
#[test]
fn comments_preserved_when_requested() {
let tokens = tokenize_with_comments("a # comment")
.expect("should succeed")
.into_iter()
.map(|s| s.token)
.collect::<Vec<_>>();
assert_eq!(tokens, vec![Token::Ident("a".to_string()), Token::Comment]);
}
#[test]
fn parse_simple_string() {
assert_eq!(parse_string_literal(r#""hello""#).expect("ok"), "hello");
}
#[test]
fn parse_string_with_escapes() {
assert_eq!(
parse_string_literal(r#""hello\nworld""#).expect("ok"),
"hello\nworld"
);
assert_eq!(
parse_string_literal(r#""tab\there""#).expect("ok"),
"tab\there"
);
assert_eq!(
parse_string_literal(r#""quote\"here""#).expect("ok"),
"quote\"here"
);
}
#[test]
fn parse_string_with_unicode() {
assert_eq!(
parse_string_literal(r#""emoji \u2764""#).expect("ok"),
"emoji ❤"
);
}
#[test]
fn parse_string_with_escaped_dollar() {
assert_eq!(
parse_string_literal(r#""\$VAR""#).expect("ok"),
"__KAISH_ESCAPED_DOLLAR__VAR"
);
assert_eq!(
parse_string_literal(r#""cost: \$100""#).expect("ok"),
"cost: __KAISH_ESCAPED_DOLLAR__100"
);
}
#[test]
fn parse_simple_var() {
assert_eq!(
parse_var_ref("${X}").expect("ok"),
vec!["X"]
);
}
#[test]
fn parse_var_with_field() {
assert_eq!(
parse_var_ref("${VAR.field}").expect("ok"),
vec!["VAR", "field"]
);
}
#[test]
fn parse_var_with_index() {
assert_eq!(
parse_var_ref("${VAR[0]}").expect("ok"),
vec!["VAR", "[0]"]
);
}
#[test]
fn parse_var_nested() {
assert_eq!(
parse_var_ref("${VAR.field[0].nested}").expect("ok"),
vec!["VAR", "field", "[0]", "nested"]
);
}
#[test]
fn parse_last_result() {
assert_eq!(
parse_var_ref("${?}").expect("ok"),
vec!["?"]
);
}
#[test]
fn parse_var_quoted_subscript_with_embedded_bracket() {
assert_eq!(
parse_var_ref(r#"${r["weird]key"]}"#).expect("ok"),
vec!["r", r#"["weird]key"]"#]
);
assert_eq!(
parse_var_ref("${r['weird]key']}").expect("ok"),
vec!["r", "['weird]key']"]
);
}
#[test]
fn parse_var_quoted_subscript_without_embedded_bracket() {
assert_eq!(
parse_var_ref(r#"${r["normal"]}"#).expect("ok"),
vec!["r", r#"["normal"]"#]
);
}
#[test]
fn parse_var_quoted_subscript_with_embedded_bracket_then_more_path() {
assert_eq!(
parse_var_ref(r#"${r["weird]key"][0]}"#).expect("ok"),
vec!["r", r#"["weird]key"]"#, "[0]"]
);
}
#[test]
fn parse_integers() {
assert_eq!(parse_int("0").expect("ok"), 0);
assert_eq!(parse_int("42").expect("ok"), 42);
assert_eq!(parse_int("-1").expect("ok"), -1);
}
#[test]
fn parse_floats() {
assert!((parse_float("3.14").expect("ok") - 3.14).abs() < f64::EPSILON);
assert!((parse_float("-0.5").expect("ok") - (-0.5)).abs() < f64::EPSILON);
}
#[test]
fn empty_input() {
assert_eq!(lex(""), vec![]);
}
#[test]
fn only_whitespace() {
assert_eq!(lex(" \t\t "), vec![]);
}
#[test]
fn json_array() {
assert_eq!(
lex(r#"[1, 2, 3]"#),
vec![
Token::LBracket,
Token::Int(1),
Token::Comma,
Token::Int(2),
Token::Comma,
Token::Int(3),
Token::RBracket
]
);
}
#[test]
fn json_object() {
assert_eq!(
lex(r#"{"key": "value"}"#),
vec![
Token::LBrace,
Token::String("key".to_string()),
Token::Colon,
Token::String("value".to_string()),
Token::RBrace
]
);
}
#[test]
fn redirect_operators() {
assert_eq!(
lex("cmd > file"),
vec![Token::Ident("cmd".to_string()), Token::Gt, Token::Ident("file".to_string())]
);
assert_eq!(
lex("cmd >> file"),
vec![Token::Ident("cmd".to_string()), Token::GtGt, Token::Ident("file".to_string())]
);
assert_eq!(
lex("cmd 2> err"),
vec![Token::Ident("cmd".to_string()), Token::Stderr, Token::Ident("err".to_string())]
);
assert_eq!(
lex("cmd &> all"),
vec![Token::Ident("cmd".to_string()), Token::Both, Token::Ident("all".to_string())]
);
}
#[test]
fn background_job() {
assert_eq!(
lex("cmd &"),
vec![Token::Ident("cmd".to_string()), Token::Amp]
);
}
#[test]
fn command_substitution() {
assert_eq!(
lex("$(cmd)"),
vec![Token::CmdSubstStart, Token::Ident("cmd".to_string()), Token::RParen]
);
assert_eq!(
lex("$(cmd arg)"),
vec![
Token::CmdSubstStart,
Token::Ident("cmd".to_string()),
Token::Ident("arg".to_string()),
Token::RParen
]
);
assert_eq!(
lex("$(a | b)"),
vec![
Token::CmdSubstStart,
Token::Ident("a".to_string()),
Token::Pipe,
Token::Ident("b".to_string()),
Token::RParen
]
);
}
#[test]
fn complex_pipeline() {
assert_eq!(
lex(r#"cat file | grep pattern="foo" | head count=10"#),
vec![
Token::Ident("cat".to_string()),
Token::Ident("file".to_string()),
Token::Pipe,
Token::Ident("grep".to_string()),
Token::Ident("pattern".to_string()),
Token::Eq,
Token::String("foo".to_string()),
Token::Pipe,
Token::Ident("head".to_string()),
Token::Ident("count".to_string()),
Token::Eq,
Token::Int(10),
]
);
}
#[test]
fn short_flag() {
assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
assert_eq!(lex("-a"), vec![Token::ShortFlag("a".to_string())]);
assert_eq!(lex("-v"), vec![Token::ShortFlag("v".to_string())]);
}
#[test]
fn short_flag_combined() {
assert_eq!(lex("-la"), vec![Token::ShortFlag("la".to_string())]);
assert_eq!(lex("-vvv"), vec![Token::ShortFlag("vvv".to_string())]);
}
#[test]
fn job_spec_lexes_as_one_token() {
assert_eq!(lex("%1"), vec![Token::JobSpec("%1".to_string())]);
assert_eq!(lex("%12"), vec![Token::JobSpec("%12".to_string())]);
assert_eq!(
lex("wait %1 %2"),
vec![
Token::Ident("wait".to_string()),
Token::JobSpec("%1".to_string()),
Token::JobSpec("%2".to_string()),
]
);
}
#[test]
fn short_flag_with_internal_hyphens_is_one_token() {
assert_eq!(
lex("-not-a-flag"),
vec![Token::ShortFlag("not-a-flag".to_string())]
);
assert_eq!(lex("--"), vec![Token::DoubleDash]);
assert_eq!(lex("-"), vec![Token::MinusAlone]);
}
#[test]
fn long_flag() {
assert_eq!(lex("--force"), vec![Token::LongFlag("force".to_string())]);
assert_eq!(lex("--verbose"), vec![Token::LongFlag("verbose".to_string())]);
assert_eq!(lex("--foo-bar"), vec![Token::LongFlag("foo-bar".to_string())]);
}
#[test]
fn double_dash() {
assert_eq!(lex("--"), vec![Token::DoubleDash]);
}
#[test]
fn flags_vs_negative_numbers() {
assert_eq!(lex("-123"), vec![Token::Int(-123)]);
assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
assert_eq!(
lex("-1 a"),
vec![Token::Int(-1), Token::Ident("a".to_string())]
);
}
#[test]
fn command_with_flags() {
assert_eq!(
lex("ls -l"),
vec![
Token::Ident("ls".to_string()),
Token::ShortFlag("l".to_string()),
]
);
assert_eq!(
lex("git commit -m"),
vec![
Token::Ident("git".to_string()),
Token::Ident("commit".to_string()),
Token::ShortFlag("m".to_string()),
]
);
assert_eq!(
lex("git push --force"),
vec![
Token::Ident("git".to_string()),
Token::Ident("push".to_string()),
Token::LongFlag("force".to_string()),
]
);
}
#[test]
fn flag_with_value() {
assert_eq!(
lex(r#"git commit -m "message""#),
vec![
Token::Ident("git".to_string()),
Token::Ident("commit".to_string()),
Token::ShortFlag("m".to_string()),
Token::String("message".to_string()),
]
);
assert_eq!(
lex(r#"--message="hello""#),
vec![
Token::LongFlag("message".to_string()),
Token::Eq,
Token::String("hello".to_string()),
]
);
}
#[test]
fn end_of_flags_marker() {
assert_eq!(
lex("git checkout -- file"),
vec![
Token::Ident("git".to_string()),
Token::Ident("checkout".to_string()),
Token::DoubleDash,
Token::Ident("file".to_string()),
]
);
}
#[test]
fn local_keyword() {
assert_eq!(lex("local"), vec![Token::Local]);
assert_eq!(
lex("local X = 5"),
vec![Token::Local, Token::Ident("X".to_string()), Token::Eq, Token::Int(5)]
);
}
#[test]
fn simple_var_ref() {
assert_eq!(lex("$X"), vec![Token::SimpleVarRef("X".to_string())]);
assert_eq!(lex("$foo"), vec![Token::SimpleVarRef("foo".to_string())]);
assert_eq!(lex("$foo_bar"), vec![Token::SimpleVarRef("foo_bar".to_string())]);
assert_eq!(lex("$_private"), vec![Token::SimpleVarRef("_private".to_string())]);
}
#[test]
fn simple_var_ref_in_command() {
assert_eq!(
lex("echo $NAME"),
vec![Token::Ident("echo".to_string()), Token::SimpleVarRef("NAME".to_string())]
);
}
#[test]
fn single_quoted_strings() {
assert_eq!(lex("'hello'"), vec![Token::SingleString("hello".to_string())]);
assert_eq!(lex("'hello world'"), vec![Token::SingleString("hello world".to_string())]);
assert_eq!(lex("''"), vec![Token::SingleString("".to_string())]);
assert_eq!(lex(r"'no $VAR here'"), vec![Token::SingleString("no $VAR here".to_string())]);
assert_eq!(lex(r"'backslash \n stays'"), vec![Token::SingleString(r"backslash \n stays".to_string())]);
}
#[test]
fn test_brackets() {
assert_eq!(lex("[["), vec![Token::LBracket, Token::LBracket]);
assert_eq!(lex("]]"), vec![Token::RBracket, Token::RBracket]);
assert_eq!(
lex("[[ -f file ]]"),
vec![
Token::LBracket,
Token::LBracket,
Token::ShortFlag("f".to_string()),
Token::Ident("file".to_string()),
Token::RBracket,
Token::RBracket
]
);
}
#[test]
fn test_expression_syntax() {
assert_eq!(
lex(r#"[[ $X == "value" ]]"#),
vec![
Token::LBracket,
Token::LBracket,
Token::SimpleVarRef("X".to_string()),
Token::EqEq,
Token::String("value".to_string()),
Token::RBracket,
Token::RBracket
]
);
}
#[test]
fn bash_style_assignment() {
assert_eq!(
lex(r#"NAME="value""#),
vec![
Token::Ident("NAME".to_string()),
Token::Eq,
Token::String("value".to_string())
]
);
}
#[test]
fn positional_params() {
assert_eq!(lex("$0"), vec![Token::Positional(0)]);
assert_eq!(lex("$1"), vec![Token::Positional(1)]);
assert_eq!(lex("$9"), vec![Token::Positional(9)]);
assert_eq!(lex("$@"), vec![Token::AllArgs]);
assert_eq!(lex("$#"), vec![Token::ArgCount]);
}
#[test]
fn positional_in_context() {
assert_eq!(
lex("echo $1 $2"),
vec![
Token::Ident("echo".to_string()),
Token::Positional(1),
Token::Positional(2),
]
);
}
#[test]
fn var_length() {
assert_eq!(lex("${#X}"), vec![Token::VarLength("X".to_string())]);
assert_eq!(lex("${#NAME}"), vec![Token::VarLength("NAME".to_string())]);
assert_eq!(lex("${#foo_bar}"), vec![Token::VarLength("foo_bar".to_string())]);
}
#[test]
fn var_length_with_subscript() {
assert_eq!(lex("${#u[tags]}"), vec![Token::VarLength("u[tags]".to_string())]);
assert_eq!(lex("${#a[0]}"), vec![Token::VarLength("a[0]".to_string())]);
assert_eq!(lex("${#a[b][c]}"), vec![Token::VarLength("a[b][c]".to_string())]);
assert_eq!(lex("${#r[$k]}"), vec![Token::VarLength("r[$k]".to_string())]);
}
#[test]
fn var_length_in_context() {
assert_eq!(
lex("echo ${#NAME}"),
vec![
Token::Ident("echo".to_string()),
Token::VarLength("NAME".to_string()),
]
);
}
#[test]
fn plus_flag() {
assert_eq!(lex("+e"), vec![Token::PlusFlag("e".to_string())]);
assert_eq!(lex("+x"), vec![Token::PlusFlag("x".to_string())]);
assert_eq!(lex("+ex"), vec![Token::PlusFlag("ex".to_string())]);
}
#[test]
fn set_with_plus_flag() {
assert_eq!(
lex("set +e"),
vec![
Token::Set,
Token::PlusFlag("e".to_string()),
]
);
}
#[test]
fn set_with_multiple_flags() {
assert_eq!(
lex("set -e -u"),
vec![
Token::Set,
Token::ShortFlag("e".to_string()),
Token::ShortFlag("u".to_string()),
]
);
}
#[test]
fn flags_vs_negative_numbers_edge_cases() {
assert_eq!(
lex("-1 a"),
vec![Token::Int(-1), Token::Ident("a".to_string())]
);
assert_eq!(lex("-l"), vec![Token::ShortFlag("l".to_string())]);
assert_eq!(lex("-123"), vec![Token::Int(-123)]);
}
#[test]
fn single_dash_is_minus_alone() {
let result = tokenize("-").expect("should lex");
assert_eq!(result.len(), 1);
assert!(matches!(result[0].token, Token::MinusAlone));
}
#[test]
fn plus_bare_for_date_format() {
let result = tokenize("+%s").expect("should lex");
assert_eq!(result.len(), 1);
assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%s"));
let result = tokenize("+%Y-%m-%d").expect("should lex");
assert_eq!(result.len(), 1);
assert!(matches!(result[0].token, Token::PlusBare(ref s) if s == "+%Y-%m-%d"));
}
#[test]
fn plus_flag_still_works() {
let result = tokenize("+e").expect("should lex");
assert_eq!(result.len(), 1);
assert!(matches!(result[0].token, Token::PlusFlag(ref s) if s == "e"));
}
#[test]
fn while_keyword_vs_while_loop() {
assert_eq!(lex("while"), vec![Token::While]);
assert_eq!(
lex("while true"),
vec![Token::While, Token::True]
);
}
#[test]
fn control_flow_keywords() {
assert_eq!(lex("break"), vec![Token::Break]);
assert_eq!(lex("continue"), vec![Token::Continue]);
assert_eq!(lex("return"), vec![Token::Return]);
assert_eq!(lex("exit"), vec![Token::Exit]);
}
#[test]
fn control_flow_with_numbers() {
assert_eq!(
lex("break 2"),
vec![Token::Break, Token::Int(2)]
);
assert_eq!(
lex("continue 3"),
vec![Token::Continue, Token::Int(3)]
);
assert_eq!(
lex("exit 1"),
vec![Token::Exit, Token::Int(1)]
);
}
#[test]
fn heredoc_simple() {
let source = "cat <<EOF\nhello\nworld\nEOF";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("cat".to_string()),
Token::HereDocStart,
Token::HereDoc(HereDocData {
content: "hello\nworld\n".to_string(),
source_body: "hello\nworld\n".to_string(),
delimiter: "EOF".to_string(),
literal: false,
strip_tabs: false,
body_start_offset: 10,
}),
Token::Newline,
]);
}
#[test]
fn heredoc_empty() {
let source = "cat <<EOF\nEOF";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("cat".to_string()),
Token::HereDocStart,
Token::HereDoc(HereDocData {
content: "".to_string(),
source_body: "".to_string(),
delimiter: "EOF".to_string(),
literal: false,
strip_tabs: false,
body_start_offset: 10,
}),
Token::Newline,
]);
}
#[test]
fn heredoc_with_special_chars() {
let source = "cat <<EOF\n$VAR and \"quoted\" 'single'\nEOF";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("cat".to_string()),
Token::HereDocStart,
Token::HereDoc(HereDocData {
content: "$VAR and \"quoted\" 'single'\n".to_string(),
source_body: "$VAR and \"quoted\" 'single'\n".to_string(),
delimiter: "EOF".to_string(),
literal: false,
strip_tabs: false,
body_start_offset: 10,
}),
Token::Newline,
]);
}
#[test]
fn heredoc_multiline() {
let source = "cat <<END\nline1\nline2\nline3\nEND";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("cat".to_string()),
Token::HereDocStart,
Token::HereDoc(HereDocData {
content: "line1\nline2\nline3\n".to_string(),
source_body: "line1\nline2\nline3\n".to_string(),
delimiter: "END".to_string(),
literal: false,
strip_tabs: false,
body_start_offset: 10,
}),
Token::Newline,
]);
}
#[test]
fn heredoc_in_command() {
let source = "cat <<EOF\nhello\nEOF\necho goodbye";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("cat".to_string()),
Token::HereDocStart,
Token::HereDoc(HereDocData {
content: "hello\n".to_string(),
source_body: "hello\n".to_string(),
delimiter: "EOF".to_string(),
literal: false,
strip_tabs: false,
body_start_offset: 10,
}),
Token::Newline,
Token::Ident("echo".to_string()),
Token::Ident("goodbye".to_string()),
]);
}
#[test]
fn heredoc_strip_tabs() {
let source = "cat <<-EOF\n\thello\n\tworld\n\tEOF";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("cat".to_string()),
Token::HereDocStart,
Token::HereDoc(HereDocData {
content: "\thello\n\tworld\n".to_string(),
source_body: "\thello\n\tworld\n".to_string(),
delimiter: "EOF".to_string(),
literal: false,
strip_tabs: true,
body_start_offset: 11,
}),
Token::Newline,
]);
}
#[test]
fn arithmetic_simple() {
let source = "$((1 + 2))";
let tokens = lex(source);
assert_eq!(tokens, vec![Token::Arithmetic("1 + 2".to_string())]);
}
#[test]
fn arithmetic_in_assignment() {
let source = "X=$((5 * 3))";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("X".to_string()),
Token::Eq,
Token::Arithmetic("5 * 3".to_string()),
]);
}
#[test]
fn arithmetic_with_nested_parens() {
let source = "$((2 * (3 + 4)))";
let tokens = lex(source);
assert_eq!(tokens, vec![Token::Arithmetic("2 * (3 + 4)".to_string())]);
}
#[test]
fn arithmetic_with_variable() {
let source = "$((X + 1))";
let tokens = lex(source);
assert_eq!(tokens, vec![Token::Arithmetic("X + 1".to_string())]);
}
#[test]
fn arithmetic_command_subst_not_confused() {
let source = "$(echo hello)";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::CmdSubstStart,
Token::Ident("echo".to_string()),
Token::Ident("hello".to_string()),
Token::RParen,
]);
}
#[test]
fn arithmetic_nesting_limit() {
let open_parens = "(".repeat(300);
let close_parens = ")".repeat(300);
let source = format!("$(({}1{}))", open_parens, close_parens);
let result = tokenize(&source);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].token, LexerError::NestingTooDeep);
}
#[test]
fn arithmetic_nesting_within_limit() {
let source = "$((((1 + 2) * 3)))";
let tokens = lex(source);
assert_eq!(tokens, vec![Token::Arithmetic("((1 + 2) * 3)".to_string())]);
}
#[test]
fn arithmetic_after_apostrophe_in_comment() {
let source = "# this doesn't work\necho $((1+2))";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Newline,
Token::Ident("echo".to_string()),
Token::Arithmetic("1+2".to_string()),
]);
}
#[test]
fn arithmetic_inside_comment_is_not_expanded() {
let source = "# the $((y)) syntax explained\necho hello";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Newline,
Token::Ident("echo".to_string()),
Token::Ident("hello".to_string()),
]);
}
#[test]
fn backticked_arithmetic_in_comment_is_not_expanded() {
let source = "# the `$((x))` syntax explained\necho $((3+4))";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Newline,
Token::Ident("echo".to_string()),
Token::Arithmetic("3+4".to_string()),
]);
}
#[test]
fn arithmetic_still_works_outside_comments() {
let source = "X=$((1+2)); Y=$((3*4))";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("X".to_string()),
Token::Eq,
Token::Arithmetic("1+2".to_string()),
Token::Semi,
Token::Ident("Y".to_string()),
Token::Eq,
Token::Arithmetic("3*4".to_string()),
]);
}
#[test]
fn arithmetic_inside_double_quotes_still_expands() {
let source = "echo \"# $((1+2))\"";
let tokens = lex(source);
assert_eq!(tokens.len(), 2);
assert!(matches!(tokens[0], Token::Ident(_)));
assert!(matches!(tokens[1], Token::String(_)));
}
#[test]
fn backtick_in_source_is_rejected() {
let result = tokenize("echo `date`");
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.token == LexerError::BackticksNotSupported));
}
#[test]
fn backtick_in_comment_is_just_comment_text() {
let source = "# use `date` here\necho hi";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Newline,
Token::Ident("echo".to_string()),
Token::Ident("hi".to_string()),
]);
}
#[test]
fn backtick_in_single_quoted_string_is_literal() {
let source = "echo '`date`'";
let tokens = lex(source);
assert_eq!(tokens, vec![
Token::Ident("echo".to_string()),
Token::SingleString("`date`".to_string()),
]);
}
#[test]
fn backtick_in_double_quoted_string_is_literal() {
let source = "echo \"`date`\"";
let tokens = lex(source);
assert_eq!(tokens.len(), 2);
assert!(matches!(tokens[0], Token::Ident(_)));
match &tokens[1] {
Token::String(s) => assert!(s.contains('`')),
other => panic!("expected Token::String, got {:?}", other),
}
}
#[test]
fn backtick_in_heredoc_body_is_preserved() {
let source = "cat <<EOF\n`date`\nEOF\n";
let tokens = lex(source);
let heredoc = tokens.iter().find(|t| matches!(t, Token::HereDoc(_)));
assert!(heredoc.is_some(), "expected a HereDoc token");
if let Some(Token::HereDoc(d)) = heredoc {
assert!(d.content.contains('`'));
}
}
#[test]
fn token_categories() {
assert_eq!(Token::If.category(), TokenCategory::Keyword);
assert_eq!(Token::Then.category(), TokenCategory::Keyword);
assert_eq!(Token::For.category(), TokenCategory::Keyword);
assert_eq!(Token::Function.category(), TokenCategory::Keyword);
assert_eq!(Token::True.category(), TokenCategory::Keyword);
assert_eq!(Token::TypeString.category(), TokenCategory::Keyword);
assert_eq!(Token::Pipe.category(), TokenCategory::Operator);
assert_eq!(Token::And.category(), TokenCategory::Operator);
assert_eq!(Token::Or.category(), TokenCategory::Operator);
assert_eq!(Token::StderrToStdout.category(), TokenCategory::Operator);
assert_eq!(Token::GtGt.category(), TokenCategory::Operator);
assert_eq!(Token::String("test".to_string()).category(), TokenCategory::String);
assert_eq!(Token::SingleString("test".to_string()).category(), TokenCategory::String);
assert_eq!(
Token::HereDoc(HereDocData {
content: "test".to_string(),
source_body: "test".to_string(),
delimiter: "EOF".to_string(),
literal: false,
strip_tabs: false,
body_start_offset: 0,
}).category(),
TokenCategory::String,
);
assert_eq!(Token::Int(42).category(), TokenCategory::Number);
assert_eq!(Token::Float(3.14).category(), TokenCategory::Number);
assert_eq!(Token::Arithmetic("1+2".to_string()).category(), TokenCategory::Number);
assert_eq!(Token::SimpleVarRef("X".to_string()).category(), TokenCategory::Variable);
assert_eq!(Token::VarRef("${X}".to_string()).category(), TokenCategory::Variable);
assert_eq!(Token::Positional(1).category(), TokenCategory::Variable);
assert_eq!(Token::AllArgs.category(), TokenCategory::Variable);
assert_eq!(Token::ArgCount.category(), TokenCategory::Variable);
assert_eq!(Token::LastExitCode.category(), TokenCategory::Variable);
assert_eq!(Token::CurrentPid.category(), TokenCategory::Variable);
assert_eq!(Token::ShortFlag("l".to_string()).category(), TokenCategory::Flag);
assert_eq!(Token::LongFlag("verbose".to_string()).category(), TokenCategory::Flag);
assert_eq!(Token::PlusFlag("e".to_string()).category(), TokenCategory::Flag);
assert_eq!(Token::DoubleDash.category(), TokenCategory::Flag);
assert_eq!(Token::Semi.category(), TokenCategory::Punctuation);
assert_eq!(Token::LParen.category(), TokenCategory::Punctuation);
assert_eq!(Token::LBracket.category(), TokenCategory::Punctuation);
assert_eq!(Token::Newline.category(), TokenCategory::Punctuation);
assert_eq!(Token::Comment.category(), TokenCategory::Comment);
assert_eq!(Token::Path("/tmp/file".to_string()).category(), TokenCategory::Path);
assert_eq!(Token::Ident("echo".to_string()).category(), TokenCategory::Command);
assert_eq!(Token::NumberIdent("019dda1c".to_string()).category(), TokenCategory::Command);
assert_eq!(Token::DottedIdent(".gitignore".to_string()).category(), TokenCategory::Command);
assert_eq!(Token::InvalidFloatNoLeading.category(), TokenCategory::Error);
assert_eq!(Token::InvalidFloatNoTrailing.category(), TokenCategory::Error);
}
#[test]
fn test_heredoc_piped_to_command() {
let tokens = tokenize("cat <<EOF | jq\n{\"key\": \"val\"}\nEOF").unwrap();
let heredoc_pos = tokens.iter().position(|t| matches!(t.token, Token::HereDoc(_)));
let pipe_pos = tokens.iter().position(|t| matches!(t.token, Token::Pipe));
assert!(heredoc_pos.is_some(), "should have a heredoc token");
assert!(pipe_pos.is_some(), "should have a pipe token");
assert!(
pipe_pos.unwrap() > heredoc_pos.unwrap(),
"Pipe must come after heredoc, got heredoc at {}, pipe at {}. Tokens: {:?}",
heredoc_pos.unwrap(), pipe_pos.unwrap(), tokens,
);
}
#[test]
fn test_heredoc_standalone_still_works() {
let tokens = tokenize("cat <<EOF\nhello\nEOF").unwrap();
assert!(tokens.iter().any(|t| matches!(t.token, Token::HereDoc(_))));
assert!(!tokens.iter().any(|t| matches!(t.token, Token::Pipe)));
}
#[test]
fn test_heredoc_preserves_leading_empty_lines() {
let tokens = tokenize("cat <<EOF\n\nhello\nEOF").unwrap();
let heredoc = tokens.iter().find_map(|t| {
if let Token::HereDoc(data) = &t.token {
Some(data.clone())
} else {
None
}
});
assert!(heredoc.is_some(), "should have a heredoc token");
let data = heredoc.unwrap();
assert!(data.content.starts_with('\n'), "leading empty line must be preserved, got: {:?}", data.content);
assert_eq!(data.content, "\nhello\n");
}
#[test]
fn test_heredoc_quoted_delimiter_sets_literal() {
let tokens = tokenize("cat <<'EOF'\nhello $HOME\nEOF").unwrap();
let heredoc = tokens.iter().find_map(|t| {
if let Token::HereDoc(data) = &t.token {
Some(data.clone())
} else {
None
}
});
assert!(heredoc.is_some(), "should have a heredoc token");
let data = heredoc.unwrap();
assert!(data.literal, "quoted delimiter should set literal=true");
assert_eq!(data.content, "hello $HOME\n");
}
#[test]
fn test_heredoc_unquoted_delimiter_not_literal() {
let tokens = tokenize("cat <<EOF\nhello $HOME\nEOF").unwrap();
let heredoc = tokens.iter().find_map(|t| {
if let Token::HereDoc(data) = &t.token {
Some(data.clone())
} else {
None
}
});
assert!(heredoc.is_some(), "should have a heredoc token");
let data = heredoc.unwrap();
assert!(!data.literal, "unquoted delimiter should have literal=false");
}
#[test]
fn colon_double_in_word() {
assert_eq!(lex("foo::bar"), vec![Token::Ident("foo::bar".into())]);
}
#[test]
fn colon_single_in_word() {
assert_eq!(lex("a:b:c"), vec![Token::Ident("a:b:c".into())]);
}
#[test]
fn colon_with_port() {
assert_eq!(lex("host:8080"), vec![Token::Ident("host:8080".into())]);
}
#[test]
fn colon_standalone() {
assert_eq!(lex(":"), vec![Token::Colon]);
}
#[test]
fn colon_spaced_no_merge() {
assert_eq!(
lex("foo : bar"),
vec![
Token::Ident("foo".into()),
Token::Colon,
Token::Ident("bar".into()),
]
);
}
#[test]
fn colon_in_command_arg() {
assert_eq!(
lex("echo foo::bar"),
vec![
Token::Ident("echo".into()),
Token::Ident("foo::bar".into()),
]
);
}
#[test]
fn colon_trailing() {
assert_eq!(lex("foo:"), vec![Token::Ident("foo:".into())]);
}
#[test]
fn colon_leading() {
assert_eq!(lex(":foo"), vec![Token::Ident(":foo".into())]);
}
#[test]
fn colon_with_path() {
assert_eq!(
lex("/usr/bin:8080"),
vec![Token::Ident("/usr/bin:8080".into())]
);
}
#[test]
fn is_keyword_covers_control_flow() {
for t in [
Token::While,
Token::Return,
Token::Break,
Token::Continue,
Token::Exit,
] {
assert!(t.is_keyword(), "{t:?} should be a keyword");
}
}
#[test]
fn starts_statement_covers_while() {
assert!(Token::While.starts_statement());
}
#[test]
fn is_keyword_rejects_operators() {
for t in [Token::Pipe, Token::Amp, Token::Eq, Token::LBrace] {
assert!(!t.is_keyword(), "{t:?} should not be a keyword");
}
}
#[test]
fn bare_comma_run_folds_to_ident() {
assert_eq!(lex("1,3p"), vec![Token::Ident("1,3p".into())]);
assert_eq!(lex("1,3"), vec![Token::Ident("1,3".into())]);
assert_eq!(lex("2,2n"), vec![Token::Ident("2,2n".into())]);
assert_eq!(lex("a,b"), vec![Token::Ident("a,b".into())]);
assert_eq!(lex("1,2,3"), vec![Token::Ident("1,2,3".into())]);
}
#[test]
fn standalone_comma_stays_a_token() {
assert_eq!(
lex("cut -d , -f2"),
vec![
Token::Ident("cut".into()),
Token::ShortFlag("d".into()),
Token::Comma,
Token::ShortFlag("f2".into()),
]
);
}
#[test]
fn case_pattern_brace_comma_stays_significant() {
assert_eq!(
lex("{js,ts}"),
vec![
Token::LBrace,
Token::Ident("js".into()),
Token::Comma,
Token::Ident("ts".into()),
Token::RBrace,
]
);
}
#[test]
fn case_pattern_paren_inside_list_literal_cmd_subst_does_not_leak_list_frame() {
assert_eq!(
lex("x=[a $(case b in b) echo [dog];; esac) c]"),
vec![
Token::Ident("x".into()),
Token::Eq,
Token::LBracket,
Token::Ident("a".into()),
Token::CmdSubstStart,
Token::Case,
Token::Ident("b".into()),
Token::In,
Token::Ident("b".into()),
Token::RParen,
Token::Ident("echo".into()),
Token::GlobWord("[dog]".into()),
Token::DoubleSemi,
Token::Esac,
Token::RParen,
Token::Ident("c".into()),
Token::RBracket,
]
);
}
#[test]
fn esac_bareword_inside_still_open_case_does_not_leak_list_frame() {
assert_eq!(
lex("x=[a $(case v in v) y=esac;; w) echo [dog];; esac) c]"),
vec![
Token::Ident("x".into()),
Token::Eq,
Token::LBracket,
Token::Ident("a".into()),
Token::CmdSubstStart,
Token::Case,
Token::Ident("v".into()),
Token::In,
Token::Ident("v".into()),
Token::RParen,
Token::Ident("y".into()),
Token::Eq,
Token::Esac,
Token::DoubleSemi,
Token::Ident("w".into()),
Token::RParen,
Token::Ident("echo".into()),
Token::GlobWord("[dog]".into()),
Token::DoubleSemi,
Token::Esac,
Token::RParen,
Token::Ident("c".into()),
Token::RBracket,
]
);
}
#[test]
fn case_pattern_parenthesized_paren_inside_list_literal_cmd_subst_does_not_leak_list_frame() {
assert_eq!(
lex("x=[a $(case v in (v) y=esac;; w) echo [dog];; esac) c]"),
vec![
Token::Ident("x".into()),
Token::Eq,
Token::LBracket,
Token::Ident("a".into()),
Token::CmdSubstStart,
Token::Case,
Token::Ident("v".into()),
Token::In,
Token::LParen,
Token::Ident("v".into()),
Token::RParen,
Token::Ident("y".into()),
Token::Eq,
Token::Esac,
Token::DoubleSemi,
Token::Ident("w".into()),
Token::RParen,
Token::Ident("echo".into()),
Token::GlobWord("[dog]".into()),
Token::DoubleSemi,
Token::Esac,
Token::RParen,
Token::Ident("c".into()),
Token::RBracket,
]
);
}
#[test]
fn case_eq_argv_key_inside_cmd_subst_does_not_leak_open_scope() {
assert_eq!(
lex("x=[a $(echo case=x) echo [dog]]"),
vec![
Token::Ident("x".into()),
Token::Eq,
Token::LBracket,
Token::Ident("a".into()),
Token::CmdSubstStart,
Token::Ident("echo".into()),
Token::Case,
Token::Eq,
Token::Ident("x".into()),
Token::RParen,
Token::Ident("echo".into()),
Token::LBracket,
Token::Ident("dog".into()),
Token::RBracket,
Token::RBracket,
]
);
}
#[test]
fn glob_brace_expansion_with_star_still_fuses() {
assert_eq!(lex("*.{js,ts}"), vec![Token::GlobWord("*.{js,ts}".into())]);
assert_eq!(
lex("src/*.{rs,toml}"),
vec![Token::GlobWord("src/*.{rs,toml}".into())]
);
}
#[test]
fn bracket_list_with_spaces_keeps_comma_significant() {
assert_eq!(
lex("[1, 2, 3]"),
vec![
Token::LBracket,
Token::Int(1),
Token::Comma,
Token::Int(2),
Token::Comma,
Token::Int(3),
Token::RBracket,
]
);
}
#[test]
fn nested_list_of_lists_keeps_commas_significant() {
assert_eq!(
lex("x=[[1,2],[3,4]]"),
vec![
Token::Ident("x".into()),
Token::Eq,
Token::LBracket,
Token::LBracket,
Token::Int(1),
Token::Comma,
Token::Int(2),
Token::RBracket,
Token::Comma,
Token::LBracket,
Token::Int(3),
Token::Comma,
Token::Int(4),
Token::RBracket,
Token::RBracket,
]
);
}
#[test]
fn nested_record_in_list_keeps_commas_significant() {
assert_eq!(
lex("x=[{a:1},{b:2}]"),
vec![
Token::Ident("x".into()),
Token::Eq,
Token::LBracket,
Token::LBrace,
Token::Ident("a".into()),
Token::Colon,
Token::Int(1),
Token::RBrace,
Token::Comma,
Token::LBrace,
Token::Ident("b".into()),
Token::Colon,
Token::Int(2),
Token::RBrace,
Token::RBracket,
]
);
}
#[test]
fn stray_unclosed_bracket_does_not_wedge_past_the_line() {
assert_eq!(
lex("[dog\nsed -n 1,3p"),
vec![
Token::LBracket,
Token::Ident("dog".into()),
Token::Newline,
Token::Ident("sed".into()),
Token::ShortFlag("n".into()),
Token::Ident("1,3p".into()),
]
);
}
#[test]
fn stray_unmatched_closing_bracket_does_not_underflow() {
assert_eq!(lex("]a,b"), vec![Token::Ident("]a,b".into())]);
}
#[test]
fn comma_in_double_quoted_string_is_string_content() {
assert_eq!(lex(r#""a,b""#), vec![Token::String("a,b".into())]);
}
#[test]
fn comma_in_single_quoted_string_is_string_content() {
assert_eq!(lex("'a,b'"), vec![Token::SingleString("a,b".into())]);
}
#[test]
fn comma_in_var_ref_braces_is_not_tokenized_separately() {
assert_eq!(
lex("${X:-1,3}"),
vec![Token::VarRef("${X:-1,3}".into())]
);
}
#[test]
fn comma_inside_cmd_subst_folds_like_top_level() {
assert_eq!(
lex("$(sed -n 1,3p)"),
vec![
Token::CmdSubstStart,
Token::Ident("sed".into()),
Token::ShortFlag("n".into()),
Token::Ident("1,3p".into()),
Token::RParen,
]
);
}
#[test]
fn non_comma_glued_pasting_is_unaffected() {
assert_eq!(
lex("--flag$(echo x)"),
vec![
Token::LongFlag("flag".into()),
Token::CmdSubstStart,
Token::Ident("echo".into()),
Token::Ident("x".into()),
Token::RParen,
]
);
}
}