use crate::ast::{
Arg, Assignment, BinaryOp, CaseBranch, CaseStmt, Command, Expr, FileTestOp, ForLoop,
HereDocMeta, IfStmt, ListElem, Pipeline, PipelineStage, Program, RecordEntry, RecordKey,
Redirect, RedirectKind, SpannedPart, Stmt, StringPart, StringTestOp, TestCmpOp, TestExpr,
ToolDef, Value,
VarPath, VarSegment, WhileLoop,
};
use crate::lexer::{self, HereDocData, Token};
use chumsky::input::{MappedInput, Stream, ValueInput};
use chumsky::prelude::*;
pub type Span = SimpleSpan;
type ParserInput = MappedInput<'static, Token, Span, Stream<std::vec::IntoIter<(Token, Span)>>, PairFn>;
type PairFn = fn((Token, Span)) -> (Token, Span);
fn keep_pair(pair: (Token, Span)) -> (Token, Span) {
pair
}
thread_local! {
static CACHED_PARSER: Boxed<
'static,
'static,
ParserInput,
Program,
extra::Err<Rich<'static, Token, Span>>,
> = program_parser().boxed();
}
fn parse_var_expr(raw: &str) -> Expr {
if raw == "${?}" {
return Expr::LastExitCode;
}
if raw == "${$}" {
return Expr::CurrentPid;
}
if let Some(colon_idx) = find_default_separator(raw) {
let path = parse_varpath(&format!("${{{}}}", &raw[2..colon_idx]));
let default_str = &raw[colon_idx + 2..raw.len() - 1];
let default_word = unquote_default_word(default_str);
let default = parse_interpolated_string(&default_word)
.unwrap_or_else(|_| vec![StringPart::Literal(default_word.clone())]);
return Expr::VarWithDefault { path, default };
}
Expr::VarRef(parse_varpath(raw))
}
pub(crate) fn bash_substring_hint(var_content: &str) -> Option<String> {
let mut depth = 0usize;
let colon = var_content.char_indices().find_map(|(i, c)| match c {
'[' => {
depth += 1;
None
}
']' => {
depth = depth.saturating_sub(1);
None
}
':' if depth == 0 => Some(i),
_ => None,
})?;
let (name, rest) = var_content.split_at(colon);
let after_offset = &rest[1..];
let suggestion = if let Some(length) = after_offset.strip_prefix(':') {
format!("${{{name}[0:{length}]}}")
} else if after_offset.contains(':') {
format!("${{{name}[{after_offset}]}}")
} else {
format!("${{{name}[{after_offset}:]}}")
};
Some(format!(
"${{{var_content}}}: kaish slices with brackets, not `:offset:length` — \
write {suggestion}. Brackets are start:end and end-exclusive, so \
${{{name}[0:5]}} is the first five characters and ${{{name}[-3:]}} the last three."
))
}
fn unquote_default_word(word: &str) -> String {
let mut out = String::with_capacity(word.len());
let mut in_single = false;
let mut in_double = false;
let chars: Vec<char> = word.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch == '\\' && !in_single {
let run_start = i;
while i < chars.len() && chars[i] == '\\' {
i += 1;
}
let run_len = i - run_start;
let next_is_quote =
chars.get(i).is_some_and(|c| *c == '"' || (*c == '\'' && !in_double));
if next_is_quote {
if run_len / 2 > 0 {
out.push_str(&"\\".repeat(run_len / 2));
}
if run_len % 2 == 1 {
out.push(chars[i]);
i += 1;
}
} else {
out.push_str(&"\\".repeat(run_len));
}
continue;
}
i += 1;
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'$' if in_single => out.push_str("__KAISH_ESCAPED_DOLLAR__"),
_ => out.push(ch),
}
}
out
}
fn find_default_separator(raw: &str) -> Option<usize> {
let bytes = raw.as_bytes();
let mut depth = 0;
let mut bracket_depth = 0;
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
depth += 1;
i += 2;
continue;
}
if bytes[i] == b'}' && depth > 0 {
depth -= 1;
i += 1;
continue;
}
if bytes[i] == b'[' {
bracket_depth += 1;
} else if bytes[i] == b']' && bracket_depth > 0 {
bracket_depth -= 1;
}
if depth == 1
&& bracket_depth == 0
&& i + 1 < bytes.len()
&& bytes[i] == b':'
&& bytes[i + 1] == b'-'
{
return Some(i);
}
i += 1;
}
None
}
fn find_default_separator_in_content(content: &str) -> Option<usize> {
let bytes = content.as_bytes();
let mut depth = 0;
let mut bracket_depth = 0;
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
depth += 1;
i += 2;
continue;
}
if bytes[i] == b'}' && depth > 0 {
depth -= 1;
i += 1;
continue;
}
if bytes[i] == b'[' {
bracket_depth += 1;
} else if bytes[i] == b']' && bracket_depth > 0 {
bracket_depth -= 1;
}
if depth == 0
&& bracket_depth == 0
&& i + 1 < bytes.len()
&& bytes[i] == b':'
&& bytes[i + 1] == b'-'
{
return Some(i);
}
i += 1;
}
None
}
fn is_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || !c.is_ascii()
}
fn is_name_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_' || !c.is_ascii()
}
fn name_in_token_kind<'a>(
tok: &'a Token,
prev: Option<&Token>,
next: Option<&Token>,
) -> Option<(&'a str, bool)> {
match tok {
Token::SimpleVarRef(name) => Some((name.as_str(), false)),
Token::VarLength(inner) => Some((root_of(inner), false)),
Token::VarRef(raw) => raw
.strip_prefix("${")
.and_then(|s| s.strip_suffix('}'))
.map(|r| (root_of(r), false)),
Token::Ident(name)
if matches!(next, Some(Token::Eq))
&& match prev {
None => true,
Some(p) => crate::lexer::is_statement_boundary(p) || matches!(p, Token::Local),
} =>
{
Some((name.as_str(), true))
}
Token::Ident(name) if matches!(prev, Some(Token::For)) => Some((name.as_str(), false)),
_ => None,
}
}
fn bad_name_in_parts(parts: &[StringPart]) -> Option<crate::name::NameError> {
fn root(path: &VarPath) -> Option<&str> {
match path.segments.first() {
Some(VarSegment::Field(name)) => Some(name.as_str()),
_ => None,
}
}
for part in parts {
let bad = match part {
StringPart::Var(path) | StringPart::VarLength(path) => {
root(path).and_then(|n| crate::name::validate(n).err())
}
StringPart::VarWithDefault { path, default } => root(path)
.and_then(|n| crate::name::validate(n).err())
.or_else(|| bad_name_in_parts(default)),
_ => None,
};
if bad.is_some() {
return bad;
}
}
None
}
fn root_of(inner: &str) -> &str {
let end = inner.find(['[', '.', ':', '-']).unwrap_or(inner.len());
&inner[..end]
}
pub(crate) fn parse_varpath(raw: &str) -> VarPath {
let segment_strs = lexer::parse_var_ref(raw).unwrap_or_default();
let segments = segment_strs
.into_iter()
.enumerate()
.map(|(i, s)| {
if i == 0 {
VarSegment::Field(crate::ast::normalize_name(s))
} else if let Some(inner) = s.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
parse_subscript(inner)
} else {
VarSegment::Field(s)
}
})
.collect();
VarPath { segments }
}
fn parse_subscript(inner: &str) -> VarSegment {
if let Some(var) = inner.strip_prefix('$') {
return VarSegment::Dynamic(var.to_string());
}
if inner.len() >= 2
&& ((inner.starts_with('"') && inner.ends_with('"'))
|| (inner.starts_with('\'') && inner.ends_with('\'')))
{
return VarSegment::Key(inner[1..inner.len() - 1].to_string());
}
if let Some((lhs, rhs)) = inner.split_once(':') {
let bound = |s: &str| -> Option<Option<i64>> {
if s.is_empty() {
Some(None)
} else {
s.parse::<i64>().ok().map(Some)
}
};
if let (Some(start), Some(end)) = (bound(lhs), bound(rhs)) {
return VarSegment::Slice(start, end);
}
}
if let Ok(i) = inner.parse::<i64>() {
return VarSegment::Index(i);
}
VarSegment::Key(inner.to_string())
}
fn strip_empty_stmts(statements: Vec<Stmt>) -> Vec<Stmt> {
statements
.into_iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect()
}
fn parse_interpolated_string_spanned(
s: &str,
base_offset: usize,
) -> Result<Vec<SpannedPart>, String> {
let s = s.replace("__KAISH_ESCAPED_DOLLAR__", "\x00DOLLAR\x00");
let chars_vec: Vec<char> = s.chars().collect();
let mut i = 0;
let mut pos: usize = 0;
let mut parts: Vec<SpannedPart> = Vec::new();
let mut current_text = String::new();
let mut current_text_start: usize = pos;
let push_literal =
|current_text: &mut String, start: &mut usize, end: usize, parts: &mut Vec<SpannedPart>| {
if !current_text.is_empty() {
parts.push(SpannedPart {
part: StringPart::Literal(std::mem::take(current_text)),
offset: base_offset + *start,
len: end - *start,
});
*start = end;
}
};
while i < chars_vec.len() {
let ch = chars_vec[i];
if ch == '\x00' {
let start = pos;
i += 1;
pos += 1;
let mut marker = String::new();
while let Some(&c) = chars_vec.get(i) {
if c == '\x00' {
i += 1;
pos += 1;
break;
}
marker.push(c);
i += 1;
pos += c.len_utf8();
}
if marker == "DOLLAR" {
if current_text.is_empty() {
current_text_start = start;
}
current_text.push('$');
}
} else if ch == '\\' {
let next = chars_vec.get(i + 1).copied();
match next {
Some('$') => {
if current_text.is_empty() {
current_text_start = pos;
}
current_text.push('$');
i += 2;
pos += 2;
}
Some('\\') => {
if current_text.is_empty() {
current_text_start = pos;
}
current_text.push('\\');
i += 2;
pos += 2;
}
Some('\n') => {
i += 2;
pos += 2;
if current_text.is_empty() {
current_text_start = pos;
}
}
Some('\r') => {
i += 2;
pos += 2;
if chars_vec.get(i) == Some(&'\n') {
i += 1;
pos += 1;
}
if current_text.is_empty() {
current_text_start = pos;
}
}
_ => {
if current_text.is_empty() {
current_text_start = pos;
}
current_text.push('\\');
i += 1;
pos += 1;
}
}
} else if ch == '$' {
let part_start = pos;
let next = chars_vec.get(i + 1).copied();
if next == Some('(') && chars_vec.get(i + 2) != Some(&'(') {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 2; pos += 2;
let mut cmd_content = String::new();
let mut depth = 1;
let mut closed = false;
while let Some(&c) = chars_vec.get(i) {
i += 1;
pos += c.len_utf8();
if c == '(' {
depth += 1;
cmd_content.push(c);
} else if c == ')' {
depth -= 1;
if depth == 0 {
closed = true;
break;
}
cmd_content.push(c);
} else {
cmd_content.push(c);
}
}
if !closed {
return Err("unterminated command substitution: missing `)`".to_string());
}
let inserted = if let Ok(program) = parse(&cmd_content) {
let stmts = strip_empty_stmts(program.statements);
if stmts.is_empty() {
false
} else {
parts.push(SpannedPart {
part: StringPart::CommandSubst(stmts),
offset: base_offset + part_start,
len: pos - part_start,
});
true
}
} else {
return Err(format!(
"syntax error in command substitution: $({cmd_content})"
));
};
if inserted {
current_text_start = pos;
} else {
if current_text.is_empty() {
current_text_start = part_start;
}
current_text.push_str("$(");
current_text.push_str(&cmd_content);
current_text.push(')');
}
} else if next == Some('{') {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 2; pos += 2;
let mut var_content = String::new();
let mut depth = 1;
while let Some(&c) = chars_vec.get(i) {
i += 1;
pos += c.len_utf8();
if c == '{' && var_content.ends_with('$') {
depth += 1;
var_content.push(c);
} else if c == '}' {
depth -= 1;
if depth == 0 {
break;
}
var_content.push(c);
} else {
var_content.push(c);
}
}
let part = if let Some(name) = var_content.strip_prefix('#') {
if find_default_separator_in_content(name).is_some() {
return Err(format!(
"${{#{name}}}: a length cannot carry a default — \
${{#NAME}} counts, ${{NAME:-default}} substitutes. \
Write ${{#NAME}} on a name you have set, or test it \
first."
));
}
StringPart::VarLength(parse_varpath(&format!("${{{name}}}")))
} else if var_content.starts_with("__ARITH:") && var_content.ends_with("__") {
let expr = var_content
.strip_prefix("__ARITH:")
.and_then(|s| s.strip_suffix("__"))
.unwrap_or("");
StringPart::Arithmetic(expr.to_string())
} else if let Some(colon_idx) = find_default_separator_in_content(&var_content) {
let path = parse_varpath(&format!("${{{}}}", &var_content[..colon_idx]));
let default_str = &var_content[colon_idx + 2..];
let default = parse_interpolated_string(&unquote_default_word(default_str))?;
StringPart::VarWithDefault { path, default }
} else if let Some(msg) = bash_substring_hint(&var_content) {
return Err(msg);
} else {
StringPart::Var(parse_varpath(&format!("${{{}}}", var_content)))
};
parts.push(SpannedPart {
part,
offset: base_offset + part_start,
len: pos - part_start,
});
current_text_start = pos;
} else if next.map(|c| c.is_ascii_digit()).unwrap_or(false) {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 1; pos += 1;
if let Some(&digit) = chars_vec.get(i) {
let n = digit.to_digit(10).unwrap_or(0) as usize;
i += 1;
pos += digit.len_utf8();
parts.push(SpannedPart {
part: StringPart::Positional(n),
offset: base_offset + part_start,
len: pos - part_start,
});
}
current_text_start = pos;
} else if next == Some('@') {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 2; pos += 2;
parts.push(SpannedPart {
part: StringPart::AllArgs,
offset: base_offset + part_start,
len: pos - part_start,
});
current_text_start = pos;
} else if next == Some('#') {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 2; pos += 2;
parts.push(SpannedPart {
part: StringPart::ArgCount,
offset: base_offset + part_start,
len: pos - part_start,
});
current_text_start = pos;
} else if next == Some('?') {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 2; pos += 2;
parts.push(SpannedPart {
part: StringPart::LastExitCode,
offset: base_offset + part_start,
len: pos - part_start,
});
current_text_start = pos;
} else if next == Some('$') {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 2; pos += 2;
parts.push(SpannedPart {
part: StringPart::CurrentPid,
offset: base_offset + part_start,
len: pos - part_start,
});
current_text_start = pos;
} else if next.map(is_name_start).unwrap_or(false) {
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
i += 1; pos += 1;
let mut var_name = String::new();
while let Some(&c) = chars_vec.get(i) {
if is_name_char(c) {
var_name.push(c);
i += 1;
pos += c.len_utf8();
} else {
break;
}
}
parts.push(SpannedPart {
part: StringPart::Var(VarPath::simple(var_name)),
offset: base_offset + part_start,
len: pos - part_start,
});
current_text_start = pos;
} else {
if current_text.is_empty() {
current_text_start = pos;
}
current_text.push(ch);
i += 1;
pos += 1;
}
} else {
if current_text.is_empty() {
current_text_start = pos;
}
current_text.push(ch);
i += 1;
pos += ch.len_utf8();
}
}
push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
Ok(parts)
}
fn parse_interpolated_string(s: &str) -> Result<Vec<StringPart>, String> {
let s = s.replace("__KAISH_ESCAPED_DOLLAR__", "\x00DOLLAR\x00");
let mut parts = Vec::new();
let mut current_text = String::new();
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x00' {
let mut marker = String::new();
while let Some(&c) = chars.peek() {
if c == '\x00' {
chars.next(); break;
}
if let Some(c) = chars.next() {
marker.push(c);
}
}
if marker == "DOLLAR" {
current_text.push('$');
}
} else if ch == '$' {
if chars.peek() == Some(&'(') {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
chars.next();
let remainder: String = chars.clone().collect();
let close = lexer::tokenize(&remainder).ok().and_then(|toks| {
let toks: Vec<(Token, Span)> = toks
.into_iter()
.map(|sp| (sp.token, (sp.span.start..sp.span.end).into()))
.collect();
find_cmd_subst_close(&toks).map(|idx| toks[idx].1)
});
let Some(rparen_span) = close else {
return Err("unterminated command substitution: missing `)`".to_string());
};
let (cmd_content, consume_bytes) =
(remainder[..rparen_span.start].to_string(), rparen_span.end);
let mut consumed = 0usize;
while consumed < consume_bytes {
match chars.next() {
Some(c) => consumed += c.len_utf8(),
None => break,
}
}
match parse(&cmd_content) {
Ok(program) => {
let stmts = strip_empty_stmts(program.statements);
if stmts.is_empty() {
current_text.push_str("$(");
current_text.push_str(&cmd_content);
current_text.push(')');
} else {
parts.push(StringPart::CommandSubst(stmts));
}
}
Err(_) => {
return Err(format!(
"syntax error in command substitution: $({cmd_content})"
));
}
}
} else if chars.peek() == Some(&'{') {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
chars.next();
let mut var_content = String::new();
let mut depth = 1;
for c in chars.by_ref() {
if c == '{' && var_content.ends_with('$') {
depth += 1;
var_content.push(c);
} else if c == '}' {
depth -= 1;
if depth == 0 {
break;
}
var_content.push(c);
} else {
var_content.push(c);
}
}
let part = if let Some(name) = var_content.strip_prefix('#') {
if find_default_separator_in_content(name).is_some() {
return Err(format!(
"${{#{name}}}: a length cannot carry a default — \
${{#NAME}} counts, ${{NAME:-default}} substitutes. \
Write ${{#NAME}} on a name you have set, or test it \
first."
));
}
StringPart::VarLength(parse_varpath(&format!("${{{name}}}")))
} else if var_content.starts_with("__ARITH:") && var_content.ends_with("__") {
let expr = var_content
.strip_prefix("__ARITH:")
.and_then(|s| s.strip_suffix("__"))
.unwrap_or("");
StringPart::Arithmetic(expr.to_string())
} else if let Some(colon_idx) = find_default_separator_in_content(&var_content) {
let path = parse_varpath(&format!("${{{}}}", &var_content[..colon_idx]));
let default_str = &var_content[colon_idx + 2..];
let default = parse_interpolated_string(&unquote_default_word(default_str))?;
StringPart::VarWithDefault { path, default }
} else if let Some(msg) = bash_substring_hint(&var_content) {
return Err(msg);
} else {
StringPart::Var(parse_varpath(&format!("${{{}}}", var_content)))
};
parts.push(part);
} else if chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
if let Some(digit) = chars.next() {
let n = digit.to_digit(10).unwrap_or(0) as usize;
parts.push(StringPart::Positional(n));
}
} else if chars.peek() == Some(&'@') {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
chars.next(); parts.push(StringPart::AllArgs);
} else if chars.peek() == Some(&'#') {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
chars.next(); parts.push(StringPart::ArgCount);
} else if chars.peek() == Some(&'?') {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
chars.next(); parts.push(StringPart::LastExitCode);
} else if chars.peek() == Some(&'$') {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
chars.next(); parts.push(StringPart::CurrentPid);
} else if chars.peek().copied().map(is_name_start).unwrap_or(false) {
if !current_text.is_empty() {
parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
}
let mut var_name = String::new();
while let Some(&c) = chars.peek() {
if is_name_char(c) {
if let Some(c) = chars.next() {
var_name.push(c);
}
} else {
break;
}
}
parts.push(StringPart::Var(VarPath::simple(var_name)));
} else {
current_text.push(ch);
}
} else {
current_text.push(ch);
}
}
if !current_text.is_empty() {
parts.push(StringPart::Literal(current_text));
}
Ok(parts)
}
#[derive(Debug, Clone)]
pub struct ParseError {
pub span: Span,
pub message: String,
}
impl ParseError {
pub fn format(&self, source: &str) -> String {
let start = self.span.start;
let mut line = 1usize;
let mut col = 1usize;
for (i, ch) in source.char_indices() {
if i >= start {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
let line_content = {
let line_start = source[..start.min(source.len())]
.rfind('\n')
.map_or(0, |i| i + 1);
let line_end = source[start.min(source.len())..]
.find('\n')
.map_or(source.len(), |i| start + i);
source.get(line_start..line_end).unwrap_or("")
};
if line_content.is_empty() {
format!("{}:{} [parse]: {}", line, col, self.message)
} else {
format!(
"{}:{} [parse]: {}\n | {}",
line, col, self.message, line_content
)
}
}
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} at {:?}", self.message, self.span)
}
}
impl std::error::Error for ParseError {}
pub fn parse(source: &str) -> Result<Program, Vec<ParseError>> {
let tokens = lexer::tokenize(source).map_err(|errs| {
errs.into_iter()
.map(|e| ParseError {
span: (e.span.start..e.span.end).into(),
message: format!("lexer error: {}", e.token),
})
.collect::<Vec<_>>()
})?;
let tokens: Vec<(Token, Span)> = tokens
.into_iter()
.map(|spanned| (spanned.token, (spanned.span.start..spanned.span.end).into()))
.collect();
for (i, (tok, span)) in tokens.iter().enumerate() {
let prev = i.checked_sub(1).and_then(|j| tokens.get(j)).map(|(t, _)| t);
if let Some((name, is_target)) =
name_in_token_kind(tok, prev, tokens.get(i + 1).map(|(t, _)| t))
{
if let Err(bad) = crate::name::validate(name) {
let defer = is_target && matches!(bad.ch, '.' | '#');
if !defer {
return Err(vec![ParseError { span: *span, message: bad.to_string() }]);
}
}
}
if let Token::String(s) = tok {
if !s.is_ascii() && s.contains('$') {
if let Ok(parts) = parse_interpolated_string(s) {
if let Some(bad) = bad_name_in_parts(&parts) {
return Err(vec![ParseError { span: *span, message: bad.to_string() }]);
}
}
}
}
let message = match tok {
Token::VarRef(raw) => raw
.strip_prefix("${")
.and_then(|s| s.strip_suffix('}'))
.filter(|_| find_default_separator(raw).is_none())
.and_then(bash_substring_hint),
Token::String(s) if s.contains("${") && s.contains(':') => {
parse_interpolated_string(s).err()
}
_ => None,
};
if let Some(message) = message {
return Err(vec![ParseError {
span: *span,
message,
}]);
}
}
let end_span: Span = (source.len()..source.len()).into();
parse_tokens(tokens, end_span, (0..0).into())
}
fn parse_tokens(
tokens: Vec<(Token, Span)>,
end_span: Span,
stdin_anchor: Span,
) -> Result<Program, Vec<ParseError>> {
let input = Stream::from_iter(tokens.clone()).map(end_span, keep_pair as PairFn);
let result = CACHED_PARSER.with(|parser| parser.parse(input));
let program = result.into_result().map_err(|errs| {
if let Err(specific) = validate_cmd_subst_bodies(&tokens) {
return specific;
}
if let Err(specific) = validate_interpolated_strings(&tokens) {
return specific;
}
if let Err(specific) = validate_heredoc_bodies(&tokens) {
return specific;
}
errs.into_iter()
.map(|e| ParseError {
span: *e.span(),
message: e.to_string(),
})
.collect::<Vec<_>>()
})?;
if first_ambiguous_stdin(&program.statements) {
return Err(vec![ParseError {
span: stdin_anchor,
message: "multiple stdin redirects on one command are ambiguous; \
use exactly one of `<`, `<<`, or `<<<`"
.to_string(),
}]);
}
Ok(program)
}
pub fn parse_statement(source: &str) -> Result<Stmt, Vec<ParseError>> {
let program = parse(source)?;
program
.statements
.into_iter()
.find(|s| !matches!(s, Stmt::Empty))
.ok_or_else(|| {
vec![ParseError {
span: (0..source.len()).into(),
message: "empty input".to_string(),
}]
})
}
fn program_parser<'tokens, 'src: 'tokens, I>(
) -> impl Parser<'tokens, I, Program, extra::Err<Rich<'tokens, Token, Span>>>
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
statement_parser()
.repeated()
.collect::<Vec<_>>()
.map(|statements| Program { statements })
}
fn statement_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
recursive(|stmt| {
let terminator = choice((just(Token::Newline), just(Token::Semi))).repeated();
let break_stmt = just(Token::Break)
.ignore_then(
select! { Token::Int(n) => n as usize }.or_not()
)
.map(Stmt::Break);
let continue_stmt = just(Token::Continue)
.ignore_then(
select! { Token::Int(n) => n as usize }.or_not()
)
.map(Stmt::Continue);
let return_stmt = just(Token::Return)
.ignore_then(primary_expr_parser().or_not())
.map(|e| Stmt::Return(e.map(Box::new)));
let exit_stmt = just(Token::Exit)
.ignore_then(primary_expr_parser().or_not())
.map(|e| Stmt::Exit(e.map(Box::new)));
let set_flag_arg = choice((
select! { Token::ShortFlag(f) => Arg::ShortFlag(f) },
select! { Token::LongFlag(f) => Arg::LongFlag(f) },
select! { Token::PlusFlag(f) => Arg::Positional(Expr::Literal(Value::String(format!("+{}", f)))) },
));
let option_value_str = select! {
Token::NumberIdent(s) => s,
Token::Int(n) => n.to_string(),
Token::Ident(s) => s,
};
let set_option_assign = ident_parser()
.then_ignore(just(Token::Eq))
.then(option_value_str)
.map(|(name, value)| {
Arg::Positional(Expr::Literal(Value::String(format!("{name}={value}"))))
});
let set_quoted_arg = select! {
Token::String(s) => Arg::Positional(Expr::Literal(Value::String(s))),
Token::SingleString(s) => Arg::Positional(Expr::Literal(Value::String(s))),
};
let set_with_flags = just(Token::Set)
.then(set_flag_arg)
.then(
choice((
set_flag_arg,
set_option_assign,
set_quoted_arg,
ident_parser().map(|name| Arg::Positional(Expr::Literal(Value::String(name)))),
))
.repeated()
.collect::<Vec<_>>(),
)
.map(|((_, first_arg), mut rest_args)| {
let mut args = vec![first_arg];
args.append(&mut rest_args);
Stmt::Command(Command {
name: "set".to_string(),
args,
redirects: vec![],
})
});
let set_no_args = just(Token::Set)
.then(
choice((
just(Token::Newline).to(()),
just(Token::Semi).to(()),
just(Token::And).to(()),
just(Token::Or).to(()),
end(),
))
.rewind(),
)
.map(|_| Stmt::Command(Command {
name: "set".to_string(),
args: vec![],
redirects: vec![],
}));
let set_command = set_with_flags.or(set_no_args);
let env_prefix_assign = ident_parser()
.then_ignore(just(Token::Eq))
.then(value_expr_parser())
.map(|(name, value)| Assignment { path: VarPath::simple(name), value, local: false });
let env_scoped = env_prefix_assign
.repeated()
.at_least(1)
.collect::<Vec<_>>()
.then(pipeline_parser(command_stage_parser()).map(pipeline_into_stmt))
.map(|(assignments, body)| Stmt::EnvScoped {
assignments,
body: Box::new(body),
});
let compound = choice((
if_parser(stmt.clone()).map(Stmt::If),
for_parser(stmt.clone()).map(Stmt::For),
while_parser(stmt.clone()).map(Stmt::While),
case_parser(stmt.clone()).map(Stmt::Case),
))
.boxed();
let base_statement = choice((
just(Token::Newline).to(Stmt::Empty),
set_command,
env_scoped,
assignment_parser().map(Stmt::Assignment),
posix_function_parser(stmt.clone()).map(Stmt::ToolDef), bash_function_parser(stmt.clone()).map(Stmt::ToolDef), break_stmt,
continue_stmt,
return_stmt,
exit_stmt,
test_expr_stmt_parser().map(Stmt::Test),
pipeline_parser(choice((
compound.map(|s| PipelineStage::Compound(Box::new(s))),
command_stage_parser(),
)))
.map(pipeline_into_stmt),
))
.boxed();
base_statement
.clone()
.foldl(
choice((
just(Token::And).to(true), just(Token::Or).to(false), ))
.then(base_statement)
.repeated(),
|left, (is_and, right): (bool, Stmt)| {
if is_and {
Stmt::AndChain {
left: Box::new(left),
right: Box::new(right),
}
} else {
Stmt::OrChain {
left: Box::new(left),
right: Box::new(right),
}
}
},
)
.then_ignore(terminator)
})
}
fn lvalue_subscript_parser<'tokens, I>(
) -> impl Parser<'tokens, I, VarSegment, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let interior = choice((
select! { Token::SimpleVarRef(name) => VarSegment::Dynamic(name) },
select! { Token::String(s) => VarSegment::Key(s) },
select! { Token::SingleString(s) => VarSegment::Key(s) },
select! { Token::Int(n) => VarSegment::Index(n) },
select! { Token::Ident(s) => parse_subscript(&s) },
));
just(Token::LBracket)
.ignore_then(interior)
.then_ignore(just(Token::RBracket))
.labelled("subscript")
}
fn lvalue_path_parser<'tokens, I>(
) -> impl Parser<'tokens, I, VarPath, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
ident_parser()
.then(lvalue_subscript_parser().repeated().collect::<Vec<_>>())
.map(|(name, subscripts)| {
let mut segments = vec![VarSegment::Field(crate::ast::normalize_name(name))];
segments.extend(subscripts);
VarPath { segments }
})
.labelled("lvalue path")
}
fn assignment_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Assignment, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let local_assignment = just(Token::Local)
.ignore_then(lvalue_path_parser())
.then_ignore(just(Token::Eq))
.then(value_expr_parser())
.map(|(path, value)| Assignment {
path,
value,
local: true,
});
let bash_assignment = lvalue_path_parser()
.then_ignore(just(Token::Eq))
.then(value_expr_parser())
.map(|(path, value)| Assignment {
path,
value,
local: false,
});
choice((local_assignment, bash_assignment))
.labelled("assignment")
.boxed()
}
fn posix_function_parser<'tokens, I, S>(
stmt: S,
) -> impl Parser<'tokens, I, ToolDef, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
ident_parser()
.then_ignore(just(Token::LParen))
.then_ignore(just(Token::RParen))
.then_ignore(just(Token::LBrace))
.then_ignore(just(Token::Newline).repeated())
.then(
stmt.repeated()
.collect::<Vec<_>>()
.map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
)
.then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::RBrace))
.map(|(name, body)| ToolDef { name, params: vec![], body })
.labelled("POSIX function")
.boxed()
}
fn bash_function_parser<'tokens, I, S>(
stmt: S,
) -> impl Parser<'tokens, I, ToolDef, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
just(Token::Function)
.ignore_then(ident_parser())
.then_ignore(just(Token::LBrace))
.then_ignore(just(Token::Newline).repeated())
.then(
stmt.repeated()
.collect::<Vec<_>>()
.map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
)
.then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::RBrace))
.map(|(name, body)| ToolDef { name, params: vec![], body })
.labelled("bash function")
.boxed()
}
fn if_parser<'tokens, I, S>(
stmt: S,
) -> impl Parser<'tokens, I, IfStmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
let branch = condition_parser()
.then_ignore(just(Token::Semi).or_not())
.then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::Then))
.then_ignore(just(Token::Newline).repeated())
.then(
stmt.clone()
.repeated()
.collect::<Vec<_>>()
.map(|stmts: Vec<Stmt>| {
stmts
.into_iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect::<Vec<_>>()
}),
);
let elif_branch = just(Token::Elif)
.ignore_then(condition_parser())
.then_ignore(just(Token::Semi).or_not())
.then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::Then))
.then_ignore(just(Token::Newline).repeated())
.then(
stmt.clone()
.repeated()
.collect::<Vec<_>>()
.map(|stmts: Vec<Stmt>| {
stmts
.into_iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect::<Vec<_>>()
}),
);
let else_branch = just(Token::Else)
.ignore_then(just(Token::Newline).repeated())
.ignore_then(stmt.repeated().collect::<Vec<_>>())
.map(|stmts: Vec<Stmt>| {
stmts
.into_iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect::<Vec<_>>()
});
just(Token::If)
.ignore_then(branch)
.then(elif_branch.repeated().collect::<Vec<_>>())
.then(else_branch.or_not())
.then_ignore(just(Token::Fi))
.map(|(((condition, then_branch), elif_branches), else_branch)| {
build_if_chain(condition, then_branch, elif_branches, else_branch)
})
.labelled("if statement")
.boxed()
}
fn build_if_chain(
condition: Expr,
then_branch: Vec<Stmt>,
mut elif_branches: Vec<(Expr, Vec<Stmt>)>,
else_branch: Option<Vec<Stmt>>,
) -> IfStmt {
if elif_branches.is_empty() {
IfStmt {
condition: Box::new(condition),
then_branch,
else_branch,
}
} else {
let (elif_cond, elif_then) = elif_branches.remove(0);
let nested_if = build_if_chain(elif_cond, elif_then, elif_branches, else_branch);
IfStmt {
condition: Box::new(condition),
then_branch,
else_branch: Some(vec![Stmt::If(nested_if)]),
}
}
}
fn for_parser<'tokens, I, S>(
stmt: S,
) -> impl Parser<'tokens, I, ForLoop, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
just(Token::For)
.ignore_then(ident_parser())
.then_ignore(just(Token::In))
.then(expr_parser().repeated().at_least(1).collect::<Vec<_>>())
.then_ignore(just(Token::Semi).or_not())
.then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::Do))
.then_ignore(just(Token::Newline).repeated())
.then(
stmt.repeated()
.collect::<Vec<_>>()
.map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
)
.then_ignore(just(Token::Done))
.map(|((variable, items), body)| ForLoop {
variable,
items,
body,
})
.labelled("for loop")
.boxed()
}
fn while_parser<'tokens, I, S>(
stmt: S,
) -> impl Parser<'tokens, I, WhileLoop, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
just(Token::While)
.ignore_then(condition_parser())
.then_ignore(just(Token::Semi).or_not())
.then_ignore(just(Token::Newline).repeated())
.then_ignore(just(Token::Do))
.then_ignore(just(Token::Newline).repeated())
.then(
stmt.repeated()
.collect::<Vec<_>>()
.map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
)
.then_ignore(just(Token::Done))
.map(|(condition, body)| WhileLoop {
condition: Box::new(condition),
body,
})
.labelled("while loop")
.boxed()
}
fn case_parser<'tokens, I, S>(
stmt: S,
) -> impl Parser<'tokens, I, CaseStmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
let pattern_part = choice((
select! { Token::GlobWord(s) => s },
select! { Token::Ident(s) => s },
select! { Token::NumberIdent(s) => s },
select! { Token::DashNumWord(s) => s },
select! { Token::AtWord(s) => s },
select! { Token::DottedIdent(s) => s },
select! { Token::String(s) => s },
select! { Token::SingleString(s) => s },
select! { Token::Int(n) => n.to_string() },
select! { Token::Star => "*".to_string() },
select! { Token::Question => "?".to_string() },
select! { Token::Dot => ".".to_string() },
select! { Token::DotDot => "..".to_string() },
select! { Token::Tilde => "~".to_string() },
select! { Token::TildePath(s) => s },
select! { Token::RelativePath(s) => s },
select! { Token::DotSlashPath(s) => s },
select! { Token::Path(p) => p },
select! { Token::VarRef(v) => v },
select! { Token::SimpleVarRef(v) => format!("${}", v) },
choice((
select! { Token::DoubleDashBare(s) => s },
select! { Token::PlusBare(s) => s },
select! { Token::MinusBare(s) => s },
select! { Token::MinusAlone => "-".to_string() },
select! { Token::DoubleDash => "--".to_string() },
select! { Token::ShortFlag(s) => format!("-{}", s) },
select! { Token::LongFlag(s) => format!("--{}", s) },
select! { Token::PlusFlag(s) => format!("+{}", s) },
)),
just(Token::LBracket)
.ignore_then(
choice((
select! { Token::Ident(s) => s },
select! { Token::Int(n) => n.to_string() },
just(Token::Colon).to(":".to_string()),
just(Token::Bang).to("!".to_string()),
select! { Token::ShortFlag(s) => format!("-{}", s) },
))
.repeated()
.at_least(1)
.collect::<Vec<String>>()
)
.then_ignore(just(Token::RBracket))
.map(|parts| format!("[{}]", parts.join(""))),
just(Token::LBrace)
.ignore_then(
choice((
select! { Token::Ident(s) => s },
select! { Token::Int(n) => n.to_string() },
))
.separated_by(just(Token::Comma))
.at_least(1)
.collect::<Vec<String>>()
)
.then_ignore(just(Token::RBrace))
.map(|parts| format!("{{{}}}", parts.join(","))),
));
let pattern = pattern_part
.repeated()
.at_least(1)
.collect::<Vec<String>>()
.map(|parts| parts.join(""))
.labelled("case pattern");
let patterns = pattern
.separated_by(just(Token::Pipe))
.at_least(1)
.collect::<Vec<String>>()
.labelled("case patterns");
let branch = just(Token::LParen)
.or_not()
.ignore_then(just(Token::Newline).repeated())
.ignore_then(patterns)
.then_ignore(just(Token::RParen))
.then_ignore(just(Token::Newline).repeated())
.then(
stmt.clone()
.repeated()
.collect::<Vec<_>>()
.map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
)
.then_ignore(just(Token::DoubleSemi))
.then_ignore(just(Token::Newline).repeated())
.map(|(patterns, body)| CaseBranch { patterns, body })
.labelled("case branch");
just(Token::Case)
.ignore_then(expr_parser())
.then_ignore(just(Token::In))
.then_ignore(just(Token::Newline).repeated())
.then(branch.repeated().collect::<Vec<_>>())
.then_ignore(just(Token::Esac))
.map(|(expr, branches)| CaseStmt { expr, branches })
.labelled("case statement")
.boxed()
}
fn pipeline_parser<'tokens, I, S>(
stage: S,
) -> impl Parser<'tokens, I, Pipeline, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
S: Parser<'tokens, I, PipelineStage, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
stage
.separated_by(just(Token::Pipe))
.at_least(1)
.collect::<Vec<_>>()
.then(just(Token::Amp).or_not())
.map(|(stages, bg)| Pipeline {
stages,
background: bg.is_some(),
})
.labelled("pipeline")
.boxed()
}
fn command_stage_parser<'tokens, I>(
) -> impl Parser<'tokens, I, PipelineStage, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
command_parser().map(PipelineStage::Command)
}
fn command_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Command, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let command_name = choice((
ident_parser(),
path_parser(),
select! { Token::DotSlashPath(s) => s },
just(Token::True).to("true".to_string()),
just(Token::False).to("false".to_string()),
just(Token::Colon).to(":".to_string()),
just(Token::Dot).to(".".to_string()),
));
command_name
.then(args_list_parser())
.then(redirect_parser(primary_expr_parser()).repeated().collect::<Vec<_>>())
.map(|((name, args), redirects)| Command {
name,
args,
redirects,
})
.labelled("command")
.boxed()
}
fn pipeline_into_stmt(p: Pipeline) -> Stmt {
if p.stages.len() == 1 && !p.background && p.stages[0].redirects().is_empty() {
match p.stages.into_iter().next() {
Some(PipelineStage::Compound(stmt)) => *stmt,
Some(PipelineStage::Command(cmd)) => Stmt::Command(cmd),
None => Stmt::Empty, }
} else {
Stmt::Pipeline(p)
}
}
fn command_has_ambiguous_stdin(cmd: &Command) -> bool {
cmd.redirects
.iter()
.filter(|r| {
matches!(
r.kind,
RedirectKind::Stdin | RedirectKind::HereDoc(_) | RedirectKind::HereString
)
})
.count()
> 1
}
fn first_ambiguous_stdin(stmts: &[Stmt]) -> bool {
stmts.iter().any(stmt_has_ambiguous_stdin)
}
fn stmt_has_ambiguous_stdin(stmt: &Stmt) -> bool {
match stmt {
Stmt::Command(c) => command_has_ambiguous_stdin(c),
Stmt::Pipeline(p) => p.stages.iter().any(|stage| match stage {
PipelineStage::Command(cmd) => command_has_ambiguous_stdin(cmd),
PipelineStage::Compound(inner) => stmt_has_ambiguous_stdin(inner),
}),
Stmt::If(i) => {
first_ambiguous_stdin(&i.then_branch)
|| i.else_branch
.as_deref()
.is_some_and(first_ambiguous_stdin)
}
Stmt::For(f) => first_ambiguous_stdin(&f.body),
Stmt::While(w) => first_ambiguous_stdin(&w.body),
Stmt::Case(c) => c.branches.iter().any(|b| first_ambiguous_stdin(&b.body)),
Stmt::ToolDef(t) => first_ambiguous_stdin(&t.body),
Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
stmt_has_ambiguous_stdin(left) || stmt_has_ambiguous_stdin(right)
}
Stmt::EnvScoped { body, .. } => stmt_has_ambiguous_stdin(body),
Stmt::Assignment(_)
| Stmt::Break(_)
| Stmt::Continue(_)
| Stmt::Return(_)
| Stmt::Exit(_)
| Stmt::Test(_)
| Stmt::Empty => false,
}
}
fn is_glue_candidate(arg: &Arg) -> bool {
matches!(
arg,
Arg::Positional(_) | Arg::LongFlag(_) | Arg::Named { .. } | Arg::WordAssign { .. }
)
}
fn reject_glued_args<'src>(
args: Vec<(Arg, Span)>,
) -> Result<Vec<Arg>, Rich<'src, Token, Span>> {
for pair in args.windows(2) {
let (prev, prev_span) = &pair[0];
let (next, next_span) = &pair[1];
if is_glue_candidate(prev) && is_glue_candidate(next) && prev_span.end == next_span.start {
let msg = "adjacent words with no space between them are not joined into one \
argument (kaish does no token pasting); quote the whole word, e.g. \
\"/tmp/$(echo x).txt\" or \"$dir/out.txt\"";
return Err(Rich::custom(*next_span, msg));
}
}
Ok(args.into_iter().map(|(arg, _)| arg).collect())
}
fn args_list_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Vec<Arg>, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let pre_dash = arg_before_double_dash_parser()
.map_with(|arg, e| -> (Arg, Span) { (arg, e.span()) })
.repeated()
.collect::<Vec<(Arg, Span)>>()
.try_map(|args, _span| reject_glued_args(args));
let double_dash = select! {
Token::DoubleDash => Arg::DoubleDash,
};
let post_dash_arg = choice((
post_dash_flag_value_parser(),
select! {
Token::ShortFlag(name) => Arg::Positional(Expr::Literal(Value::String(format!("-{}", name)))),
Token::LongFlag(name) => Arg::Positional(Expr::Literal(Value::String(format!("--{}", name)))),
},
word_assign_arg_parser(),
test_operator_arg_parser(),
primary_expr_parser().map(Arg::Positional),
));
let post_dash = post_dash_arg
.map_with(|arg, e| -> (Arg, Span) { (arg, e.span()) })
.repeated()
.collect::<Vec<(Arg, Span)>>()
.try_map(|args, _span| reject_glued_args(args));
pre_dash
.then(double_dash.then(post_dash).or_not())
.map(|(mut args, maybe_dd)| {
if let Some((dd, post)) = maybe_dd {
args.push(dd);
args.extend(post);
}
args
})
}
fn keyword_word<'tokens, I>(
) -> impl Parser<'tokens, I, String, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
select! {
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",
}
.map(|s| s.to_string())
}
fn post_dash_flag_value_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
select! { Token::LongFlag(name) => name }
.map_with(|s, e| -> (String, Span) { (s, e.span()) })
.then(just(Token::Eq).map_with(|_, e| -> Span { e.span() }))
.then(primary_expr_parser().map_with(|expr, e| -> (Expr, Span) { (expr, e.span()) }))
.try_map(
|(((key, key_span), eq_span), (value, value_span)): (((String, Span), Span), (Expr, Span)),
span| {
if key_span.end != eq_span.start || eq_span.end != value_span.start {
Err(Rich::custom(
span,
"a flag and its value must not have spaces around '=' \
(use '--flag=value' not '--flag = value')",
))
} else {
Ok(Arg::Named { key, value })
}
},
)
}
fn word_assign_arg_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
choice((
select! { Token::Ident(s) => s },
keyword_word(),
))
.map_with(|s, e| -> (String, Span) { (s, e.span()) })
.then(just(Token::Eq).map_with(|_, e| -> Span { e.span() }))
.then(primary_expr_parser().map_with(|expr, e| -> (Expr, Span) { (expr, e.span()) }))
.try_map(|(((key, key_span), eq_span), (value, value_span)): (((String, Span), Span), (Expr, Span)), span| {
if key_span.end != eq_span.start || eq_span.end != value_span.start {
Err(Rich::custom(
span,
"shell assignment must not have spaces around '=' (use 'key=value' not 'key = value')",
))
} else {
Ok(Arg::WordAssign { key, value })
}
})
}
fn test_operator_arg_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
select! {
Token::Eq => "=",
Token::EqEq => "==",
Token::NotEq => "!=",
Token::Bang => "!",
}
.map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string()))))
}
fn arg_before_double_dash_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let long_flag_with_value = select! {
Token::LongFlag(name) => name,
}
.then_ignore(just(Token::Eq))
.then(primary_expr_parser())
.map(|(key, value)| Arg::Named { key, value });
let long_flag = select! {
Token::LongFlag(name) => Arg::LongFlag(name),
};
let short_flag = select! {
Token::ShortFlag(name) => Arg::ShortFlag(name),
};
let named = word_assign_arg_parser();
let positional = primary_expr_parser().map(Arg::Positional);
let test_operator = test_operator_arg_parser();
choice((
long_flag_with_value,
long_flag,
short_flag,
named,
test_operator,
positional,
))
.boxed()
}
fn redirect_parser<'tokens, I, T>(
target: T,
) -> impl Parser<'tokens, I, Redirect, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
T: Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
let target = target
.clone()
.map_with(|expr, e| -> (Expr, Span) { (expr, e.span()) })
.then(target.clone().map_with(|_, e| e.span()).rewind().or_not())
.try_map(|((expr, span), glued), _| match glued {
Some(next_span) if next_span.start == span.end => Err(Rich::custom(
next_span,
"adjacent words with no space between them are not joined into the redirect \
target (kaish does no token pasting); quote the whole target, e.g. \
\"/tmp/$(echo x).txt\"",
)),
_ => Ok(expr),
})
.boxed();
let regular_redirect = select! {
Token::GtGt => RedirectKind::StdoutAppend,
Token::Gt => RedirectKind::StdoutOverwrite,
Token::Lt => RedirectKind::Stdin,
Token::Stderr => RedirectKind::Stderr,
Token::Both => RedirectKind::Both,
}
.then(target.clone())
.map(|(kind, target)| Redirect { kind, target });
let heredoc_redirect = just(Token::HereDocStart)
.ignore_then(select! { Token::HereDoc(data) => data })
.try_map(|data: HereDocData, span| {
let meta = HereDocMeta {
delimiter: data.delimiter.clone(),
literal: data.literal,
strip_tabs: data.strip_tabs,
body: data.source_body.clone(),
body_offset: data.body_start_offset,
};
let target = if data.literal {
let body = if data.strip_tabs {
crate::interpreter::strip_leading_tabs(&data.content)
} else {
data.content
};
Expr::Literal(Value::String(body))
} else {
let parts =
parse_interpolated_string_spanned(&data.content, data.body_start_offset)
.map_err(|msg| Rich::custom(span, msg))?;
if parts.len() == 1 && !data.strip_tabs {
if let StringPart::Literal(text) = &parts[0].part {
return Ok(Redirect {
kind: RedirectKind::HereDoc(meta),
target: Expr::Literal(Value::String(text.clone())),
});
}
}
Expr::HereDocBody {
parts,
strip_tabs: data.strip_tabs,
}
};
Ok(Redirect {
kind: RedirectKind::HereDoc(meta),
target,
})
});
let herestring_redirect = just(Token::HereString)
.ignore_then(target.clone())
.map(|target| Redirect {
kind: RedirectKind::HereString,
target,
});
let merge_stderr_redirect = just(Token::StderrToStdout)
.map(|_| Redirect {
kind: RedirectKind::MergeStderr,
target: Expr::Literal(Value::Null),
});
let merge_stdout_redirect = choice((
just(Token::StdoutToStderr),
just(Token::StdoutToStderr2),
))
.map(|_| Redirect {
kind: RedirectKind::MergeStdout,
target: Expr::Literal(Value::Null),
});
choice((
heredoc_redirect,
herestring_redirect,
merge_stderr_redirect,
merge_stdout_redirect,
regular_redirect,
))
.labelled("redirect")
.boxed()
}
fn test_expr_stmt_parser<'tokens, I>(
) -> impl Parser<'tokens, I, TestExpr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let file_test_op = select! {
Token::ShortFlag(s) if s == "e" => FileTestOp::Exists,
Token::ShortFlag(s) if s == "f" => FileTestOp::IsFile,
Token::ShortFlag(s) if s == "d" => FileTestOp::IsDir,
Token::ShortFlag(s) if s == "r" => FileTestOp::Readable,
Token::ShortFlag(s) if s == "w" => FileTestOp::Writable,
Token::ShortFlag(s) if s == "x" => FileTestOp::Executable,
};
let string_test_op = select! {
Token::ShortFlag(s) if s == "z" => StringTestOp::IsEmpty,
Token::ShortFlag(s) if s == "n" => StringTestOp::IsNonEmpty,
Token::ShortFlag(s) if s == "list" => StringTestOp::IsList,
Token::ShortFlag(s) if s == "record" => StringTestOp::IsRecord,
};
let cmp_op = choice((
just(Token::EqEq).to(TestCmpOp::Eq),
just(Token::Eq).to(TestCmpOp::Eq),
just(Token::NotEq).to(TestCmpOp::NotEq),
just(Token::Match).to(TestCmpOp::Match),
just(Token::NotMatch).to(TestCmpOp::NotMatch),
just(Token::Gt).to(TestCmpOp::Gt),
just(Token::Lt).to(TestCmpOp::Lt),
just(Token::GtEq).to(TestCmpOp::GtEq),
just(Token::LtEq).to(TestCmpOp::LtEq),
select! { Token::ShortFlag(s) if s == "eq" => TestCmpOp::NumEq },
select! { Token::ShortFlag(s) if s == "ne" => TestCmpOp::NumNotEq },
select! { Token::ShortFlag(s) if s == "gt" => TestCmpOp::NumGt },
select! { Token::ShortFlag(s) if s == "lt" => TestCmpOp::NumLt },
select! { Token::ShortFlag(s) if s == "ge" => TestCmpOp::NumGtEq },
select! { Token::ShortFlag(s) if s == "le" => TestCmpOp::NumLtEq },
));
let file_test = file_test_op
.then(primary_expr_parser())
.map(|(op, path)| TestExpr::FileTest {
op,
path: Box::new(path),
});
let string_test = string_test_op
.then(primary_expr_parser())
.map(|(op, value)| TestExpr::StringTest {
op,
value: Box::new(value),
});
let comparison = primary_expr_parser()
.then(cmp_op)
.then(primary_expr_parser())
.map(|((left, op), right)| TestExpr::Comparison {
left: Box::new(left),
op,
right: Box::new(right),
});
let not_in = primary_expr_parser()
.then_ignore(select! { Token::Ident(s) if s == "not" => () })
.then_ignore(just(Token::In))
.then(value_primary_parser())
.map(|(left, right)| TestExpr::NotIn {
left: Box::new(left),
right: Box::new(right),
});
let in_ = primary_expr_parser()
.then_ignore(just(Token::In))
.then(value_primary_parser())
.map(|(left, right)| TestExpr::In {
left: Box::new(left),
right: Box::new(right),
});
let primary_test = choice((file_test, string_test, not_in, in_, comparison));
let unary = recursive(|unary| {
let not_expr = just(Token::Bang)
.ignore_then(unary)
.map(|expr| TestExpr::Not { expr: Box::new(expr) });
choice((not_expr, primary_test.clone()))
});
let and_expr = unary.clone().foldl(
just(Token::And).ignore_then(unary).repeated(),
|left, right| TestExpr::And {
left: Box::new(left),
right: Box::new(right),
},
);
let compound_test = and_expr.clone().foldl(
just(Token::Or).ignore_then(and_expr).repeated(),
|left, right| TestExpr::Or {
left: Box::new(left),
right: Box::new(right),
},
);
just(Token::LBracket)
.then(just(Token::LBracket))
.ignore_then(compound_test)
.then_ignore(just(Token::RBracket).then(just(Token::RBracket)))
.labelled("test expression")
.boxed()
}
fn condition_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let test_expr_condition = test_expr_stmt_parser().map(|test| Expr::Test(Box::new(test)));
let command_condition = command_parser().map(Expr::Command);
let base = choice((test_expr_condition, command_condition));
let base = just(Token::Bang)
.repeated()
.foldr(base, |_, inner| Expr::Not(Box::new(inner)));
let and_expr = base.clone().foldl(
just(Token::And).ignore_then(base).repeated(),
|left, right| Expr::BinaryOp {
left: Box::new(left),
op: BinaryOp::And,
right: Box::new(right),
},
);
and_expr
.clone()
.foldl(
just(Token::Or).ignore_then(and_expr).repeated(),
|left, right| Expr::BinaryOp {
left: Box::new(left),
op: BinaryOp::Or,
right: Box::new(right),
},
)
.labelled("condition")
.boxed()
}
fn expr_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
primary_expr_parser()
}
fn value_expr_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
value_literal_parser()
}
fn value_primary_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
value_literal_parser()
}
fn value_literal_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
recursive(|value| {
choice((
list_literal_parser(value.clone()),
record_literal_parser(value.clone()),
primary_expr_parser(),
))
})
.boxed()
}
fn list_literal_parser<'tokens, I, V>(
value: V,
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
V: Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
let spread_elem = just(Token::DotDotDot)
.ignore_then(value.clone())
.map(ListElem::Spread);
let item_elem = value.map(ListElem::Item);
let elem = choice((spread_elem, item_elem));
let sep = choice((just(Token::Comma).to(()), just(Token::Newline).to(()))).repeated();
just(Token::LBracket)
.ignore_then(just(Token::Newline).repeated())
.ignore_then(elem.then_ignore(sep).repeated().collect::<Vec<_>>())
.then_ignore(just(Token::RBracket))
.map(Expr::ListLiteral)
.labelled("list literal")
}
fn record_literal_parser<'tokens, I, V>(
value: V,
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
V: Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
{
let bare_key = select! { Token::Ident(s) => RecordKey::Bare(s) };
let double_key = select! { Token::String(s) => s }.try_map(|s, span| {
let parts = parse_interpolated_string(&s)
.map_err(|e| Rich::custom(span, format!("record key: {e}")))?;
Ok(match parts.as_slice() {
[] => RecordKey::Quoted(String::new()),
[StringPart::Literal(lit)] => RecordKey::Quoted(lit.clone()),
_ => RecordKey::Interpolated(parts),
})
});
let single_key = select! { Token::SingleString(s) => RecordKey::Quoted(s) };
let key = choice((double_key, single_key, bare_key)).labelled("record key");
let stray_bareword_after_value = select! { Token::Ident(s) => s }
.then(just(Token::Colon).or_not())
.rewind()
.or_not()
.try_map(|maybe, span| match maybe {
Some((word, None)) => Err(Rich::custom(
span,
format!(
"record value: unexpected word \"{word}\" after the value — a multi-word \
value must be quoted, e.g. {{key: \"hello world\"}}"
),
)),
_ => Ok(()),
});
let entry = key
.then_ignore(just(Token::Colon))
.then(value)
.then_ignore(stray_bareword_after_value)
.map(|(key, value)| RecordEntry { key, value });
let sep = choice((just(Token::Comma).to(()), just(Token::Newline).to(()))).repeated();
just(Token::LBrace)
.ignore_then(just(Token::Newline).repeated())
.ignore_then(entry.then_ignore(sep).repeated().collect::<Vec<_>>())
.then_ignore(just(Token::RBrace))
.map(Expr::RecordLiteral)
.labelled("record literal")
}
fn primary_expr_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let positional = select! {
Token::Positional(n) => Expr::Positional(n),
Token::AllArgs => Expr::AllArgs,
Token::ArgCount => Expr::ArgCount,
Token::VarLength(name) => Expr::VarLength(parse_varpath(&format!("${{{name}}}"))),
Token::LastExitCode => Expr::LastExitCode,
Token::CurrentPid => Expr::CurrentPid,
};
let arithmetic = select! {
Token::Arithmetic(expr_str) => Expr::Arithmetic(expr_str),
};
let keyword_as_bareword = select! {
Token::Done => "done",
Token::Fi => "fi",
Token::Then => "then",
Token::Else => "else",
Token::Elif => "elif",
Token::In => "in",
Token::Do => "do",
Token::Esac => "esac",
Token::Set => "set",
}
.map(|s| Expr::Literal(Value::String(s.to_string())));
let plus_minus_bare = select! {
Token::PlusBare(s) => Expr::Literal(Value::String(s)),
Token::MinusBare(s) => Expr::Literal(Value::String(s)),
Token::MinusAlone => Expr::Literal(Value::String("-".to_string())),
Token::DoubleDashBare(s) => Expr::Literal(Value::String(s)),
};
let glob_pattern = select! {
Token::GlobWord(s) => Expr::GlobPattern(s),
Token::Star => Expr::GlobPattern("*".to_string()),
Token::Question => Expr::GlobPattern("?".to_string()),
};
choice((
positional,
arithmetic,
cmd_subst_parser(),
var_expr_parser(),
interpolated_string_parser(),
literal_parser().map(Expr::Literal),
glob_pattern,
ident_parser().map(|s| Expr::Literal(Value::String(s))),
path_parser().map(|s| Expr::Literal(Value::String(s))),
select! {
Token::Dot => Expr::Literal(Value::String(".".into())),
Token::DotDot => Expr::Literal(Value::String("..".into())),
Token::Comma => Expr::Literal(Value::String(",".into())),
Token::Colon => Expr::Literal(Value::String(":".into())),
Token::Tilde => Expr::Literal(Value::String("~".into())),
Token::TildePath(s) => Expr::Literal(Value::String(s)),
Token::RelativePath(s) => Expr::Literal(Value::String(s)),
Token::DotSlashPath(s) => Expr::Literal(Value::String(s)),
Token::NumberIdent(s) => Expr::Literal(Value::String(s)),
Token::DashNumWord(s) => Expr::Literal(Value::String(s)),
Token::AtWord(s) => Expr::Literal(Value::String(s)),
Token::DottedIdent(s) => Expr::Literal(Value::String(s)),
Token::JobSpec(s) => Expr::Literal(Value::String(s)),
},
plus_minus_bare,
keyword_as_bareword,
))
.labelled("expression")
.boxed()
}
fn var_expr_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
choice((
select! { Token::VarRef(raw) => raw }.try_map(|raw, span| {
let inner = raw
.strip_prefix("${")
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(&raw);
if !raw.starts_with("${?}")
&& !raw.starts_with("${$}")
&& find_default_separator(&raw).is_none()
&& let Some(msg) = bash_substring_hint(inner)
{
return Err(Rich::custom(span, msg));
}
if let Some(colon) = find_default_separator(&raw)
&& raw.len() > colon + 3
&& let Err(msg) =
parse_interpolated_string(&unquote_default_word(&raw[colon + 2..raw.len() - 1]))
{
return Err(Rich::custom(span, msg));
}
Ok(parse_var_expr(&raw))
}),
select! { Token::SimpleVarRef(name) => Expr::VarRef(VarPath::simple(name)) },
))
.labelled("variable reference")
}
type CmdSubstBody = (Vec<(Token, Span)>, Span);
fn cmd_subst_body_tokens<'tokens, I>(
) -> impl Parser<'tokens, I, CmdSubstBody, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
custom(|inp| {
let mut tracker = CmdSubstFrames::default();
let mut body: Vec<(Token, Span)> = Vec::new();
loop {
let before = inp.cursor();
match inp.next() {
None => {
let span = inp.span_since(&before);
return Err(Rich::custom(
span,
"unterminated command substitution: missing `)`",
));
}
Some(tok) => {
let span = inp.span_since(&before);
let next = inp.peek();
if tracker.step(&tok, next.as_ref()) {
return Ok((body, span));
}
body.push((tok, span));
}
}
}
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CmdSubstFrame {
Subst,
Paren,
Case { awaiting_pattern: bool },
}
#[derive(Default)]
struct CmdSubstFrames(Vec<CmdSubstFrame>);
impl CmdSubstFrames {
fn step(&mut self, tok: &Token, next: Option<&Token>) -> bool {
match tok {
Token::RParen => match self.0.last_mut() {
None => return true,
Some(CmdSubstFrame::Case { awaiting_pattern }) => {
*awaiting_pattern = false;
}
Some(CmdSubstFrame::Paren) => {
self.0.pop();
if let Some(CmdSubstFrame::Case { awaiting_pattern }) = self.0.last_mut() {
*awaiting_pattern = false;
}
}
Some(CmdSubstFrame::Subst) => {
self.0.pop();
}
},
Token::LParen => self.0.push(CmdSubstFrame::Paren),
Token::CmdSubstStart => self.0.push(CmdSubstFrame::Subst),
Token::Case if !matches!(next, Some(Token::Eq)) => {
self.0.push(CmdSubstFrame::Case { awaiting_pattern: true });
}
Token::Case => {}
Token::DoubleSemi => {
if let Some(CmdSubstFrame::Case { awaiting_pattern }) = self.0.last_mut() {
*awaiting_pattern = true;
}
}
Token::Esac
if matches!(
self.0.last(),
Some(CmdSubstFrame::Case { awaiting_pattern: true })
) =>
{
self.0.pop();
}
_ => {}
}
false
}
}
fn find_cmd_subst_close(tokens: &[(Token, Span)]) -> Option<usize> {
let mut tracker = CmdSubstFrames::default();
(0..tokens.len()).find(|&i| {
let next = tokens.get(i + 1).map(|(t, _)| t);
tracker.step(&tokens[i].0, next)
})
}
fn validate_cmd_subst_bodies(tokens: &[(Token, Span)]) -> Result<(), Vec<ParseError>> {
let mut i = 0;
while i < tokens.len() {
if !matches!(tokens[i].0, Token::CmdSubstStart) {
i += 1;
continue;
}
let start_span = tokens[i].1;
let rest = &tokens[i + 1..];
let Some(close_rel) = find_cmd_subst_close(rest) else {
return Err(vec![ParseError {
span: start_span,
message: "unterminated command substitution: missing `)`".to_string(),
}]);
};
let body = &rest[..close_rel];
let rparen_span = rest[close_rel].1;
let end_span: Span = (rparen_span.start..rparen_span.start).into();
parse_tokens(body.to_vec(), end_span, start_span)?;
i += 1 + close_rel + 1;
}
Ok(())
}
fn validate_interpolated_strings(tokens: &[(Token, Span)]) -> Result<(), Vec<ParseError>> {
for (tok, span) in tokens {
let owned;
let body = match tok {
Token::String(s) => Some(s.as_str()),
Token::VarRef(raw) => match find_default_separator(raw) {
Some(colon) if raw.len() > colon + 3 => {
owned = unquote_default_word(&raw[colon + 2..raw.len() - 1]);
Some(owned.as_str())
}
_ => None,
},
_ => None,
};
if let Some(body) = body
&& let Err(message) = parse_interpolated_string(body)
{
return Err(vec![ParseError { span: *span, message }]);
}
}
Ok(())
}
fn validate_heredoc_bodies(tokens: &[(Token, Span)]) -> Result<(), Vec<ParseError>> {
for (tok, span) in tokens {
if let Token::HereDoc(d) = tok
&& !d.literal
&& let Err(message) = parse_interpolated_string_spanned(&d.content, 0)
{
return Err(vec![ParseError { span: *span, message }]);
}
}
Ok(())
}
fn cmd_subst_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
just(Token::CmdSubstStart)
.ignore_then(cmd_subst_body_tokens())
.try_map(|(body_tokens, rparen_span), outer_span| {
let end_span: Span = (rparen_span.start..rparen_span.start).into();
parse_tokens(body_tokens, end_span, outer_span)
.map(|program| Expr::CommandSubst(program.statements))
.map_err(|errs| {
let first = errs.into_iter().next().unwrap_or_else(|| ParseError {
span: outer_span,
message: "command substitution failed to parse".to_string(),
});
Rich::custom(first.span, first.message)
})
})
.labelled("command substitution")
}
fn interpolated_string_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
let double_quoted = select! {
Token::String(s) => s,
}
.try_map(|s, span| {
if s.contains('$') || s.contains("__KAISH_ESCAPED_DOLLAR__") {
let parts = parse_interpolated_string(&s)
.map_err(|msg| Rich::custom(span, msg))?;
if parts.len() == 1
&& let StringPart::Literal(text) = &parts[0] {
return Ok(Expr::Literal(Value::String(text.clone())));
}
Ok(Expr::Interpolated(parts))
} else {
Ok(Expr::Literal(Value::String(s)))
}
});
let single_quoted = select! {
Token::SingleString(s) => Expr::Literal(Value::String(s)),
};
choice((single_quoted, double_quoted)).labelled("string")
}
fn literal_parser<'tokens, I>(
) -> impl Parser<'tokens, I, Value, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
choice((
select! {
Token::True => Value::Bool(true),
Token::False => Value::Bool(false),
},
select! {
Token::Int(n) => Value::Int(n),
Token::Float(f) => Value::Float(f),
},
))
.labelled("literal")
.boxed()
}
fn ident_parser<'tokens, I>(
) -> impl Parser<'tokens, I, String, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
select! {
Token::Ident(s) => s,
}
.labelled("identifier")
}
fn path_parser<'tokens, I>(
) -> impl Parser<'tokens, I, String, extra::Err<Rich<'tokens, Token, Span>>> + Clone
where
I: ValueInput<'tokens, Token = Token, Span = Span>,
{
select! {
Token::Path(s) => s,
}
.labelled("path")
}
#[cfg(test)]
#[allow(clippy::approx_constant)]
mod tests {
use super::*;
use proptest::strategy::Strategy;
fn pipeline_commands(p: &Pipeline) -> Vec<&Command> {
p.stages
.iter()
.map(|stage| stage.as_command().expect("expected a command stage"))
.collect()
}
fn subst_cmd(expr: &Expr) -> &Command {
match expr {
Expr::CommandSubst(stmts) => match stmts.as_slice() {
[Stmt::Command(cmd)] => cmd,
other => panic!("expected a single command in $(), got {other:?}"),
},
other => panic!("expected command subst, got {other:?}"),
}
}
fn subst_pipeline(expr: &Expr) -> &Pipeline {
match expr {
Expr::CommandSubst(stmts) => match stmts.as_slice() {
[Stmt::Pipeline(p)] => p,
other => panic!("expected a single pipeline in $(), got {other:?}"),
},
other => panic!("expected command subst, got {other:?}"),
}
}
#[test]
fn parse_empty() {
let result = parse("");
assert!(result.is_ok());
assert_eq!(result.expect("ok").statements.len(), 0);
}
#[test]
fn parse_newlines_only() {
let result = parse("\n\n\n");
assert!(result.is_ok());
}
#[test]
fn parse_simple_command() {
let result = parse("echo");
assert!(result.is_ok());
let program = result.expect("ok");
assert_eq!(program.statements.len(), 1);
assert!(matches!(&program.statements[0], Stmt::Command(_)));
}
#[test]
fn parse_command_with_string_arg() {
let result = parse(r#"echo "hello""#);
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => assert_eq!(cmd.args.len(), 1),
_ => panic!("expected Command"),
}
}
#[test]
fn parse_assignment() {
let result = parse("X=5");
assert!(result.is_ok());
let program = result.expect("ok");
assert!(matches!(&program.statements[0], Stmt::Assignment(_)));
}
#[test]
fn parse_pipeline() {
let result = parse("a | b | c");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Pipeline(p) => assert_eq!(pipeline_commands(p).len(), 3),
_ => panic!("expected Pipeline"),
}
}
#[test]
fn parse_background_job() {
let result = parse("cmd &");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Pipeline(p) => assert!(p.background),
_ => panic!("expected Pipeline with background"),
}
}
#[test]
fn parse_if_simple() {
let result = parse("if true; then echo; fi");
assert!(result.is_ok());
let program = result.expect("ok");
assert!(matches!(&program.statements[0], Stmt::If(_)));
}
#[test]
fn parse_if_else() {
let result = parse("if true; then echo; else echo; fi");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::If(if_stmt) => assert!(if_stmt.else_branch.is_some()),
_ => panic!("expected If"),
}
}
#[test]
fn parse_elif_simple() {
let result = parse("if true; then echo a; elif false; then echo b; fi");
assert!(result.is_ok(), "parse failed: {:?}", result);
let program = result.expect("ok");
match &program.statements[0] {
Stmt::If(if_stmt) => {
assert!(if_stmt.else_branch.is_some());
let else_branch = if_stmt.else_branch.as_ref().unwrap();
assert_eq!(else_branch.len(), 1);
assert!(matches!(&else_branch[0], Stmt::If(_)));
}
_ => panic!("expected If"),
}
}
#[test]
fn parse_elif_with_else() {
let result = parse("if true; then echo a; elif false; then echo b; else echo c; fi");
assert!(result.is_ok(), "parse failed: {:?}", result);
let program = result.expect("ok");
match &program.statements[0] {
Stmt::If(outer_if) => {
let else_branch = outer_if.else_branch.as_ref().expect("outer else");
assert_eq!(else_branch.len(), 1);
match &else_branch[0] {
Stmt::If(inner_if) => {
assert!(inner_if.else_branch.is_some());
}
_ => panic!("expected nested If from elif"),
}
}
_ => panic!("expected If"),
}
}
#[test]
fn parse_multiple_elif() {
let result = parse(
"if [[ ${X} == 1 ]]; then echo one; elif [[ ${X} == 2 ]]; then echo two; elif [[ ${X} == 3 ]]; then echo three; else echo other; fi",
);
assert!(result.is_ok(), "parse failed: {:?}", result);
}
#[test]
fn parse_for_loop() {
let result = parse("for X in items; do echo; done");
assert!(result.is_ok());
let program = result.expect("ok");
assert!(matches!(&program.statements[0], Stmt::For(_)));
}
#[test]
fn parse_brackets_not_array_literal() {
let result = parse("cmd [1");
let _ = result;
}
#[test]
fn parse_named_arg() {
let result = parse("cmd foo=5");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.args.len(), 1);
assert!(matches!(&cmd.args[0], Arg::WordAssign { .. }));
}
_ => panic!("expected Command"),
}
}
#[test]
fn parse_short_flag() {
let result = parse("ls -l");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "ls");
assert_eq!(cmd.args.len(), 1);
match &cmd.args[0] {
Arg::ShortFlag(name) => assert_eq!(name, "l"),
_ => panic!("expected ShortFlag"),
}
}
_ => panic!("expected Command"),
}
}
#[test]
fn parse_long_flag() {
let result = parse("git push --force");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "git");
assert_eq!(cmd.args.len(), 2);
match &cmd.args[0] {
Arg::Positional(Expr::Literal(Value::String(s))) => assert_eq!(s, "push"),
_ => panic!("expected Positional push"),
}
match &cmd.args[1] {
Arg::LongFlag(name) => assert_eq!(name, "force"),
_ => panic!("expected LongFlag"),
}
}
_ => panic!("expected Command"),
}
}
#[test]
fn parse_long_flag_with_value() {
let result = parse(r#"git commit --message="hello""#);
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "git");
assert_eq!(cmd.args.len(), 2);
match &cmd.args[1] {
Arg::Named { key, value } => {
assert_eq!(key, "message");
match value {
Expr::Literal(Value::String(s)) => assert_eq!(s, "hello"),
_ => panic!("expected String value"),
}
}
_ => panic!("expected Named from --flag=value"),
}
}
_ => panic!("expected Command"),
}
}
#[test]
fn parse_mixed_flags_and_args() {
let result = parse(r#"git commit -m "message" --amend"#);
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "git");
assert_eq!(cmd.args.len(), 4);
assert!(matches!(&cmd.args[0], Arg::Positional(_)));
match &cmd.args[1] {
Arg::ShortFlag(name) => assert_eq!(name, "m"),
_ => panic!("expected ShortFlag -m"),
}
assert!(matches!(&cmd.args[2], Arg::Positional(_)));
match &cmd.args[3] {
Arg::LongFlag(name) => assert_eq!(name, "amend"),
_ => panic!("expected LongFlag --amend"),
}
}
_ => panic!("expected Command"),
}
}
#[test]
fn parse_redirect_stdout() {
let result = parse("cmd > file");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Pipeline(p) => {
assert_eq!(pipeline_commands(p).len(), 1);
let cmd = pipeline_commands(p)[0];
assert_eq!(cmd.redirects.len(), 1);
assert!(matches!(cmd.redirects[0].kind, RedirectKind::StdoutOverwrite));
}
_ => panic!("expected Pipeline"),
}
}
#[test]
fn parse_var_ref() {
let result = parse("echo ${VAR}");
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.args.len(), 1);
assert!(matches!(&cmd.args[0], Arg::Positional(Expr::VarRef(_))));
}
_ => panic!("expected Command"),
}
}
#[test]
fn parse_multiple_statements() {
let result = parse("a\nb\nc");
assert!(result.is_ok());
let program = result.expect("ok");
let non_empty: Vec<_> = program.statements.iter().filter(|s| !matches!(s, Stmt::Empty)).collect();
assert_eq!(non_empty.len(), 3);
}
#[test]
fn parse_semicolon_separated() {
let result = parse("a; b; c");
assert!(result.is_ok());
let program = result.expect("ok");
let non_empty: Vec<_> = program.statements.iter().filter(|s| !matches!(s, Stmt::Empty)).collect();
assert_eq!(non_empty.len(), 3);
}
#[test]
fn parse_complex_pipeline() {
let result = parse(r#"cat file | grep pattern="foo" | head count=10"#);
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Pipeline(p) => assert_eq!(pipeline_commands(p).len(), 3),
_ => panic!("expected Pipeline"),
}
}
#[test]
fn parse_json_as_string_arg() {
let result = parse(r#"cmd '[[1, 2], [3, 4]]'"#);
assert!(result.is_ok());
}
#[test]
fn parse_mixed_args() {
let result = parse(r#"cmd pos1 key="val" pos2 num=42"#);
assert!(result.is_ok());
let program = result.expect("ok");
match &program.statements[0] {
Stmt::Command(cmd) => assert_eq!(cmd.args.len(), 4),
_ => panic!("expected Command"),
}
}
#[test]
fn error_unterminated_string() {
let result = parse(r#"echo "hello"#);
assert!(result.is_err());
}
#[test]
fn error_unterminated_var_ref() {
let result = parse("echo ${VAR");
assert!(result.is_err());
}
#[test]
fn error_missing_fi() {
let result = parse("if true; then echo");
assert!(result.is_err());
}
#[test]
fn error_missing_done() {
let result = parse("for X in items; do echo");
assert!(result.is_err());
}
#[test]
fn parse_lvalue_single_index() {
let result = parse("xs[0]=9").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
assert_eq!(a.name(), "xs");
assert_eq!(
a.path.segments,
vec![VarSegment::Field("xs".into()), VarSegment::Index(0)]
);
assert!(!a.local);
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_lvalue_negative_index() {
let result = parse("xs[-1]=7").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => assert_eq!(
a.path.segments,
vec![VarSegment::Field("xs".into()), VarSegment::Index(-1)]
),
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_lvalue_bareword_key() {
let result = parse("user[email]=x").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => assert_eq!(
a.path.segments,
vec![
VarSegment::Field("user".into()),
VarSegment::Key("email".into())
]
),
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_lvalue_chained_keys() {
let result = parse("s[web][port]=9000").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => assert_eq!(
a.path.segments,
vec![
VarSegment::Field("s".into()),
VarSegment::Key("web".into()),
VarSegment::Key("port".into())
]
),
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_lvalue_dynamic_key() {
let result = parse("r[$k]=v").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => assert_eq!(
a.path.segments,
vec![
VarSegment::Field("r".into()),
VarSegment::Dynamic("k".into())
]
),
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_local_lvalue_spaced() {
let result = parse("local xs[0] = 9").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
assert!(a.local);
assert_eq!(
a.path.segments,
vec![VarSegment::Field("xs".into()), VarSegment::Index(0)]
);
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn env_prefix_subscripted_target_is_not_captured_as_env_scoped() {
let result = parse("user={}\nuser[email]=x echo hi").unwrap();
for stmt in &result.statements {
assert!(
!matches!(stmt, Stmt::EnvScoped { .. }),
"a subscripted assignment must never be captured into EnvScoped: {stmt:?}"
);
}
assert!(matches!(&result.statements[1], Stmt::Assignment(a) if a.name() == "user"));
assert!(matches!(&result.statements[2], Stmt::Command(c) if c.name == "echo"));
}
#[test]
fn parse_nested_cmd_subst() {
let result = parse("X=$(echo $(date))").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
assert_eq!(a.name(), "X");
let outer = subst_cmd(&a.value);
assert_eq!(outer.name, "echo");
match &outer.args[0] {
Arg::Positional(inner_expr) => {
assert_eq!(subst_cmd(inner_expr).name, "date");
}
other => panic!("expected nested cmd subst arg, got {:?}", other),
}
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_deeply_nested_cmd_subst() {
let result = parse("X=$(a $(b $(c)))").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
let level1 = subst_cmd(&a.value);
assert_eq!(level1.name, "a");
match &level1.args[0] {
Arg::Positional(level2_expr) => {
let level2 = subst_cmd(level2_expr);
assert_eq!(level2.name, "b");
match &level2.args[0] {
Arg::Positional(level3_expr) => {
assert_eq!(subst_cmd(level3_expr).name, "c");
}
other => panic!("expected level3 cmd subst, got {:?}", other),
}
}
other => panic!("expected level2 cmd subst, got {:?}", other),
}
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn value_int_preserved() {
let result = parse("X=42").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
assert_eq!(a.name(), "X");
match &a.value {
Expr::Literal(Value::Int(n)) => assert_eq!(*n, 42),
other => panic!("expected int literal, got {:?}", other),
}
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn value_negative_int_preserved() {
let result = parse("X=-99").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::Literal(Value::Int(n)) => assert_eq!(*n, -99),
other => panic!("expected int, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn value_float_preserved() {
let result = parse("PI=3.14").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::Literal(Value::Float(f)) => assert!((*f - 3.14).abs() < 0.001),
other => panic!("expected float, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn value_string_preserved() {
let result = parse(r#"echo "hello world""#).unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "echo");
match &cmd.args[0] {
Arg::Positional(Expr::Literal(Value::String(s))) => {
assert_eq!(s, "hello world");
}
other => panic!("expected string arg, got {:?}", other),
}
}
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn value_string_with_escapes_preserved() {
let result = parse(r#"echo "line1\nline2""#).unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => match &cmd.args[0] {
Arg::Positional(Expr::Literal(Value::String(s))) => {
assert_eq!(s, "line1\nline2");
}
other => panic!("expected string, got {:?}", other),
},
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn value_command_name_preserved() {
let result = parse("my-command").unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => assert_eq!(cmd.name, "my-command"),
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn value_assignment_name_preserved() {
let result = parse("MY_VAR=1").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => assert_eq!(a.name(), "MY_VAR"),
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn value_for_variable_preserved() {
let result = parse("for ITEM in items; do echo; done").unwrap();
match &result.statements[0] {
Stmt::For(f) => assert_eq!(f.variable, "ITEM"),
other => panic!("expected for, got {:?}", other),
}
}
#[test]
fn value_varref_name_preserved() {
let result = parse("echo ${MESSAGE}").unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => match &cmd.args[0] {
Arg::Positional(Expr::VarRef(path)) => {
assert_eq!(path.segments.len(), 1);
let VarSegment::Field(name) = &path.segments[0] else {
panic!("expected root field, got {:?}", path.segments[0]);
};
assert_eq!(name, "MESSAGE");
}
other => panic!("expected varref, got {:?}", other),
},
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn value_varref_field_access_preserved() {
let result = parse("echo ${RESULT.data}").unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => match &cmd.args[0] {
Arg::Positional(Expr::VarRef(path)) => {
assert_eq!(path.segments.len(), 2);
let VarSegment::Field(a) = &path.segments[0] else {
panic!("expected field, got {:?}", path.segments[0]);
};
let VarSegment::Field(b) = &path.segments[1] else {
panic!("expected field, got {:?}", path.segments[1]);
};
assert_eq!(a, "RESULT");
assert_eq!(b, "data");
}
other => panic!("expected varref, got {:?}", other),
},
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn value_varref_index_parsed() {
let result = parse("echo ${ITEMS[0]}").unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => match &cmd.args[0] {
Arg::Positional(Expr::VarRef(path)) => {
assert_eq!(path.segments.len(), 2);
let VarSegment::Field(name) = &path.segments[0] else {
panic!("expected root field, got {:?}", path.segments[0]);
};
assert_eq!(name, "ITEMS");
assert_eq!(path.segments[1], VarSegment::Index(0));
}
other => panic!("expected varref, got {:?}", other),
},
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn value_named_arg_preserved() {
let result = parse("cmd count=42").unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "cmd");
match &cmd.args[0] {
Arg::WordAssign { key, value } => {
assert_eq!(key, "count");
match value {
Expr::Literal(Value::Int(n)) => assert_eq!(*n, 42),
other => panic!("expected int, got {:?}", other),
}
}
other => panic!("expected WordAssign arg, got {:?}", other),
}
}
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn value_function_def_name_preserved() {
let result = parse("greet() { echo }").unwrap();
match &result.statements[0] {
Stmt::ToolDef(t) => {
assert_eq!(t.name, "greet");
assert!(t.params.is_empty());
}
other => panic!("expected function def, got {:?}", other),
}
}
#[test]
fn parse_comparison_equals() {
let result = parse("if [[ ${X} == 5 ]]; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { left, op, right } => {
assert!(matches!(left.as_ref(), Expr::VarRef(_)));
assert_eq!(*op, TestCmpOp::Eq);
match right.as_ref() {
Expr::Literal(Value::Int(n)) => assert_eq!(*n, 5),
other => panic!("expected int, got {:?}", other),
}
}
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_comparison_not_equals() {
let result = parse("if [[ ${X} != 0 ]]; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NotEq),
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_comparison_less_than() {
let result = parse("if [[ ${COUNT} -lt 10 ]]; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumLt),
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_comparison_greater_than() {
let result = parse("if [[ ${COUNT} -gt 0 ]]; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumGt),
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_comparison_less_equal() {
let result = parse("if [[ ${X} -le 100 ]]; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumLtEq),
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_comparison_greater_equal() {
let result = parse("if [[ ${X} -ge 1 ]]; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumGtEq),
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_regex_match() {
let result = parse(r#"if [[ ${NAME} =~ "^test" ]]; then echo; fi"#).unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::Match),
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_regex_not_match() {
let result = parse(r#"if [[ ${NAME} !~ "^test" ]]; then echo; fi"#).unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NotMatch),
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_string_interpolation() {
let result = parse(r#"echo "Hello ${NAME}!""#).unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => match &cmd.args[0] {
Arg::Positional(Expr::Interpolated(parts)) => {
assert_eq!(parts.len(), 3);
match &parts[0] {
StringPart::Literal(s) => assert_eq!(s, "Hello "),
other => panic!("expected literal, got {:?}", other),
}
match &parts[1] {
StringPart::Var(path) => {
assert_eq!(path.segments.len(), 1);
let VarSegment::Field(name) = &path.segments[0] else {
panic!("expected root field, got {:?}", path.segments[0]);
};
assert_eq!(name, "NAME");
}
other => panic!("expected var, got {:?}", other),
}
match &parts[2] {
StringPart::Literal(s) => assert_eq!(s, "!"),
other => panic!("expected literal, got {:?}", other),
}
}
other => panic!("expected interpolated, got {:?}", other),
},
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn parse_string_interpolation_multiple_vars() {
let result = parse(r#"echo "${FIRST} and ${SECOND}""#).unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => match &cmd.args[0] {
Arg::Positional(Expr::Interpolated(parts)) => {
assert_eq!(parts.len(), 3);
assert!(matches!(&parts[0], StringPart::Var(_)));
assert!(matches!(&parts[1], StringPart::Literal(_)));
assert!(matches!(&parts[2], StringPart::Var(_)));
}
other => panic!("expected interpolated, got {:?}", other),
},
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn parse_empty_function_body() {
let result = parse("empty() { }").unwrap();
match &result.statements[0] {
Stmt::ToolDef(t) => {
assert_eq!(t.name, "empty");
assert!(t.params.is_empty());
assert!(t.body.is_empty());
}
other => panic!("expected function def, got {:?}", other),
}
}
#[test]
fn parse_bash_style_function() {
let result = parse("function greet { echo hello }").unwrap();
match &result.statements[0] {
Stmt::ToolDef(t) => {
assert_eq!(t.name, "greet");
assert!(t.params.is_empty());
assert_eq!(t.body.len(), 1);
}
other => panic!("expected function def, got {:?}", other),
}
}
#[test]
fn parse_comparison_string_values() {
let result = parse(r#"if [[ ${STATUS} == "ok" ]]; then echo; fi"#).unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { left, op, right } => {
assert!(matches!(left.as_ref(), Expr::VarRef(_)));
assert_eq!(*op, TestCmpOp::Eq);
match right.as_ref() {
Expr::Literal(Value::String(s)) => assert_eq!(s, "ok"),
other => panic!("expected string, got {:?}", other),
}
}
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test expr, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_simple() {
let result = parse("X=$(echo)").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
assert_eq!(a.name(), "X");
assert_eq!(subst_cmd(&a.value).name, "echo");
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_with_args() {
let result = parse(r#"X=$(fetch url="http://example.com")"#).unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
let cmd = subst_cmd(&a.value);
assert_eq!(cmd.name, "fetch");
assert_eq!(cmd.args.len(), 1);
match &cmd.args[0] {
Arg::WordAssign { key, .. } => assert_eq!(key, "url"),
other => panic!("expected WordAssign arg, got {:?}", other),
}
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_pipeline() {
let result = parse("X=$(cat file | grep pattern)").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
let pipeline = subst_pipeline(&a.value);
assert_eq!(pipeline_commands(pipeline).len(), 2);
assert_eq!(pipeline_commands(pipeline)[0].name, "cat");
assert_eq!(pipeline_commands(pipeline)[1].name, "grep");
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_with_redirect() {
let result = parse("X=$(echo hi > out.txt)").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
let pipeline = subst_pipeline(&a.value);
assert_eq!(pipeline_commands(pipeline).len(), 1);
let cmd = pipeline_commands(pipeline)[0];
assert_eq!(cmd.name, "echo");
assert_eq!(cmd.redirects.len(), 1);
assert!(matches!(
cmd.redirects[0].kind,
RedirectKind::StdoutOverwrite
));
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_redirect_target_with_nested_subst() {
let result = parse("X=$(echo hi > $(echo f))").unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => {
let pipeline = subst_pipeline(&a.value);
assert_eq!(pipeline_commands(pipeline).len(), 1);
let cmd = pipeline_commands(pipeline)[0];
assert_eq!(cmd.name, "echo");
assert_eq!(cmd.redirects.len(), 1);
assert!(
matches!(cmd.redirects[0].target, Expr::CommandSubst(_)),
"redirect target should be a nested command substitution, got {:?}",
cmd.redirects[0].target
);
}
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_chain_with_redirect() {
let result = parse("X=$(echo a && echo b > out.txt)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
match stmts.as_slice() {
[Stmt::AndChain { left, right }] => {
assert!(
matches!(**left, Stmt::Command(_)),
"left of && should be a bare command, got {:?}",
left
);
match &**right {
Stmt::Pipeline(p) => {
assert_eq!(pipeline_commands(p).len(), 1);
assert_eq!(pipeline_commands(p)[0].name, "echo");
assert_eq!(pipeline_commands(p)[0].redirects.len(), 1);
}
other => panic!("right should be a redirect-bearing pipeline, got {:?}", other),
}
}
other => panic!("expected a single AndChain, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_in_condition() {
let result = parse("if kaish-validate; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::Command(cmd) => {
assert_eq!(cmd.name, "kaish-validate");
}
other => panic!("expected command, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_unquoted_for_loop() {
let result = parse("X=$(for f in a b; do echo $f; done)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
match stmts.as_slice() {
[Stmt::For(f)] => {
assert_eq!(f.variable, "f");
assert_eq!(f.items.len(), 2);
assert!(matches!(f.body.as_slice(), [Stmt::Command(c)] if c.name == "echo"));
}
other => panic!("expected a single For statement, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_unquoted_while_loop() {
let result = parse("X=$(while false; do echo x; done)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
assert!(
matches!(stmts.as_slice(), [Stmt::While(w)] if matches!(w.body.as_slice(), [Stmt::Command(c)] if c.name == "echo")),
"expected a single While statement, got {stmts:?}"
);
}
#[test]
fn parse_cmd_subst_unquoted_if_else() {
let result = parse("X=$(if true; then echo one; else echo two; fi)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
match stmts.as_slice() {
[Stmt::If(i)] => {
assert!(i.else_branch.is_some(), "expected an else branch");
assert!(matches!(i.then_branch.as_slice(), [Stmt::Command(c)] if c.name == "echo"));
}
other => panic!("expected a single If statement, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_unquoted_case() {
let result = parse("X=$(case a in a) echo hit;; esac)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
match stmts.as_slice() {
[Stmt::Case(c)] => {
assert_eq!(c.branches.len(), 1);
assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
}
other => panic!("expected a single Case statement, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_unquoted_case_with_parenthesized_pattern() {
let result = parse("X=$(case a in (a) echo hit;; esac)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
assert!(
matches!(stmts.as_slice(), [Stmt::Case(c)] if c.branches.len() == 1),
"expected a single Case statement, got {stmts:?}"
);
}
#[test]
fn parse_cmd_subst_unquoted_case_parenthesized_pattern_with_bareword_esac_in_body() {
let result = parse("X=$(case a in (a) y=esac;; b) echo two;; esac)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
match stmts.as_slice() {
[Stmt::Case(c)] => {
assert_eq!(c.branches.len(), 2);
assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
assert_eq!(c.branches[1].patterns, vec!["b".to_string()]);
}
other => panic!("expected a single Case statement with two branches, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_unquoted_nested_case_parenthesized_pattern_esac_in_outer_body() {
let result = parse(
"X=$(case a in (a) case b in (b) echo z;; esac; y=esac;; c) echo two;; esac)",
)
.unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
match stmts.as_slice() {
[Stmt::Case(c)] => {
assert_eq!(c.branches.len(), 2);
assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
assert_eq!(c.branches[1].patterns, vec!["c".to_string()]);
}
other => panic!("expected a single Case statement with two branches, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_unquoted_case_eq_argv_key() {
let result = parse("X=$(echo case=x)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let cmd = match stmts.as_slice() {
[Stmt::Command(c)] => c,
other => panic!("expected a single echo command, got {:?}", other),
};
assert_eq!(cmd.name, "echo");
match &cmd.args[0] {
Arg::WordAssign { key, value } => {
assert_eq!(key, "case");
match value {
Expr::Literal(Value::String(s)) => assert_eq!(s, "x"),
other => panic!("expected string \"x\", got {:?}", other),
}
}
other => panic!("expected WordAssign arg, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_unquoted_case_eq_argv_key_with_sibling_keyword_keys() {
let result = parse("X=$(tool case=x do=y)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let cmd = match stmts.as_slice() {
[Stmt::Command(c)] => c,
other => panic!("expected a single tool command, got {:?}", other),
};
assert_eq!(cmd.name, "tool");
assert_eq!(cmd.args.len(), 2);
assert!(matches!(&cmd.args[0], Arg::WordAssign { key, .. } if key == "case"));
assert!(matches!(&cmd.args[1], Arg::WordAssign { key, .. } if key == "do"));
}
#[test]
fn parse_cmd_subst_unquoted_case_inside_nested_subst() {
let result = parse("X=$(echo $(case b in b) echo x;; esac))").unwrap();
let outer_stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let outer_cmd = match outer_stmts.as_slice() {
[Stmt::Command(c)] => c,
other => panic!("expected a single echo command, got {:?}", other),
};
assert_eq!(outer_cmd.name, "echo");
let inner_stmts = match &outer_cmd.args[0] {
Arg::Positional(Expr::CommandSubst(s)) => s,
other => panic!("expected nested command subst arg, got {:?}", other),
};
assert!(
matches!(inner_stmts.as_slice(), [Stmt::Case(c)] if c.branches.len() == 1),
"expected a single Case statement inside the inner $(), got {inner_stmts:?}"
);
}
#[test]
fn parse_cmd_subst_unquoted_esac_as_bareword() {
let result = parse("X=$(echo esac)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let cmd = match stmts.as_slice() {
[Stmt::Command(c)] => c,
other => panic!("expected a single echo command, got {:?}", other),
};
assert_eq!(cmd.name, "echo");
assert!(
matches!(&cmd.args[0], Arg::Positional(Expr::Literal(Value::String(s))) if s == "esac"),
"expected \"esac\" as a literal argument, got {:?}",
cmd.args[0]
);
}
#[test]
fn parse_cmd_subst_unquoted_esac_as_bareword_inside_still_open_case() {
let result = parse("X=$(case a in a) y=esac;; b) echo two;; esac)").unwrap();
let stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
match stmts.as_slice() {
[Stmt::Case(c)] => {
assert_eq!(c.branches.len(), 2);
assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
assert_eq!(c.branches[1].patterns, vec!["b".to_string()]);
}
other => panic!("expected a single Case statement with two branches, got {:?}", other),
}
}
#[test]
fn parse_quoted_cmd_subst_case_pattern_paren_not_miscounted() {
let result = parse(r#"X="pre $(case v in v) echo x;; esac) post""#).unwrap();
let parts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::Interpolated(parts) => parts,
other => panic!("expected an interpolated string, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let stmts = match parts.as_slice() {
[StringPart::Literal(pre), StringPart::CommandSubst(stmts), StringPart::Literal(post)] =>
{
assert_eq!(pre, "pre ");
assert_eq!(post, " post");
stmts
}
other => panic!("expected [literal, command subst, literal], got {:?}", other),
};
assert!(
matches!(stmts.as_slice(), [Stmt::Case(c)] if c.branches.len() == 1),
"expected a single Case statement inside the quoted $(...), got {stmts:?}"
);
}
#[test]
fn parse_quoted_cmd_subst_case_parenthesized_pattern_esac_in_body_not_miscounted() {
let result = parse(r#"X="pre $(case a in (a) y=esac;; b) echo two;; esac) post""#).unwrap();
let parts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::Interpolated(parts) => parts,
other => panic!("expected an interpolated string, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let stmts = match parts.as_slice() {
[StringPart::Literal(pre), StringPart::CommandSubst(stmts), StringPart::Literal(post)] =>
{
assert_eq!(pre, "pre ");
assert_eq!(post, " post");
stmts
}
other => panic!("expected [literal, command subst, literal], got {:?}", other),
};
match stmts.as_slice() {
[Stmt::Case(c)] => {
assert_eq!(c.branches.len(), 2);
assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
assert_eq!(c.branches[1].patterns, vec!["b".to_string()]);
}
other => panic!("expected a single Case statement with two branches, got {:?}", other),
}
}
#[test]
fn parse_quoted_cmd_subst_case_eq_argv_key_not_miscounted() {
let result = parse(r#"X="pre $(echo case=x) post""#).unwrap();
let parts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::Interpolated(parts) => parts,
other => panic!("expected an interpolated string, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let stmts = match parts.as_slice() {
[StringPart::Literal(pre), StringPart::CommandSubst(stmts), StringPart::Literal(post)] =>
{
assert_eq!(pre, "pre ");
assert_eq!(post, " post");
stmts
}
other => panic!("expected [literal, command subst, literal], got {:?}", other),
};
let cmd = match stmts.as_slice() {
[Stmt::Command(c)] => c,
other => panic!("expected a single echo command, got {:?}", other),
};
assert_eq!(cmd.name, "echo");
assert!(matches!(&cmd.args[0], Arg::WordAssign { key, .. } if key == "case"));
}
#[test]
fn parse_cmd_subst_unquoted_nested_with_control_structure() {
let result = parse("X=$(echo $(for f in a; do echo $f; done))").unwrap();
let outer_stmts = match &result.statements[0] {
Stmt::Assignment(a) => match &a.value {
Expr::CommandSubst(s) => s,
other => panic!("expected command subst, got {:?}", other),
},
other => panic!("expected assignment, got {:?}", other),
};
let outer_cmd = match outer_stmts.as_slice() {
[Stmt::Command(c)] => c,
other => panic!("expected a single echo command, got {:?}", other),
};
assert_eq!(outer_cmd.name, "echo");
let inner_stmts = match &outer_cmd.args[0] {
Arg::Positional(Expr::CommandSubst(s)) => s,
other => panic!("expected nested command subst arg, got {:?}", other),
};
assert!(
matches!(inner_stmts.as_slice(), [Stmt::For(f)] if f.variable == "f"),
"expected a single For statement inside the inner $(), got {inner_stmts:?}"
);
}
#[test]
fn parse_cmd_subst_unquoted_pipeline() {
let result = parse("X=$(cat f | grep pat | wc -l)").unwrap();
let value = match &result.statements[0] {
Stmt::Assignment(a) => a.value.clone(),
other => panic!("expected assignment, got {:?}", other),
};
let pipeline = subst_pipeline(&value);
assert_eq!(pipeline_commands(pipeline).len(), 3);
assert_eq!(pipeline_commands(pipeline)[2].name, "wc");
}
#[test]
fn parse_quoted_cmd_subst_with_for_loop_still_works() {
let result = parse(r#"out="$(for f in a b; do echo $f; done)""#).unwrap();
match &result.statements[0] {
Stmt::Assignment(a) => assert_eq!(a.name(), "out"),
other => panic!("expected assignment, got {:?}", other),
}
}
#[test]
fn parse_cmd_subst_body_error_reports_span_inside_body_not_at_dollar_paren() {
let source = "echo $(for f in a; do echo $f done)";
let errs = parse(source).expect_err("missing loop terminator must be a parse error");
let dollar_paren = source.find("$(").expect("fixture contains $(");
assert!(
errs.iter().all(|e| e.span.start > dollar_paren + 1),
"error span must point inside the $() body, not at '$(' itself: {errs:?}"
);
assert!(
errs.iter().any(|e| e.message.contains("done")),
"expected the missing-`done` diagnostic, got: {errs:?}"
);
}
#[test]
fn parse_cmd_subst_unterminated_reports_error() {
let result = parse("echo $(for f in a; do echo $f; done");
assert!(result.is_err(), "a missing `)` must be a parse error");
}
#[test]
fn parse_env_prefix_single() {
let result = parse("FOO=bar echo hi").unwrap();
match &result.statements[0] {
Stmt::EnvScoped { assignments, body } => {
assert_eq!(assignments.len(), 1);
assert_eq!(assignments[0].name(), "FOO");
assert!(!assignments[0].local);
match body.as_ref() {
Stmt::Command(cmd) => assert_eq!(cmd.name, "echo"),
other => panic!("expected command body, got {other:?}"),
}
}
other => panic!("expected env-scoped, got {other:?}"),
}
}
#[test]
fn parse_env_prefix_multiple() {
let result = parse("A=1 B=2 run").unwrap();
match &result.statements[0] {
Stmt::EnvScoped { assignments, body } => {
assert_eq!(assignments.len(), 2);
assert_eq!(assignments[0].name(), "A");
assert_eq!(assignments[1].name(), "B");
assert!(matches!(body.as_ref(), Stmt::Command(c) if c.name == "run"));
}
other => panic!("expected env-scoped, got {other:?}"),
}
}
#[test]
fn parse_bare_assignment_is_not_env_scoped() {
let result = parse("FOO=bar").unwrap();
assert!(
matches!(&result.statements[0], Stmt::Assignment(a) if a.name() == "FOO"),
"got {:?}",
result.statements[0]
);
}
#[test]
fn parse_assignment_then_and_chain_does_not_over_capture() {
let result = parse("FOO=bar && echo hi").unwrap();
match &result.statements[0] {
Stmt::AndChain { left, right } => {
assert!(matches!(left.as_ref(), Stmt::Assignment(a) if a.name() == "FOO"));
assert!(matches!(right.as_ref(), Stmt::Command(c) if c.name == "echo"));
}
other => panic!("expected and-chain, got {other:?}"),
}
}
#[test]
fn parse_env_prefix_pipeline_body() {
let result = parse("FOO=bar cat | grep x").unwrap();
match &result.statements[0] {
Stmt::EnvScoped { assignments, body } => {
assert_eq!(assignments[0].name(), "FOO");
match body.as_ref() {
Stmt::Pipeline(p) => assert_eq!(pipeline_commands(p).len(), 2),
other => panic!("expected pipeline body, got {other:?}"),
}
}
other => panic!("expected env-scoped, got {other:?}"),
}
}
fn parse_err_message(source: &str) -> String {
parse(source)
.expect_err("expected a parse error")
.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn argv_splat_cmdsubst_glued_to_path_is_rejected() {
let msg = parse_err_message("echo /tmp/$(echo x).txt");
assert!(msg.contains("quote"), "expected quote hint, got: {msg}");
}
#[test]
fn argv_splat_var_glued_to_path_is_rejected() {
assert!(parse("echo $dir/out.txt").is_err());
}
#[test]
fn argv_splat_three_way_glue_is_rejected() {
assert!(parse("echo foo$(echo bar)baz").is_err());
}
#[test]
fn argv_splat_quoted_word_is_accepted() {
assert!(parse(r#"echo "/tmp/$(echo x).txt""#).is_ok());
assert!(parse(r#"echo "$dir/out.txt""#).is_ok());
}
#[test]
fn argv_single_token_words_are_not_splat() {
assert!(parse("echo file.txt").is_ok(), "file.txt");
assert!(parse("echo a.b.c").is_ok(), "a.b.c");
assert!(parse("echo v1.2.3").is_ok(), "v1.2.3");
}
#[test]
fn argv_spaced_words_are_not_splat() {
assert!(parse("echo a b c").is_ok());
assert!(parse("echo /tmp/x $(echo y)").is_ok());
}
#[test]
fn parse_cmd_subst_in_command_arg() {
let result = parse("echo $(whoami)").unwrap();
match &result.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "echo");
match &cmd.args[0] {
Arg::Positional(expr) => {
assert_eq!(subst_cmd(expr).name, "whoami");
}
other => panic!("expected command subst, got {:?}", other),
}
}
other => panic!("expected command, got {:?}", other),
}
}
#[test]
fn parse_condition_and() {
let result = parse("if check-a && check-b; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::BinaryOp { left, op, right } => {
assert_eq!(*op, BinaryOp::And);
assert!(matches!(left.as_ref(), Expr::Command(_)));
assert!(matches!(right.as_ref(), Expr::Command(_)));
}
other => panic!("expected binary op, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_condition_or() {
let result = parse("if try-a || try-b; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::BinaryOp { left, op, right } => {
assert_eq!(*op, BinaryOp::Or);
assert!(matches!(left.as_ref(), Expr::Command(_)));
assert!(matches!(right.as_ref(), Expr::Command(_)));
}
other => panic!("expected binary op, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_condition_and_or_precedence() {
let result = parse("if cmd-a && cmd-b || cmd-c; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::BinaryOp { left, op, right } => {
assert_eq!(*op, BinaryOp::Or);
match left.as_ref() {
Expr::BinaryOp { op: inner_op, .. } => {
assert_eq!(*inner_op, BinaryOp::And);
}
other => panic!("expected binary op (&&), got {:?}", other),
}
assert!(matches!(right.as_ref(), Expr::Command(_)));
}
other => panic!("expected binary op, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_condition_multiple_and() {
let result = parse("if cmd-a && cmd-b && cmd-c; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::BinaryOp { left, op, .. } => {
assert_eq!(*op, BinaryOp::And);
match left.as_ref() {
Expr::BinaryOp { op: inner_op, .. } => {
assert_eq!(*inner_op, BinaryOp::And);
}
other => panic!("expected binary op, got {:?}", other),
}
}
other => panic!("expected binary op, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn parse_condition_mixed_comparison_and_logical() {
let result = parse("if [[ ${X} == 5 ]] && [[ ${Y} -gt 0 ]]; then echo; fi").unwrap();
match &result.statements[0] {
Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
Expr::BinaryOp { left, op, right } => {
assert_eq!(*op, BinaryOp::And);
match left.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op: left_op, .. } => {
assert_eq!(*left_op, TestCmpOp::Eq);
}
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test, got {:?}", other),
}
match right.as_ref() {
Expr::Test(test) => match test.as_ref() {
TestExpr::Comparison { op: right_op, .. } => {
assert_eq!(*right_op, TestCmpOp::NumGt);
}
other => panic!("expected comparison, got {:?}", other),
},
other => panic!("expected test, got {:?}", other),
}
}
other => panic!("expected binary op, got {:?}", other),
},
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn script_level1_linear() {
let script = r#"
NAME="kaish"
VERSION=1
TIMEOUT=30
ITEMS="alpha beta gamma"
echo "Starting ${NAME} v${VERSION}"
cat "README.md" | grep pattern="install" | head count=5
fetch url="https://api.example.com/status" timeout=${TIMEOUT} > "/tmp/status.json"
echo "Items: ${ITEMS}"
"#;
let result = parse(script).unwrap();
let stmts: Vec<_> = result.statements.iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect();
assert_eq!(stmts.len(), 8);
assert!(matches!(stmts[0], Stmt::Assignment(_))); assert!(matches!(stmts[1], Stmt::Assignment(_))); assert!(matches!(stmts[2], Stmt::Assignment(_))); assert!(matches!(stmts[3], Stmt::Assignment(_))); assert!(matches!(stmts[4], Stmt::Command(_))); assert!(matches!(stmts[5], Stmt::Pipeline(_))); assert!(matches!(stmts[6], Stmt::Pipeline(_))); assert!(matches!(stmts[7], Stmt::Command(_))); }
#[test]
fn script_level2_branching() {
let script = r#"
RESULT=$(kaish-validate "input.json")
if [[ ${RESULT.ok} == true ]]; then
echo "Validation passed"
process "input.json" > "output.json"
else
echo "Validation failed: ${RESULT.err}"
fi
if [[ ${COUNT} -gt 0 ]] && [[ ${COUNT} -le 100 ]]; then
echo "Count in valid range"
fi
if check-network || check-cache; then
fetch url=${URL}
fi
"#;
let result = parse(script).unwrap();
let stmts: Vec<_> = result.statements.iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect();
assert_eq!(stmts.len(), 4);
match stmts[0] {
Stmt::Assignment(a) => {
assert_eq!(a.name(), "RESULT");
assert!(matches!(&a.value, Expr::CommandSubst(_)));
}
other => panic!("expected assignment, got {:?}", other),
}
match stmts[1] {
Stmt::If(if_stmt) => {
assert_eq!(if_stmt.then_branch.len(), 2);
assert!(if_stmt.else_branch.is_some());
assert_eq!(if_stmt.else_branch.as_ref().unwrap().len(), 1);
}
other => panic!("expected if, got {:?}", other),
}
match stmts[2] {
Stmt::If(if_stmt) => {
match if_stmt.condition.as_ref() {
Expr::BinaryOp { op, .. } => assert_eq!(*op, BinaryOp::And),
other => panic!("expected && condition, got {:?}", other),
}
}
other => panic!("expected if, got {:?}", other),
}
match stmts[3] {
Stmt::If(if_stmt) => {
match if_stmt.condition.as_ref() {
Expr::BinaryOp { op, left, right } => {
assert_eq!(*op, BinaryOp::Or);
assert!(matches!(left.as_ref(), Expr::Command(_)));
assert!(matches!(right.as_ref(), Expr::Command(_)));
}
other => panic!("expected || condition, got {:?}", other),
}
}
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn script_level3_loops_and_functions() {
let script = r#"
greet() {
echo "Hello, $1!"
}
fetch_all() {
for URL in $@; do
fetch url=${URL}
done
}
USERS="alice bob charlie"
for USER in ${USERS}; do
greet ${USER}
if [[ ${USER} == "bob" ]]; then
echo "Found Bob!"
fi
done
long-running-task &
"#;
let result = parse(script).unwrap();
let stmts: Vec<_> = result.statements.iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect();
assert_eq!(stmts.len(), 5);
match stmts[0] {
Stmt::ToolDef(t) => {
assert_eq!(t.name, "greet");
assert!(t.params.is_empty());
}
other => panic!("expected function def, got {:?}", other),
}
match stmts[1] {
Stmt::ToolDef(t) => {
assert_eq!(t.name, "fetch_all");
assert_eq!(t.body.len(), 1);
assert!(matches!(&t.body[0], Stmt::For(_)));
}
other => panic!("expected function def, got {:?}", other),
}
assert!(matches!(stmts[2], Stmt::Assignment(_)));
match stmts[3] {
Stmt::For(f) => {
assert_eq!(f.variable, "USER");
assert_eq!(f.body.len(), 2);
assert!(matches!(&f.body[0], Stmt::Command(_)));
assert!(matches!(&f.body[1], Stmt::If(_)));
}
other => panic!("expected for loop, got {:?}", other),
}
match stmts[4] {
Stmt::Pipeline(p) => {
assert!(p.background);
assert_eq!(pipeline_commands(p)[0].name, "long-running-task");
}
other => panic!("expected pipeline (background), got {:?}", other),
}
}
#[test]
fn script_level4_complex_nesting() {
let script = r#"
RESULT=$(cat "config.json" | jq query=".servers" | kaish-validate schema="server-schema.json")
if ping host=${HOST} && [[ ${RESULT} == true ]]; then
for SERVER in "prod-1 prod-2"; do
deploy target=${SERVER} port=8080
if [[ $? -ne 0 ]]; then
notify channel="ops" message="Deploy failed"
fi
done
fi
"#;
let result = parse(script).unwrap();
let stmts: Vec<_> = result.statements.iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect();
assert_eq!(stmts.len(), 2);
match stmts[0] {
Stmt::Assignment(a) => {
assert_eq!(a.name(), "RESULT");
assert_eq!(pipeline_commands(subst_pipeline(&a.value)).len(), 3);
}
other => panic!("expected assignment, got {:?}", other),
}
match stmts[1] {
Stmt::If(if_stmt) => {
match if_stmt.condition.as_ref() {
Expr::BinaryOp { op, .. } => assert_eq!(*op, BinaryOp::And),
other => panic!("expected && condition, got {:?}", other),
}
assert_eq!(if_stmt.then_branch.len(), 1);
match &if_stmt.then_branch[0] {
Stmt::For(f) => {
assert_eq!(f.body.len(), 2);
assert!(matches!(&f.body[1], Stmt::If(_)));
}
other => panic!("expected for in if body, got {:?}", other),
}
}
other => panic!("expected if, got {:?}", other),
}
}
#[test]
fn script_level5_edge_cases() {
let script = r#"
echo ""
echo "quotes: \"nested\" here"
echo "escapes: \n\t\r\\"
echo "unicode: \u2764"
X=-99999
Y=3.14159265358979
Z=-0.001
cmd a=1 b="two" c=true d=false e=null
if true; then
if false; then
echo "inner"
else
echo "else"
fi
fi
for I in "a b c"; do
echo ${I}
done
no_params() {
echo "no params"
}
function all_args {
echo "args: $@"
}
a | b | c | d | e &
cmd 2> "errors.log"
cmd &> "all.log"
cmd >> "append.log"
cmd < "input.txt"
"#;
let result = parse(script).unwrap();
let stmts: Vec<_> = result.statements.iter()
.filter(|s| !matches!(s, Stmt::Empty))
.collect();
assert!(stmts.len() >= 10, "expected many statements, got {}", stmts.len());
let bg_stmt = stmts.iter().find(|s| matches!(s, Stmt::Pipeline(p) if p.background));
assert!(bg_stmt.is_some(), "expected background pipeline");
match bg_stmt.unwrap() {
Stmt::Pipeline(p) => {
assert_eq!(pipeline_commands(p).len(), 5);
assert!(p.background);
}
_ => unreachable!(),
}
}
#[test]
fn parse_keyword_as_variable_rejected() {
let result = parse(r#"if="value""#);
assert!(result.is_err(), "if= should fail - 'if' is a keyword");
let result = parse("while=true");
assert!(result.is_err(), "while= should fail - 'while' is a keyword");
let result = parse(r#"then="next""#);
assert!(result.is_err(), "then= should fail - 'then' is a keyword");
}
#[test]
fn parse_set_command_with_flag() {
let result = parse("set -e");
assert!(result.is_ok(), "failed to parse set -e: {:?}", result);
let program = result.unwrap();
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "set");
assert_eq!(cmd.args.len(), 1);
match &cmd.args[0] {
Arg::ShortFlag(f) => assert_eq!(f, "e"),
other => panic!("expected ShortFlag, got {:?}", other),
}
}
other => panic!("expected Command, got {:?}", other),
}
}
#[test]
fn parse_set_command_no_args() {
let result = parse("set");
assert!(result.is_ok(), "failed to parse set: {:?}", result);
let program = result.unwrap();
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "set");
assert_eq!(cmd.args.len(), 0);
}
other => panic!("expected Command, got {:?}", other),
}
}
#[test]
fn parse_set_assignment_vs_command() {
let result = parse("X=5");
assert!(result.is_ok());
let program = result.unwrap();
assert!(matches!(&program.statements[0], Stmt::Assignment(_)));
let result = parse("set -e");
assert!(result.is_ok());
let program = result.unwrap();
assert!(matches!(&program.statements[0], Stmt::Command(_)));
}
#[test]
fn parse_true_as_command() {
let result = parse("true");
assert!(result.is_ok());
let program = result.unwrap();
match &program.statements[0] {
Stmt::Command(cmd) => assert_eq!(cmd.name, "true"),
other => panic!("expected Command(true), got {:?}", other),
}
}
#[test]
fn parse_false_as_command() {
let result = parse("false");
assert!(result.is_ok());
let program = result.unwrap();
match &program.statements[0] {
Stmt::Command(cmd) => assert_eq!(cmd.name, "false"),
other => panic!("expected Command(false), got {:?}", other),
}
}
#[test]
fn parse_dot_as_source_alias() {
let result = parse(". script.kai");
assert!(result.is_ok(), "failed to parse . script.kai: {:?}", result);
let program = result.unwrap();
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, ".");
assert_eq!(cmd.args.len(), 1);
}
other => panic!("expected Command(.), got {:?}", other),
}
}
#[test]
fn parse_source_command() {
let result = parse("source utils.kai");
assert!(result.is_ok(), "failed to parse source: {:?}", result);
let program = result.unwrap();
match &program.statements[0] {
Stmt::Command(cmd) => {
assert_eq!(cmd.name, "source");
assert_eq!(cmd.args.len(), 1);
}
other => panic!("expected Command(source), got {:?}", other),
}
}
#[test]
fn parse_test_expr_file_test() {
let result = parse(r#"[[ -f "/path/file" ]]"#);
assert!(result.is_ok(), "failed to parse file test: {:?}", result);
}
#[test]
fn parse_test_expr_comparison() {
let result = parse(r#"[[ $X == "value" ]]"#);
assert!(result.is_ok(), "failed to parse comparison test: {:?}", result);
}
#[test]
fn parse_test_expr_single_eq() {
let result = parse(r#"[[ $X = "value" ]]"#);
assert!(result.is_ok(), "failed to parse single-= comparison: {:?}", result);
let program = result.unwrap();
match &program.statements[0] {
Stmt::Test(TestExpr::Comparison { op, .. }) => {
assert_eq!(op, &TestCmpOp::Eq);
}
other => panic!("expected Test(Comparison), got {:?}", other),
}
}
#[test]
fn parse_while_loop() {
let result = parse("while true; do echo; done");
assert!(result.is_ok(), "failed to parse while loop: {:?}", result);
let program = result.unwrap();
assert!(matches!(&program.statements[0], Stmt::While(_)));
}
#[test]
fn parse_break_with_level() {
let result = parse("break 2");
assert!(result.is_ok());
let program = result.unwrap();
match &program.statements[0] {
Stmt::Break(Some(n)) => assert_eq!(*n, 2),
other => panic!("expected Break(2), got {:?}", other),
}
}
#[test]
fn parse_continue_with_level() {
let result = parse("continue 3");
assert!(result.is_ok());
let program = result.unwrap();
match &program.statements[0] {
Stmt::Continue(Some(n)) => assert_eq!(*n, 3),
other => panic!("expected Continue(3), got {:?}", other),
}
}
#[test]
fn parse_exit_with_code() {
let result = parse("exit 1");
assert!(result.is_ok());
let program = result.unwrap();
match &program.statements[0] {
Stmt::Exit(Some(expr)) => {
match expr.as_ref() {
Expr::Literal(Value::Int(n)) => assert_eq!(*n, 1),
other => panic!("expected Int(1), got {:?}", other),
}
}
other => panic!("expected Exit(1), got {:?}", other),
}
}
#[test]
fn spanned_literal_only_records_byte_range() {
let parts = parse_interpolated_string_spanned("hello world", 100).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "hello world"));
assert_eq!(parts[0].offset, 100, "base_offset must propagate to literals");
assert_eq!(parts[0].len, 11);
}
#[test]
fn spanned_braced_var_at_zero() {
let parts = parse_interpolated_string_spanned("${X}", 50).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::Var(_)));
assert_eq!(parts[0].offset, 50);
assert_eq!(parts[0].len, 4); }
#[test]
fn spanned_simple_var_then_literal() {
let parts = parse_interpolated_string_spanned("$X end", 10).unwrap();
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0].part, StringPart::Var(_)));
assert_eq!(parts[0].offset, 10);
assert_eq!(parts[0].len, 2); assert!(matches!(&parts[1].part, StringPart::Literal(s) if s == " end"));
assert_eq!(parts[1].offset, 12);
assert_eq!(parts[1].len, 4);
}
#[test]
fn spanned_mixed_literal_var_literal() {
let parts = parse_interpolated_string_spanned("hi ${X} bye", 0).unwrap();
assert_eq!(parts.len(), 3);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "hi "));
assert_eq!(parts[0].offset, 0);
assert_eq!(parts[0].len, 3);
assert!(matches!(&parts[1].part, StringPart::Var(_)));
assert_eq!(parts[1].offset, 3);
assert_eq!(parts[1].len, 4);
assert!(matches!(&parts[2].part, StringPart::Literal(s) if s == " bye"));
assert_eq!(parts[2].offset, 7);
assert_eq!(parts[2].len, 4);
}
#[test]
fn spanned_positional_param() {
let parts = parse_interpolated_string_spanned("$1 done", 0).unwrap();
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0].part, StringPart::Positional(1)));
assert_eq!(parts[0].offset, 0);
assert_eq!(parts[0].len, 2); }
#[test]
fn spanned_special_dollar_dollar() {
let parts = parse_interpolated_string_spanned("$$", 5).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::CurrentPid));
assert_eq!(parts[0].offset, 5);
assert_eq!(parts[0].len, 2);
}
#[test]
fn spanned_arithmetic_marker_recognised() {
let parts = parse_interpolated_string_spanned("${__ARITH:1+2__}", 0).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::Arithmetic(e) if e == "1+2"));
}
#[test]
fn spanned_default_separator_yields_var_with_default() {
let parts = parse_interpolated_string_spanned("${X:-fallback}", 0).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::VarWithDefault { .. }));
assert_eq!(parts[0].offset, 0);
assert_eq!(parts[0].len, 14); }
#[test]
fn spanned_no_dollar_runs_one_literal() {
let parts = parse_interpolated_string_spanned("plain text only", 7).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "plain text only"));
assert_eq!(parts[0].offset, 7);
assert_eq!(parts[0].len, 15);
}
#[test]
fn spanned_matches_unspanned_part_count() {
let cases = [
"hello",
"$X",
"${X}",
"${X:-d}",
"hi $A and $B",
"$0 $1 $2",
"$$ $? $#",
];
for s in &cases {
let unspanned = parse_interpolated_string(s).expect("test input parses");
let spanned = parse_interpolated_string_spanned(s, 0).unwrap();
assert_eq!(
unspanned.len(),
spanned.len(),
"part count differs for {:?}",
s
);
}
}
#[test]
fn spanned_multibyte_utf8_before_var_uses_byte_offsets() {
let parts = parse_interpolated_string_spanned("🚀 ${X}", 0).unwrap();
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "🚀 "));
assert_eq!(parts[0].offset, 0);
assert_eq!(parts[0].len, 5, "literal len must be bytes, not chars");
assert!(matches!(&parts[1].part, StringPart::Var(_)));
assert_eq!(parts[1].offset, 5, "var offset must be bytes, not chars");
assert_eq!(parts[1].len, 4);
}
#[test]
fn spanned_multibyte_utf8_pure_literal_is_byte_length() {
let parts = parse_interpolated_string_spanned("hello 世界 world", 0).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "hello 世界 world"));
assert_eq!(parts[0].offset, 0);
assert_eq!(parts[0].len, 18);
}
#[test]
fn spanned_escape_dollar_consumes_two_bytes_emits_one_char() {
let parts = parse_interpolated_string_spanned("\\$", 0).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "$"));
assert_eq!(parts[0].offset, 0);
assert_eq!(parts[0].len, 2, "len is source byte length, not rendered length");
}
#[test]
fn spanned_escape_backslash_collapses_pair_to_one() {
let parts = parse_interpolated_string_spanned("\\\\", 0).unwrap();
assert_eq!(parts.len(), 1);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "\\"));
assert_eq!(parts[0].len, 2);
}
#[test]
fn spanned_standalone_cr_continuation_realigns_span_start() {
let parts = parse_interpolated_string_spanned("\\\rCD${x}", 0).unwrap();
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "CD"));
assert_eq!(parts[0].offset, 2, "literal run must start after the consumed \\+CR");
assert_eq!(parts[0].len, 2);
assert!(matches!(&parts[1].part, StringPart::Var(_)));
assert_eq!(parts[1].offset, 4);
assert_eq!(parts[1].len, 4); }
#[test]
fn spanned_standalone_cr_continuation_mid_run_keeps_span_start() {
let parts = parse_interpolated_string_spanned("AB\\\rCD${x}", 0).unwrap();
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "ABCD"));
assert_eq!(parts[0].offset, 0);
assert_eq!(parts[0].len, 6); assert!(matches!(&parts[1].part, StringPart::Var(_)));
assert_eq!(parts[1].offset, 6);
assert_eq!(parts[1].len, 4); }
fn assignment_value(source: &str) -> Expr {
let program = parse(source).unwrap_or_else(|e| panic!("parse {source:?}: {e:?}"));
match program.statements.as_slice() {
[Stmt::Assignment(a)] => a.value.clone(),
other => panic!("expected a single assignment, got {other:?}"),
}
}
#[test]
fn list_literal_three_elements() {
let expr = assignment_value("xs=[a b c]");
match expr {
Expr::ListLiteral(elems) => {
assert_eq!(elems.len(), 3);
assert!(elems.iter().all(|e| matches!(e, ListElem::Item(_))));
}
other => panic!("expected ListLiteral, got {other:?}"),
}
}
#[test]
fn list_literal_empty() {
let expr = assignment_value("xs=[]");
assert!(matches!(expr, Expr::ListLiteral(elems) if elems.is_empty()));
}
#[test]
fn list_literal_single_glued_dog() {
let expr = assignment_value("xs=[dog]");
match expr {
Expr::ListLiteral(elems) => assert_eq!(elems.len(), 1),
other => panic!("expected ListLiteral, got {other:?}"),
}
}
#[test]
fn list_literal_single_int() {
let expr = assignment_value("xs=[1]");
match expr {
Expr::ListLiteral(elems) => match elems.as_slice() {
[ListElem::Item(Expr::Literal(Value::Int(1)))] => {}
other => panic!("expected one Int(1) item, got {other:?}"),
},
other => panic!("expected ListLiteral, got {other:?}"),
}
}
#[test]
fn record_literal_unspaced_colon_equals_spaced() {
let spaced = assignment_value("x={port: 8080}");
let unspaced = assignment_value("x={port:8080}");
assert_eq!(spaced, unspaced, "{{port:8080}} must parse identically to {{port: 8080}}");
match spaced {
Expr::RecordLiteral(entries) => match entries.as_slice() {
[RecordEntry { key: RecordKey::Bare(k), value: Expr::Literal(Value::Int(8080)) }] => {
assert_eq!(k, "port");
}
other => panic!("expected one port:8080 entry, got {other:?}"),
},
other => panic!("expected RecordLiteral, got {other:?}"),
}
}
#[test]
fn record_literal_name_role() {
let expr = assignment_value("u={name: amy, role: maintainer}");
match expr {
Expr::RecordLiteral(entries) => assert_eq!(entries.len(), 2),
other => panic!("expected RecordLiteral, got {other:?}"),
}
}
#[test]
fn record_literal_multiline_trailing_comma() {
let source = "services={\n web: {port: 8080, replicas: 3, healthy: true},\n api: {port: 9000, replicas: 2, healthy: false},\n}";
let expr = assignment_value(source);
match expr {
Expr::RecordLiteral(entries) => assert_eq!(entries.len(), 2, "web + api entries"),
other => panic!("expected RecordLiteral, got {other:?}"),
}
}
#[test]
fn record_literal_quoted_key() {
let expr = assignment_value(r#"r={"content-type": x}"#);
match expr {
Expr::RecordLiteral(entries) => match entries.as_slice() {
[RecordEntry { key: RecordKey::Quoted(k), .. }] => assert_eq!(k, "content-type"),
other => panic!("expected one quoted-key entry, got {other:?}"),
},
other => panic!("expected RecordLiteral, got {other:?}"),
}
}
#[test]
fn nested_list_and_record_in_record() {
let expr = assignment_value("x={tags: [a b], meta: {active: true}}");
match expr {
Expr::RecordLiteral(entries) => {
assert_eq!(entries.len(), 2);
assert!(matches!(entries[0].value, Expr::ListLiteral(_)));
assert!(matches!(entries[1].value, Expr::RecordLiteral(_)));
}
other => panic!("expected RecordLiteral, got {other:?}"),
}
}
#[test]
fn spread_and_item_elements() {
let expr = assignment_value("new=[...$xs date]");
match expr {
Expr::ListLiteral(elems) => match elems.as_slice() {
[ListElem::Spread(Expr::VarRef(_)), ListElem::Item(Expr::Literal(Value::String(s)))] => {
assert_eq!(s, "date");
}
other => panic!("expected [Spread($xs), Item(date)], got {other:?}"),
},
other => panic!("expected ListLiteral, got {other:?}"),
}
}
#[test]
fn spread_of_two_variables() {
let expr = assignment_value("c=[...$a ...$b]");
match expr {
Expr::ListLiteral(elems) => {
assert_eq!(elems.len(), 2);
assert!(elems.iter().all(|e| matches!(e, ListElem::Spread(_))));
}
other => panic!("expected ListLiteral, got {other:?}"),
}
}
#[test]
fn in_rhs_accepts_a_list_literal() {
let program = parse("if [[ $a not in [dog] ]]; then echo hit; fi")
.unwrap_or_else(|e| panic!("parse: {e:?}"));
assert_eq!(program.statements.len(), 1);
}
#[test]
fn multiword_bareword_record_value_is_a_parse_error() {
assert!(parse("x={msg: hello world}").is_err());
}
#[test]
fn argv_bracket_glob_stays_a_glob_pattern() {
let program = parse("ls [dog]").unwrap_or_else(|e| panic!("parse: {e:?}"));
assert_eq!(program.statements.len(), 1);
}
#[test]
fn brace_expansion_at_argv_position_is_unaffected() {
let program = parse("cmd *.{rs,go}").unwrap_or_else(|e| panic!("parse: {e:?}"));
assert_eq!(program.statements.len(), 1);
}
#[test]
fn for_head_item_is_not_a_literal() {
let program = parse("for x in [a]; do echo $x; done")
.unwrap_or_else(|e| panic!("parse: {e:?}"));
match program.statements.as_slice() {
[Stmt::For(for_loop)] => {
assert_eq!(for_loop.items.len(), 1);
assert!(
!matches!(for_loop.items[0], Expr::ListLiteral(_)),
"for-head item must not be a ListLiteral: {:?}",
for_loop.items[0]
);
}
other => panic!("expected a single For statement, got {other:?}"),
}
}
#[derive(Debug, Clone, Copy)]
enum NestingLayer {
CmdSubst,
QuotedCmdSubst,
Case,
If,
For,
}
fn wrap_in_layer(inner: &str, layer: NestingLayer) -> String {
match layer {
NestingLayer::CmdSubst => format!("x=$({inner})"),
NestingLayer::QuotedCmdSubst => format!("x=\"pre $({inner}) post\""),
NestingLayer::Case => format!("case v in v) {inner};; esac"),
NestingLayer::If => format!("if true; then {inner}; fi"),
NestingLayer::For => format!("for f in a; do {inner}; done"),
}
}
proptest::proptest! {
#[test]
fn nested_compound_constructs_always_parse(
layers in proptest::collection::vec(
proptest::prop_oneof![
proptest::strategy::Just(NestingLayer::CmdSubst),
proptest::strategy::Just(NestingLayer::QuotedCmdSubst),
proptest::strategy::Just(NestingLayer::Case),
proptest::strategy::Just(NestingLayer::If),
proptest::strategy::Just(NestingLayer::For),
],
1..=4,
).prop_filter("at most one QuotedCmdSubst layer", |layers| {
layers.iter().filter(|l| matches!(l, NestingLayer::QuotedCmdSubst)).count() <= 1
})
) {
let source = layers
.iter()
.fold("echo x".to_string(), |inner, &layer| wrap_in_layer(&inner, layer));
proptest::prop_assert!(
parse(&source).is_ok(),
"grammar-nested construct failed to parse: {source:?}"
);
}
}
}