use crate::viml_ast::{ArithOp, Expr, ForVars, LetTarget, Stmt, UnaryOp, UnletArg};
use crate::viml_lexer::{lex, CaseFlag, CmpOp, InterpPart, Tok, Token, VimlError};
use std::cell::Cell;
thread_local! {
static VIM9: Cell<bool> = const { Cell::new(false) };
}
fn vim9_active() -> bool {
VIM9.with(|f| f.get())
}
struct Vim9Guard(bool);
impl Vim9Guard {
fn enter(on: bool) -> Self {
Vim9Guard(VIM9.with(|f| f.replace(on)))
}
}
impl Drop for Vim9Guard {
fn drop(&mut self) {
VIM9.with(|f| f.set(self.0));
}
}
fn script_is_vim9(src: &str) -> bool {
src.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.is_some_and(|l| l.split(char::is_whitespace).next() == Some("vim9script"))
}
pub const PHASE3_BUILTINS: &[&str] = &[
"len",
"type",
"string",
"empty",
"abs",
"str2nr",
"str2float",
"float2nr",
];
pub fn parse_stmt(line: &str) -> Result<Stmt, VimlError> {
let line = strip_command_modifiers(line.trim());
if line.is_empty() {
return Ok(Stmt::Expr(Expr::Number(0)));
}
if line
.split(|c: char| c.is_whitespace())
.next()
.is_some_and(|w| w == "vim9script")
{
return Ok(Stmt::Expr(Expr::Number(0)));
}
let cmd_end = line
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(line.len());
let cmd = &line[..cmd_end];
let rest = line[cmd_end..].trim_start();
match cmd {
"echo" | "ec" => Ok(Stmt::Echo(parse_expr_list(rest)?)),
"echon" => Ok(Stmt::Echon(parse_expr_list(rest)?)),
"echomsg" | "echom" | "echoerr" | "echoer" | "echoe" => {
Ok(Stmt::Echo(parse_expr_list(rest)?))
}
"execute" | "execut" | "execu" | "exec" | "exe" => {
Ok(Stmt::Execute(parse_expr_list(rest)?))
}
"set" | "se" | "setlocal" | "setl" | "setglobal" | "setg" => {
Ok(Stmt::Set(rest.to_string()))
}
"source" | "so" => Ok(Stmt::Source(rest.trim().to_string())),
"unlet" | "unl" => {
let args = rest.trim_start_matches('!').trim();
split_unlet_args(args)
.into_iter()
.map(parse_unlet_arg)
.collect::<Result<Vec<_>, _>>()
.map(Stmt::Unlet)
}
"let" => parse_let(rest),
"var" => parse_let(&vim9_var_decl(rest)),
"final" => parse_let(&strip_vim9_type(rest)),
"function" | "fu" | "fun" | "func" | "funct" | "functi" | "functio"
if !rest.contains('(') =>
{
Ok(Stmt::Expr(Expr::Number(0)))
}
"const" | "cons" => parse_let(&strip_vim9_type(rest)),
"call" => Ok(Stmt::Call(parse_expr(strip_legacy_trailing_comment(rest))?)),
"eval" => Ok(Stmt::Expr(parse_expr(strip_legacy_trailing_comment(rest))?)),
"break" => Ok(Stmt::Break),
"continue" | "cont" => Ok(Stmt::Continue),
"finish" | "finis" | "fini" => Ok(Stmt::Finish),
"return" => Ok(if rest.trim().is_empty() {
Stmt::Return(None)
} else {
Stmt::Return(Some(parse_expr(strip_legacy_trailing_comment(rest))?))
}),
"throw" => Ok(Stmt::Throw(parse_expr(strip_legacy_trailing_comment(
rest,
))?)),
"command" | "comm" | "com" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::CommandDef(line[cmd.len()..].trim_start().to_string()))
}
"delcommand" | "delc" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::CommandDel(rest.to_string()))
}
"delfunction" | "delfunctio" | "delfuncti" | "delfunct" | "delfunc" | "delfun" | "delf"
if !line[cmd.len()..].starts_with('(') =>
{
Ok(Stmt::DelFunction(
line[cmd.len()..].trim_start().to_string(),
))
}
"autocmd" | "autocm" | "autoc" | "auto" | "au" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::Autocmd(line[cmd.len()..].trim_start().to_string()))
}
"augroup" | "aug" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::Augroup(rest.to_string()))
}
"doautocmd" | "doau" | "doautoall" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::Doautocmd(rest.to_string()))
}
_ if is_map_command(cmd) && !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::Map(line.to_string()))
}
"colorscheme" | "colo" | "colors" | "colorsc" | "colorsch" | "colorsche" | "colorschem"
if !line[cmd.len()..].starts_with('(') =>
{
Ok(Stmt::Colorscheme(rest.trim().to_string()))
}
"highlight" | "hi" | "highligh" | "highlig" | "highli" | "highl" | "high" | "hig"
if !line[cmd.len()..].starts_with('(') =>
{
Ok(Stmt::Highlight(line[cmd.len()..].trim_start().to_string()))
}
"syntax" | "syn" | "synta" | "synt" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::Syntax(rest.trim().to_string()))
}
"filetype" | "filetyp" | "filety" | "filet" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::Filetype(rest.trim().to_string()))
}
"normal" | "norm" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::ExCmd(line.to_string()))
}
"echohl" | "echoh" if !line[cmd.len()..].starts_with('(') => {
Ok(Stmt::ExCmd(line.to_string()))
}
"redraw" | "redr" | "redra" | "redraws" | "redrawstatus" | "redrawt" | "redrawtabline"
| "redir" | "redi" | "runtime" | "ru" | "run" | "runt" | "runti" | "runtim" | "mark"
| "ma" | "mar" | "noh" | "nohl" | "nohls" | "nohlse" | "nohlsea" | "nohlsear"
| "nohlsearc" | "nohlsearch"
if !line[cmd.len()..].starts_with('(') =>
{
Ok(Stmt::ExCmd(line.to_string()))
}
"fold" | "fo" | "fol" | "foldopen" | "foldo" | "foldop" | "foldope" | "foldclose"
| "foldc" | "foldcl" | "foldclo" | "foldclos"
if !line[cmd.len()..].starts_with('(') =>
{
Ok(Stmt::ExCmd(line.to_string()))
}
"edit" | "ed" | "bnext" | "bn" | "bne" | "bprevious" | "bp" | "bprev" | "bNext" | "bN"
| "bfirst" | "bf" | "blast" | "bl" | "buffer" | "bu" | "buf" | "bmodified" | "bm"
| "bmod" | "ball" | "ba"
if !line[cmd.len()..].starts_with('(') =>
{
Ok(Stmt::ExCmd(line.to_string()))
}
_ if is_script_lang_cmd(line) => Ok(Stmt::ExCmd(line.to_string())),
_ if line.starts_with(|c: char| c.is_ascii_digit()) => match parse_expr(line) {
Ok(e) => Ok(Stmt::Expr(e)),
Err(_) => Ok(Stmt::ExCmd(line.to_string())),
},
_ if line.starts_with(':')
|| line.starts_with('!')
|| line.starts_with('\'')
|| (line.starts_with('%')
&& line[1..].starts_with(|c: char| c.is_ascii_alphabetic())) =>
{
Ok(Stmt::ExCmd(line.to_string()))
}
_ if vim9_active() && is_vim9_assignment(line) => parse_let(line),
_ if cmd.starts_with(|c: char| c.is_ascii_uppercase())
&& !line[cmd.len()..].starts_with('(') =>
{
Ok(Stmt::UserCmd(line.to_string()))
}
_ => Ok(Stmt::Expr(parse_expr(line)?)),
}
}
const CMD_MODIFIERS: &[&str] = &[
"silent",
"sil",
"unsilent",
"uns",
"verbose",
"verb",
"noautocmd",
"noa",
"keepmarks",
"keepm",
"keepjumps",
"keepj",
"keepalt",
"keepa",
"keeppatterns",
"keepp",
"lockmarks",
"lockm",
"noswapfile",
"nos",
"sandbox",
"sandb",
"browse",
"bro",
"confirm",
"conf",
"hide",
"hid",
"aboveleft",
"abo",
"belowright",
"bel",
"botright",
"bo",
"topleft",
"to",
"leftabove",
"lefta",
"rightbelow",
"rightb",
"vertical",
"vert",
"horizontal",
"hor",
"tab",
];
fn strip_command_modifiers(mut line: &str) -> &str {
loop {
line = line.trim_start();
let end = line
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(line.len());
if end == 0 {
break;
}
let word = &line[..end];
if !CMD_MODIFIERS.contains(&word) {
break;
}
let after = &line[end..];
let mut rest = match after.chars().next() {
None => "",
Some('!') => &after[1..],
Some(c) if c.is_whitespace() => after,
_ => break,
};
if matches!(word, "verbose" | "verb" | "tab") {
let r = rest.trim_start();
let ne = r.find(|c: char| !c.is_ascii_digit()).unwrap_or(r.len());
if ne > 0 {
rest = &r[ne..];
}
}
line = rest;
}
line
}
fn is_map_command(cmd: &str) -> bool {
let prefix = cmd
.strip_suffix("mapclear")
.or_else(|| cmd.strip_suffix("noremap"))
.or_else(|| cmd.strip_suffix("unmap"))
.or_else(|| cmd.strip_suffix("map"));
matches!(
prefix,
Some("" | "n" | "i" | "v" | "x" | "s" | "o" | "c" | "t" | "l")
)
}
pub(crate) fn is_script_lang_cmd(line: &str) -> bool {
let line = line.trim_start();
let end = line
.find(|c: char| !c.is_ascii_alphanumeric())
.unwrap_or(line.len());
if line[end..].starts_with('(') {
return false;
}
let after = line[end..].trim_start();
let is_assign = matches!(
after.as_bytes().first(),
Some(b'+' | b'-' | b'*' | b'/' | b'%')
) && after[1..].starts_with('=')
|| after.starts_with("..=")
|| after.starts_with(".=")
|| (after.starts_with('=') && !after.starts_with("==") && !after.starts_with("=~"));
if is_assign {
return false;
}
matches!(
&line[..end],
"python"
| "py"
| "pydo"
| "pyfile"
| "python3"
| "py3"
| "py3do"
| "py3file"
| "pythonx"
| "pyx"
| "pyxdo"
| "pyxfile"
| "perl"
| "perldo"
| "ruby"
| "rubydo"
| "rubyfile"
| "lua"
| "luado"
| "luafile"
| "tcl"
| "tcldo"
| "tclfile"
| "mzscheme"
| "mz"
| "mzfile"
)
}
fn cmd_word(line: &str) -> (&str, &str) {
let line = line.trim();
let end = line
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(line.len());
(&line[..end], line[end..].trim_start())
}
fn is_block_terminator(cmd: &str) -> bool {
matches!(
canon_block_kw(cmd),
"endif"
| "elseif"
| "else"
| "endwhile"
| "endfor"
| "endfunction"
| "enddef"
| "catch"
| "finally"
| "endtry"
)
}
fn canon_block_kw(cmd: &str) -> &str {
match cmd {
"fu" | "fun" | "func" | "funct" | "functi" | "functio" | "function" => "function",
"endf" | "endfu" | "endfun" | "endfunc" | "endfunct" | "endfuncti" | "endfunctio"
| "endfunction" => "endfunction",
"wh" | "whi" | "whil" | "while" => "while",
"endw" | "endwh" | "endwhi" | "endwhil" | "endwhile" => "endwhile",
"for" => "for",
"endfo" | "endfor" => "endfor",
"if" => "if",
"elsei" | "elseif" => "elseif",
"el" | "els" | "else" => "else",
"en" | "end" | "endi" | "endif" => "endif",
"try" => "try",
"cat" | "catc" | "catch" => "catch",
"fina" | "finall" | "finally" => "finally",
"endt" | "endtr" | "endtry" => "endtry",
other => other,
}
}
fn is_block_opener(cmd: &str) -> bool {
matches!(
canon_block_kw(cmd),
"if" | "while" | "for" | "function" | "try" | "def" | "enum" | "class" | "interface"
)
}
fn block_cmd_word(line: &str) -> &str {
let (cmd, rest) = cmd_word(strip_command_modifiers(line));
if cmd == "export" {
cmd_word(rest).0
} else {
cmd
}
}
fn is_final_block_terminator(cmd: &str) -> bool {
matches!(
canon_block_kw(cmd),
"endif"
| "endwhile"
| "endfor"
| "endfunction"
| "enddef"
| "endtry"
| "endenum"
| "endclass"
| "endinterface"
)
}
pub fn parse_program(src: &str) -> Result<Vec<Stmt>, VimlError> {
Ok(parse_program_lines(src)?
.into_iter()
.map(|(_, s)| s)
.collect())
}
pub fn parse_program_lines(src: &str) -> Result<Vec<(u32, Stmt)>, VimlError> {
let _vim9 = Vim9Guard::enter(script_is_vim9(src));
let mut cur = Lines::new(src);
let mut out = Vec::new();
loop {
cur.skip_blanks();
let Some(line) = cur.peek() else { break };
let (cmd, _) = cmd_word(&line);
if is_block_terminator(cmd) {
return Err(VimlError::msg(format!(
"E580: `:{cmd}` without matching block opener"
)));
}
let lineno = cur.line_no();
for s in parse_one(&mut cur)? {
out.push((lineno, s));
}
}
Ok(out)
}
pub type TolerantParse = (Vec<(u32, Stmt)>, Vec<(u32, String)>);
pub fn parse_program_lines_tolerant(src: &str) -> TolerantParse {
let _vim9 = Vim9Guard::enter(script_is_vim9(src));
let mut cur = Lines::new(src);
let mut out = Vec::new();
let mut errs = Vec::new();
loop {
cur.skip_blanks();
let Some(line) = cur.peek() else { break };
let lineno = cur.line_no();
let snapshot = cur.i;
let (cmd, _) = cmd_word(&line);
if is_block_terminator(cmd) {
errs.push((
lineno,
format!("E580: `:{cmd}` without matching block opener"),
));
cur.i = snapshot + 1;
continue;
}
match parse_one(&mut cur) {
Ok(stmts) => {
for s in stmts {
out.push((lineno, s));
}
}
Err(e) => {
errs.push((lineno, e.0));
if is_block_opener(block_cmd_word(&line)) {
cur.skip_block_from(snapshot);
} else {
cur.i = snapshot + 1;
}
}
}
}
(out, errs)
}
struct Lines {
lines: Vec<(u32, String)>,
i: usize,
}
impl Lines {
fn new(src: &str) -> Self {
let raw: Vec<&str> = src.lines().collect();
let mut collapsed: Vec<(u32, String)> = Vec::new();
let mut k = 0;
while k < raw.len() {
let lineno = (k + 1) as u32;
if let Some((trim, marker)) = script_lang_heredoc_marker(raw[k]) {
let cmd_indent: String = raw[k].chars().take_while(|c| c.is_whitespace()).collect();
let mut j = k + 1;
while j < raw.len() {
let bl = raw[j];
let probe = if trim {
bl.strip_prefix(cmd_indent.as_str()).unwrap_or(bl)
} else {
bl
};
j += 1;
if probe == marker {
break;
}
}
collapsed.push((lineno, raw[k].to_string()));
k = j;
continue;
}
if let Some((prefix, trim, _eval, marker)) = heredoc_opener(raw[k]) {
let cmd_indent: String = raw[k].chars().take_while(|c| c.is_whitespace()).collect();
let mut body: Vec<String> = Vec::new();
let mut j = k + 1;
while j < raw.len() {
let bl = raw[j];
let probe = if trim {
bl.strip_prefix(cmd_indent.as_str()).unwrap_or(bl)
} else {
bl
};
j += 1;
if probe == marker {
break;
}
body.push(bl.to_string());
}
if trim {
if let Some(first) = body.iter().find(|l| !l.trim().is_empty()) {
let ti: String = first.chars().take_while(|c| c.is_whitespace()).collect();
for l in body.iter_mut() {
let n: usize = l
.chars()
.zip(ti.chars())
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum();
*l = l[n..].to_string();
}
}
}
let items: Vec<String> = body
.iter()
.map(|l| format!("'{}'", l.replace('\'', "''")))
.collect();
collapsed.push((lineno, format!("{prefix}= [{}]", items.join(", "))));
k = j;
continue;
}
collapsed.push((lineno, raw[k].to_string()));
k += 1;
}
let script_vim9 = collapsed
.iter()
.map(|(_, l)| l.trim())
.find(|t| !t.is_empty() && !t.starts_with('"'))
.is_some_and(|t| t.split(char::is_whitespace).next() == Some("vim9script"));
let mut joined: Vec<(u32, String)> = Vec::new();
let mut in_def: u32 = 0; let mut open_depth: i32 = 0; for (lineno, raw) in collapsed {
let trimmed = raw.trim_start();
if let Some(rest) = trimmed.strip_prefix('\\') {
if let Some(last) = joined.last_mut() {
last.1.push_str(rest);
if script_vim9 || in_def > 0 {
open_depth = vim9_bracket_depth(&last.1);
}
continue;
}
}
let fw = cmd_word(&raw).0;
let is_enddef = fw == "enddef";
let active_vim9 = script_vim9 || in_def > 0;
if active_vim9 && !trimmed.is_empty() && strip_vim9_comment(&raw).trim().is_empty() {
if open_depth <= 0 {
joined.push((lineno, String::new()));
}
continue;
}
if active_vim9 && !is_enddef {
if let Some(last) = joined.last_mut() {
let join = open_depth > 0
|| vim9_trailing_continues(&last.1)
|| vim9_leading_continues(trimmed)
|| (trimmed.starts_with(':') && vim9_open_ternary(&last.1));
if join {
last.1.push(' ');
last.1.push_str(strip_vim9_comment(&raw).trim_start());
open_depth = vim9_bracket_depth(&last.1);
continue;
}
}
}
let text = if active_vim9 || fw == "def" {
strip_vim9_comment(&raw).to_string()
} else {
raw.to_string()
};
joined.push((lineno, text));
if fw == "def" {
in_def += 1;
} else if is_enddef && in_def > 0 {
in_def -= 1;
}
open_depth = if script_vim9 || in_def > 0 {
vim9_bracket_depth(&joined.last().expect("just pushed").1)
} else {
0
};
}
let mut lines: Vec<(u32, String)> = Vec::new();
for (lineno, text) in joined {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.starts_with('"') {
lines.push((lineno, text));
continue;
}
let (lead, _) = cmd_word(strip_command_modifiers(trimmed));
if cmd_takes_bar_arg(lead) {
lines.push((lineno, text));
continue;
}
let segs = split_commands(&text);
if segs.len() > 1 {
for seg in segs {
if !seg.trim().is_empty() {
lines.push((lineno, seg.to_string()));
}
}
} else {
lines.push((lineno, text));
}
}
Lines { lines, i: 0 }
}
fn skip_blanks(&mut self) {
while let Some((_, l)) = self.lines.get(self.i) {
let t = l.trim();
if t.is_empty() || t.starts_with('"') {
self.i += 1;
} else {
break;
}
}
}
fn peek(&self) -> Option<String> {
self.lines.get(self.i).map(|(_, s)| s.clone())
}
fn bump(&mut self) {
self.i += 1;
}
fn line_no(&self) -> u32 {
self.lines.get(self.i).map(|(n, _)| *n).unwrap_or(0)
}
fn skip_block_from(&mut self, start: usize) {
let mut depth = 0i32;
let mut j = start;
while j < self.lines.len() {
let cmd = block_cmd_word(&self.lines[j].1);
if is_block_opener(cmd) {
depth += 1;
} else if is_final_block_terminator(cmd) {
depth -= 1;
if depth == 0 {
self.i = j + 1;
return;
}
}
j += 1;
}
self.i = self.lines.len();
}
}
fn parse_one(cur: &mut Lines) -> Result<Vec<Stmt>, VimlError> {
let line = cur.peek().expect("parse_one called at EOF");
let (cmd, rest) = cmd_word(&line);
if cmd == "export" {
let stripped = rest.trim_start().to_string();
cur.lines[cur.i].1 = stripped;
return parse_one(cur);
}
match canon_block_kw(cmd) {
"if" => {
cur.bump();
Ok(vec![parse_if(cur, rest)?])
}
"while" => {
cur.bump();
Ok(vec![parse_while(cur, rest)?])
}
"for" => {
cur.bump();
Ok(vec![parse_for(cur, rest)?])
}
"try" => {
cur.bump();
Ok(vec![parse_try(cur)?])
}
"function" => {
cur.bump();
let hdr = rest
.trim_start()
.strip_prefix('!')
.unwrap_or(rest)
.trim_start();
if !hdr.starts_with('/') && hdr.contains('(') {
Ok(vec![parse_function(cur, rest)?])
} else {
Ok(vec![Stmt::Expr(Expr::Number(0))])
}
}
"def" => {
cur.bump();
let hdr = rest
.trim_start()
.strip_prefix('!')
.unwrap_or(rest)
.trim_start();
if hdr.contains('(') {
Ok(vec![parse_def(cur, rest)?])
} else {
Ok(vec![Stmt::Expr(Expr::Number(0))])
}
}
_ => {
cur.bump();
if cmd_takes_bar_arg(cmd_word(strip_command_modifiers(line.trim())).0) {
return Ok(vec![parse_stmt(&line)?]);
}
let mut out = Vec::new();
for seg in split_commands(&line) {
if seg.trim().is_empty() {
continue;
}
out.push(parse_stmt(seg)?);
}
Ok(out)
}
}
}
fn cmd_takes_bar_arg(cmd: &str) -> bool {
matches!(
cmd,
"au" | "aut"
| "auto"
| "autoc"
| "autocm"
| "autocmd"
| "com"
| "comm"
| "command"
| "norm"
| "norma"
| "normal"
| "g"
| "gl"
| "glo"
| "glob"
| "globa"
| "global"
| "v"
| "vg"
| "vgl"
| "vglo"
| "vglob"
| "vgloba"
| "vglobal"
)
}
fn split_commands(line: &str) -> Vec<&str> {
let bytes = line.as_bytes();
let mut segs = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
let mut sq = false; let mut dq = false; let is_syntax_cmd = matches!(
cmd_word(strip_command_modifiers(line.trim())).0,
"sy" | "syn" | "synt" | "synta" | "syntax"
);
let mut slash = false; while i < bytes.len() {
let c = bytes[i];
if slash {
if c == b'\\' {
i += 2;
continue;
}
if c == b'/' {
slash = false;
}
i += 1;
continue;
}
if sq {
if c == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
sq = false;
}
i += 1;
continue;
}
if dq {
if c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
dq = false;
}
i += 1;
continue;
}
match c {
b'/' if is_syntax_cmd => slash = true,
b'\'' => sq = true,
b'"' => {
if line[start..i].trim().is_empty() {
let seg = &line[start..i];
if !seg.trim().is_empty() {
segs.push(seg);
}
return segs;
}
dq = true;
}
b'|' => {
if bytes.get(i + 1) == Some(&b'|') {
i += 2; continue;
}
if i > 0 && bytes[i - 1] == b'\\' {
i += 1; continue;
}
segs.push(&line[start..i]);
start = i + 1;
}
_ => {}
}
i += 1;
}
let tail = &line[start..];
if !tail.trim().is_empty() {
segs.push(tail);
}
segs
}
type ParseBlockResult = Result<(Vec<Stmt>, Option<(String, String)>), VimlError>;
fn parse_block(cur: &mut Lines, terms: &[&str]) -> ParseBlockResult {
let mut stmts = Vec::new();
loop {
cur.skip_blanks();
let Some(line) = cur.peek() else {
return Ok((stmts, None));
};
let (cmd, rest) = cmd_word(&line);
let ck = canon_block_kw(cmd);
if terms.contains(&ck) {
cur.bump();
return Ok((stmts, Some((ck.to_string(), rest.to_string()))));
}
if is_block_terminator(cmd) {
return Err(VimlError::msg(format!("E580: unexpected `:{cmd}`")));
}
stmts.extend(parse_one(cur)?);
}
}
const IF_TERMS: &[&str] = &["elseif", "else", "endif"];
fn strip_legacy_trailing_comment(s: &str) -> &str {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'\'' => {
i += 1;
while i < b.len() {
if b[i] == b'\'' {
if b.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
}
b'"' => {
let mut j = i + 1;
let mut closed = false;
while j < b.len() {
match b[j] {
b'\\' => j += 2,
b'"' => {
closed = true;
j += 1;
break;
}
_ => j += 1,
}
}
if closed {
i = j; } else {
return s[..i].trim_end(); }
}
_ => i += 1,
}
}
s.trim_end()
}
fn parse_if(cur: &mut Lines, cond_str: &str) -> Result<Stmt, VimlError> {
let mut arms = Vec::new();
let mut else_body = None;
let (body, mut term) = parse_block(cur, IF_TERMS)?;
arms.push((parse_expr(strip_legacy_trailing_comment(cond_str))?, body));
loop {
match term {
Some((ref c, ref rest)) if c == "elseif" => {
let cond = parse_expr(strip_legacy_trailing_comment(rest))?;
let (b, t) = parse_block(cur, IF_TERMS)?;
arms.push((cond, b));
term = t;
}
Some((ref c, _)) if c == "else" => {
let (b, t) = parse_block(cur, &["endif"])?;
else_body = Some(b);
if t.is_none() {
return Err(VimlError::msg("E171: Missing :endif"));
}
break;
}
Some((ref c, _)) if c == "endif" => break,
None => return Err(VimlError::msg("E171: Missing :endif")),
Some((c, _)) => return Err(VimlError::msg(format!("E580: unexpected `:{c}` in :if"))),
}
}
Ok(Stmt::If { arms, else_body })
}
fn parse_while(cur: &mut Lines, cond_str: &str) -> Result<Stmt, VimlError> {
let cond = parse_expr(strip_legacy_trailing_comment(cond_str))?;
let (body, term) = parse_block(cur, &["endwhile"])?;
if term.is_none() {
return Err(VimlError::msg("E170: Missing :endwhile"));
}
Ok(Stmt::While { cond, body })
}
fn parse_for(cur: &mut Lines, header: &str) -> Result<Stmt, VimlError> {
let idx = header
.find(" in ")
.ok_or_else(|| VimlError::msg("E690: Missing \"in\" after :for"))?;
let var = header[..idx].trim();
let vars = if let Some(inner) = var.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
ForVars::List(
inner
.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect(),
)
} else {
ForVars::One(var.to_string())
};
let iter = parse_expr(strip_legacy_trailing_comment(header[idx + 4..].trim()))?;
let (body, term) = parse_block(cur, &["endfor"])?;
if term.is_none() {
return Err(VimlError::msg("E170: Missing :endfor"));
}
Ok(Stmt::For { vars, iter, body })
}
fn strip_vim9_comment(s: &str) -> &str {
let b = s.as_bytes();
let mut i = 0;
let mut sq = false;
let mut dq = false;
let mut prev_ws = true; while i < b.len() {
let c = b[i];
if sq {
if c == b'\'' {
if b.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
sq = false;
}
prev_ws = false;
i += 1;
continue;
}
if dq {
if c == b'\\' {
i += 2;
prev_ws = false;
continue;
}
if c == b'"' {
dq = false;
}
prev_ws = false;
i += 1;
continue;
}
match c {
b'\'' => {
sq = true;
prev_ws = false;
}
b'"' => {
dq = true;
prev_ws = false;
}
b'#' if prev_ws => return &s[..i],
_ => prev_ws = c == b' ' || c == b'\t',
}
i += 1;
}
s
}
fn vim9_bracket_depth(s: &str) -> i32 {
let code = strip_vim9_comment(s);
let b = code.as_bytes();
let mut i = 0;
let mut depth = 0i32;
let mut sq = false;
let mut dq = false;
while i < b.len() {
let c = b[i];
if sq {
if c == b'\'' {
if b.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
sq = false;
}
i += 1;
continue;
}
if dq {
if c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
dq = false;
}
i += 1;
continue;
}
match c {
b'\'' => sq = true,
b'"' => dq = true,
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
_ => {}
}
i += 1;
}
depth
}
fn vim9_trailing_continues(s: &str) -> bool {
let code = strip_vim9_comment(s).trim_end();
for op in ["..", "&&", "||", "==", "!=", ">=", "<=", "=~", "!~", "->"] {
if code.ends_with(op) {
return true;
}
}
matches!(
code.as_bytes().last(),
Some(b'+' | b'-' | b'*' | b'%' | b'?' | b'&' | b'|' | b'=')
)
}
fn vim9_leading_continues(trimmed: &str) -> bool {
for op in ["->", "..", "&&", "||", "==", "!=", ">=", "<=", "=~", "!~"] {
if trimmed.starts_with(op) {
return true;
}
}
matches!(
trimmed.as_bytes().first(),
Some(b'+' | b'-' | b'*' | b'/' | b'%' | b'.' | b'?' | b')' | b']' | b'}' | b'|' | b'&')
)
}
fn vim9_open_ternary(s: &str) -> bool {
let code = strip_vim9_comment(s);
let b = code.as_bytes();
let mut i = 0;
let mut sq = false;
let mut dq = false;
let mut depth = 0i32;
let mut q = 0i32;
while i < b.len() {
let c = b[i];
if sq {
if c == b'\'' {
if b.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
sq = false;
}
i += 1;
continue;
}
if dq {
if c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
dq = false;
}
i += 1;
continue;
}
match c {
b'\'' => sq = true,
b'"' => dq = true,
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b'?' if depth == 0 => q += 1,
b':' if depth == 0 => q -= 1,
_ => {}
}
i += 1;
}
q > 0
}
fn find_top_eq(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut depth = 0i32;
let mut quote: Option<u8> = None;
for (i, &c) in b.iter().enumerate() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' => quote = Some(c),
b'[' | b'(' | b'{' => depth += 1,
b']' | b')' | b'}' => depth -= 1,
b'=' if depth == 0
&& !matches!(b.get(i.wrapping_sub(1)), Some(b'!' | b'<' | b'>' | b'='))
&& !matches!(b.get(i + 1), Some(b'=' | b'~')) =>
{
return Some(i);
}
_ => {}
},
}
}
None
}
fn find_top_colon(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut depth = 0i32;
let mut quote: Option<u8> = None;
for (i, &c) in b.iter().enumerate() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' => quote = Some(c),
b'[' | b'(' | b'{' => depth += 1,
b']' | b')' | b'}' => depth -= 1,
b':' if depth == 0 => return Some(i),
_ => {}
},
}
}
None
}
fn is_plain_ident(s: &str) -> bool {
!s.is_empty()
&& s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
&& !s.as_bytes()[0].is_ascii_digit()
}
fn strip_vim9_type(rest: &str) -> String {
let Some(eq) = find_top_eq(rest) else {
return rest.to_string();
};
let (decl, tail) = (rest[..eq].trim_end(), &rest[eq..]);
if decl.starts_with('[') {
return rest.to_string();
}
match find_top_colon(decl) {
Some(p) => format!("{} {}", decl[..p].trim_end(), tail),
None => rest.to_string(),
}
}
fn vim9_var_decl(rest: &str) -> String {
if find_top_eq(rest).is_some() {
return strip_vim9_type(rest);
}
let trimmed = rest.trim();
if trimmed.starts_with('[') {
return rest.to_string();
}
let Some(p) = find_top_colon(trimmed) else {
return rest.to_string();
};
let name = trimmed[..p].trim_end();
let outer: String = trimmed[p + 1..]
.trim_start()
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
let default = match outer.as_str() {
"string" => "''",
"number" => "0",
"float" => "0.0",
"bool" => "v:false",
"list" => "[]",
"dict" => "{}",
"blob" => "0z",
"any" => "0",
_ => return rest.to_string(),
};
format!("{name} = {default}")
}
fn is_vim9_assignment(line: &str) -> bool {
let b = line.as_bytes();
match b.first() {
Some(&c) if c.is_ascii_alphabetic() || c == b'_' || c == b'[' => {}
_ => return false,
}
let mut i = 0;
while i < b.len() {
match b[i] {
c if c.is_ascii_alphanumeric() || c == b'_' => i += 1,
b':' => i += 1, b'[' => {
let mut depth = 0i32;
let mut quote: Option<u8> = None;
while i < b.len() {
let c = b[i];
i += 1;
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' => quote = Some(c),
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
},
}
}
}
b'.' if b
.get(i + 1)
.is_some_and(|&c| c.is_ascii_alphabetic() || c == b'_') =>
{
i += 1
}
_ => break,
}
}
let op = line[i..].trim_start().as_bytes();
match op.first() {
Some(b'=') => !matches!(op.get(1), Some(b'=') | Some(b'~')),
Some(b'+') | Some(b'-') | Some(b'*') | Some(b'%') | Some(b'/') => op.get(1) == Some(&b'='),
Some(b'.') => op.get(1) == Some(&b'.') && op.get(2) == Some(&b'='),
_ => false,
}
}
fn parse_function(cur: &mut Lines, header: &str) -> Result<Stmt, VimlError> {
let _vim9 = Vim9Guard::enter(false);
let header = header.trim();
let (bang, header) = match header.strip_prefix('!') {
Some(rest) => (true, rest.trim()),
None => (false, header),
};
let lparen = header
.find('(')
.ok_or_else(|| VimlError::msg("E124: Missing '(' in :function"))?;
let name = header[..lparen].trim().to_string();
let rparen = {
let mut depth = 0i32;
let mut quote: Option<u8> = None;
let mut found = None;
for (i, &b) in header.as_bytes().iter().enumerate().skip(lparen) {
match quote {
Some(q) => {
if b == q {
quote = None;
}
}
None => match b {
b'\'' | b'"' => quote = Some(b),
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => {
depth -= 1;
if depth == 0 {
found = Some(i);
break;
}
}
_ => {}
},
}
}
found.ok_or_else(|| VimlError::msg("E125: Missing ')' in :function"))?
};
let mut args: Vec<String> = Vec::new();
let mut defaults: Vec<(usize, Expr)> = Vec::new();
for raw in split_top_commas(&header[lparen + 1..rparen]) {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
match raw.find('=').filter(|&p| {
raw[..p]
.trim_end()
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == ':')
&& !matches!(raw.as_bytes().get(p + 1), Some(b'=') | Some(b'~'))
}) {
Some(p) => {
defaults.push((args.len(), parse_expr(raw[p + 1..].trim())?));
args.push(raw[..p].trim().to_string());
}
None => args.push(raw.to_string()),
}
}
let (body, term) = parse_block(cur, &["endfunction"])?;
if term.is_none() {
return Err(VimlError::msg("E126: Missing :endfunction"));
}
Ok(Stmt::Function {
name,
args,
defaults,
body,
bang,
vim9: false,
})
}
fn parse_def(cur: &mut Lines, header: &str) -> Result<Stmt, VimlError> {
let _vim9 = Vim9Guard::enter(true);
let header = header.trim();
let header = header.strip_prefix('!').map_or(header, str::trim_start);
let lparen = header
.find('(')
.ok_or_else(|| VimlError::msg("E1055: Missing '(' in :def"))?;
let name = header[..lparen].trim().to_string();
let rparen = {
let mut depth = 0i32;
let mut quote: Option<u8> = None;
let mut found = None;
for (i, &b) in header.as_bytes().iter().enumerate().skip(lparen) {
match quote {
Some(q) => {
if b == q {
quote = None;
}
}
None => match b {
b'\'' | b'"' => quote = Some(b),
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => {
depth -= 1;
if depth == 0 {
found = Some(i);
break;
}
}
_ => {}
},
}
}
found.ok_or_else(|| VimlError::msg("E1055: Missing ')' in :def"))?
};
let mut args: Vec<String> = Vec::new();
let mut defaults: Vec<(usize, Expr)> = Vec::new();
for raw in split_top_commas(&header[lparen + 1..rparen]) {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
if raw.starts_with("...") {
args.push("...".to_string());
continue;
}
let (decl, default) = match find_top_eq(raw) {
Some(p) => (raw[..p].trim(), Some(raw[p + 1..].trim())),
None => (raw, None),
};
let pname = match find_top_colon(decl) {
Some(p) => decl[..p].trim(),
None => decl,
};
if let Some(d) = default {
defaults.push((args.len(), parse_expr(d)?));
}
args.push(pname.to_string());
}
let (body_stmts, term) = parse_block(cur, &["enddef"])?;
if term.is_none() {
return Err(VimlError::msg("E1057: Missing :enddef"));
}
let mut body: Vec<Stmt> = Vec::with_capacity(body_stmts.len() + args.len());
for p in &args {
if is_plain_ident(p) {
body.push(Stmt::Let {
target: LetTarget::Var(p.clone()),
expr: Expr::Var(format!("a:{p}")),
});
}
}
body.extend(body_stmts);
Ok(Stmt::Function {
name,
args,
defaults,
body,
bang: true,
vim9: true,
})
}
fn parse_try(cur: &mut Lines) -> Result<Stmt, VimlError> {
const TRY_TERMS: &[&str] = &["catch", "finally", "endtry"];
let (body, mut term) = parse_block(cur, TRY_TERMS)?;
let mut catches = Vec::new();
let mut finally = None;
loop {
match term {
Some((ref c, ref rest)) if c == "catch" => {
let pat = {
let r = rest.trim();
if r.is_empty() {
None
} else {
Some(r.trim_matches('/').to_string())
}
};
let (b, t) = parse_block(cur, TRY_TERMS)?;
catches.push((pat, b));
term = t;
}
Some((ref c, _)) if c == "finally" => {
let (b, t) = parse_block(cur, &["endtry"])?;
finally = Some(b);
if t.is_none() {
return Err(VimlError::msg("E170: Missing :endtry"));
}
break;
}
Some((ref c, _)) if c == "endtry" => break,
None => return Err(VimlError::msg("E170: Missing :endtry")),
Some((c, _)) => return Err(VimlError::msg(format!("E580: unexpected `:{c}` in :try"))),
}
}
Ok(Stmt::Try {
body,
catches,
finally,
})
}
fn parse_let(rest: &str) -> Result<Stmt, VimlError> {
let Some(eq) = rest.find('=') else {
return Ok(Stmt::Expr(Expr::Number(0)));
};
let op = match rest.as_bytes()[..eq].last() {
Some(b'+') => Some(ArithOp::Add),
Some(b'-') => Some(ArithOp::Sub),
Some(b'*') => Some(ArithOp::Mul),
Some(b'/') => Some(ArithOp::Div),
Some(b'%') => Some(ArithOp::Mod),
Some(b'.') => Some(ArithOp::Concat),
_ => None,
};
let lhs_end = match op {
Some(ArithOp::Concat) if eq >= 2 && rest.as_bytes()[eq - 2] == b'.' => eq - 2,
Some(_) => eq - 1,
None => eq,
};
let lhs = rest[..lhs_end].trim();
let rhs = rest[eq + 1..].trim();
let target = if let Some(inner) = lhs.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let (head, rest_name) = match inner.split_once(';') {
Some((h, r)) => (h, Some(r.trim().to_string())),
None => (inner, None),
};
let names = head
.split(',')
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
LetTarget::List {
names,
rest: rest_name,
}
} else if let Some(name) = lhs.strip_prefix('&') {
LetTarget::Option(name.to_string())
} else if let Some(name) = lhs.strip_prefix('$') {
LetTarget::Env(name.to_string())
} else if let Some(reg) = lhs.strip_prefix('@') {
LetTarget::Register(reg.chars().next().unwrap_or('"'))
} else if lhs.ends_with(']') && lhs.contains('[') {
let bytes = lhs.as_bytes();
let mut depth = 0i32;
let mut open = 0;
for i in (0..lhs.len()).rev() {
match bytes[i] {
b']' => depth += 1,
b'[' => {
depth -= 1;
if depth == 0 {
open = i;
break;
}
}
_ => {}
}
}
let base_src = lhs[..open].trim();
let index_src = &lhs[open + 1..lhs.len() - 1];
match split_top_colon(index_src) {
Some((a, b)) => {
let parse_opt = |s: &str| -> Result<Option<Box<Expr>>, VimlError> {
let s = s.trim();
Ok(if s.is_empty() {
None
} else {
Some(Box::new(parse_expr(s)?))
})
};
LetTarget::Range {
base: Box::new(parse_expr(base_src)?),
idx1: parse_opt(a)?,
idx2: parse_opt(b)?,
}
}
None => LetTarget::Index {
base: Box::new(parse_expr(base_src)?),
index: Box::new(parse_expr(index_src)?),
},
}
} else if !lhs.contains('[')
&& lhs.contains('.')
&& lhs.rsplit_once('.').is_some_and(|(_, k)| {
!k.is_empty() && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
})
{
let (base, key) = lhs.rsplit_once('.').unwrap();
LetTarget::Index {
base: Box::new(parse_expr(base)?),
index: Box::new(Expr::Str(key.to_string())),
}
} else {
LetTarget::Var(lhs.to_string())
};
let rhs = strip_legacy_trailing_comment(rhs);
let expr = match op {
None => parse_expr(rhs)?,
Some(op) => {
let cur = let_target_expr(&target)?;
Expr::Arith {
op,
lhs: Box::new(cur),
rhs: Box::new(parse_expr(rhs)?),
}
}
};
Ok(Stmt::Let { target, expr })
}
fn split_unlet_args(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut depth = 0i32;
let mut quote: Option<u8> = None;
let mut start: Option<usize> = None;
for (i, &c) in bytes.iter().enumerate() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' => quote = Some(c),
b'[' | b'(' => depth += 1,
b']' | b')' => depth -= 1,
_ if c.is_ascii_whitespace() && depth == 0 => {
if let Some(st) = start.take() {
out.push(&s[st..i]);
}
continue;
}
_ => {}
},
}
if start.is_none() {
start = Some(i);
}
}
if let Some(st) = start {
out.push(&s[st..]);
}
out
}
fn split_top_commas(s: &str) -> Vec<&str> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut depth = 0i32;
let mut quote: Option<u8> = None;
let mut start = 0usize;
for (i, &c) in bytes.iter().enumerate() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' => quote = Some(c),
b'[' | b'(' | b'{' => depth += 1,
b']' | b')' | b'}' => depth -= 1,
b',' if depth == 0 => {
out.push(&s[start..i]);
start = i + 1;
}
_ => {}
},
}
}
out.push(&s[start..]);
out
}
fn heredoc_opener(line: &str) -> Option<(String, bool, bool, String)> {
let (cmd, _) = cmd_word(line.trim_start());
if !matches!(cmd, "let" | "const" | "cons" | "var" | "final") {
return None;
}
let op = line.find("=<<")?;
let prefix = line[..op].to_string();
let mut rest = line[op + 3..].trim_start();
let (mut trim, mut eval) = (false, false);
loop {
let kw = |r: &str, w: &str| -> bool {
r.strip_prefix(w)
.is_some_and(|t| t.is_empty() || t.starts_with(char::is_whitespace))
};
if kw(rest, "trim") {
trim = true;
rest = rest[4..].trim_start();
} else if kw(rest, "eval") {
eval = true;
rest = rest[4..].trim_start();
} else {
break;
}
}
let marker = rest.split_whitespace().next()?;
if marker.is_empty() {
return None;
}
Some((prefix, trim, eval, marker.to_string()))
}
fn script_lang_heredoc_marker(line: &str) -> Option<(bool, String)> {
let line = line.trim_start();
let end = line
.find(|c: char| !c.is_ascii_alphanumeric())
.unwrap_or(line.len());
let (cmd, rest) = (&line[..end], line[end..].trim_start());
if !matches!(
cmd,
"python"
| "py"
| "python3"
| "py3"
| "pythonx"
| "pyx"
| "perl"
| "ruby"
| "lua"
| "tcl"
| "mzscheme"
| "mz"
) {
return None;
}
let mut r = rest.strip_prefix("<<")?.trim_start();
let mut trim = false;
loop {
let kw = |s: &str, w: &str| -> bool {
s.strip_prefix(w)
.is_some_and(|t| t.is_empty() || t.starts_with(char::is_whitespace))
};
if kw(r, "trim") {
trim = true;
r = r[4..].trim_start();
} else if kw(r, "eval") {
r = r[4..].trim_start();
} else {
break;
}
}
let marker = match r.split_whitespace().next() {
Some(m) if !m.starts_with('"') => m.to_string(),
_ => ".".to_string(),
};
Some((trim, marker))
}
fn parse_unlet_arg(arg: &str) -> Result<UnletArg, VimlError> {
let arg = arg.trim();
if arg.ends_with(']') && arg.contains('[') {
let bytes = arg.as_bytes();
let mut depth = 0i32;
let mut open = 0;
for i in (0..arg.len()).rev() {
match bytes[i] {
b']' => depth += 1,
b'[' => {
depth -= 1;
if depth == 0 {
open = i;
break;
}
}
_ => {}
}
}
let base_src = arg[..open].trim();
let index_src = &arg[open + 1..arg.len() - 1];
if split_top_colon(index_src).is_none() {
return Ok(UnletArg::Item {
base: Box::new(parse_expr(base_src)?),
index: Box::new(parse_expr(index_src)?),
});
}
} else if !arg.contains('[')
&& arg.contains('.')
&& arg.rsplit_once('.').is_some_and(|(_, k)| {
!k.is_empty() && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
})
{
let (base, key) = arg.rsplit_once('.').unwrap();
return Ok(UnletArg::Item {
base: Box::new(parse_expr(base)?),
index: Box::new(Expr::Str(key.to_string())),
});
}
Ok(UnletArg::Name(arg.to_string()))
}
fn split_top_colon(s: &str) -> Option<(&str, &str)> {
let bytes = s.as_bytes();
let mut depth = 0i32;
let mut quote: Option<u8> = None;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' => quote = Some(c),
b'[' | b'(' => depth += 1,
b']' | b')' => depth -= 1,
b':' if depth == 0 => {
let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let scope_prefix = i >= 1
&& matches!(
bytes[i - 1],
b's' | b'g' | b'b' | b'w' | b't' | b'l' | b'a' | b'v'
)
&& (i == 1 || !is_ident(bytes[i - 2]))
&& i + 1 < bytes.len()
&& is_ident(bytes[i + 1]);
if !scope_prefix {
return Some((&s[..i], &s[i + 1..]));
}
}
_ => {}
},
}
i += 1;
}
None
}
fn let_target_expr(target: &LetTarget) -> Result<Expr, VimlError> {
Ok(match target {
LetTarget::Var(n) => Expr::Var(n.clone()),
LetTarget::Option(n) => Expr::Option(n.clone()),
LetTarget::Env(n) => Expr::Env(n.clone()),
LetTarget::Register(c) => Expr::Register(*c),
LetTarget::Index { base, index } => Expr::Index {
base: base.clone(),
index: index.clone(),
},
LetTarget::List { .. } | LetTarget::Range { .. } => {
return Err(VimlError::msg("E734: Wrong variable type for +="))
}
})
}
fn parse_expr_list(src: &str) -> Result<Vec<Expr>, VimlError> {
if src.trim().is_empty() {
return Ok(Vec::new());
}
let toks = lex(src)?;
let mut p = Parser::new(toks, src);
let mut out = Vec::new();
loop {
out.push(p.eval1()?);
if matches!(p.peek(), Tok::Eof) {
break;
}
}
Ok(out)
}
pub fn parse_expr(src: &str) -> Result<Expr, VimlError> {
let toks = lex(src)?;
let mut p = Parser::new(toks, src);
let e = p.eval1()?;
if !matches!(p.peek(), Tok::Eof) {
return Err(VimlError::msg(
"E15: Invalid expression: trailing tokens".to_string(),
));
}
Ok(e)
}
struct Parser {
toks: Vec<Token>,
i: usize,
src: String,
depth: u32,
}
impl Parser {
const EXPR_MAX_DEPTH: u32 = 1000;
fn new(toks: Vec<Token>, src: &str) -> Self {
Parser {
toks,
i: 0,
src: src.to_string(),
depth: 0,
}
}
fn nested_eval1(&mut self) -> Result<Expr, VimlError> {
self.depth += 1;
if self.depth >= Self::EXPR_MAX_DEPTH {
self.depth -= 1;
let tail = self.src.get(self.toks[self.i].span..).unwrap_or("");
return Err(VimlError::msg(format!(
"E1169: Expression too recursive: {tail}"
)));
}
let r = self.eval1();
self.depth -= 1;
r
}
fn peek(&self) -> &Tok {
&self.toks[self.i].kind
}
fn advance(&mut self) -> Tok {
let t = self.toks[self.i].kind.clone();
if self.i + 1 < self.toks.len() {
self.i += 1;
}
t
}
fn eat(&mut self, want: &Tok) -> Result<(), VimlError> {
if self.peek() == want {
self.advance();
Ok(())
} else {
Err(VimlError::msg(format!(
"E15: expected {want:?}, found {:?}",
self.peek()
)))
}
}
fn eval1(&mut self) -> Result<Expr, VimlError> {
let cond = self.eval2()?;
match self.peek() {
Tok::Question => {
self.advance();
let then = self.eval1()?;
self.eat(&Tok::Colon)?;
let otherwise = self.eval1()?;
Ok(Expr::Ternary {
cond: Box::new(cond),
then: Box::new(then),
otherwise: Box::new(otherwise),
})
}
Tok::QuestionQuestion => {
self.advance();
let rhs = self.eval1()?;
Ok(Expr::Coalesce(Box::new(cond), Box::new(rhs)))
}
_ => Ok(cond),
}
}
fn eval2(&mut self) -> Result<Expr, VimlError> {
let mut lhs = self.eval3()?;
while matches!(self.peek(), Tok::OrOr) {
self.advance();
let rhs = self.eval3()?;
lhs = Expr::Or(Box::new(lhs), Box::new(rhs));
}
Ok(lhs)
}
fn eval3(&mut self) -> Result<Expr, VimlError> {
let mut lhs = self.eval4()?;
while matches!(self.peek(), Tok::AndAnd) {
self.advance();
let rhs = self.eval4()?;
lhs = Expr::And(Box::new(lhs), Box::new(rhs));
}
Ok(lhs)
}
fn eval4(&mut self) -> Result<Expr, VimlError> {
let lhs = self.eval5()?;
let (op, case) = match self.peek() {
Tok::Cmp(op, case) => (*op, *case),
Tok::Ident(id) if id == "is" => (CmpOp::Is, CaseFlag::Default),
Tok::Ident(id) if id == "isnot" => (CmpOp::IsNot, CaseFlag::Default),
_ => return Ok(lhs),
};
self.advance();
let rhs = self.eval5()?;
Ok(Expr::Compare {
op,
case,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
})
}
fn eval5(&mut self) -> Result<Expr, VimlError> {
let mut lhs = self.eval6()?;
loop {
let op = match self.peek() {
Tok::Plus => ArithOp::Add,
Tok::Minus => ArithOp::Sub,
Tok::Dot | Tok::DotDot => ArithOp::Concat,
_ => break,
};
self.advance();
let rhs = self.eval6()?;
lhs = Expr::Arith {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
};
}
Ok(lhs)
}
fn eval6(&mut self) -> Result<Expr, VimlError> {
let mut lhs = self.eval7()?;
loop {
let op = match self.peek() {
Tok::Star => ArithOp::Mul,
Tok::Slash => ArithOp::Div,
Tok::Percent => ArithOp::Mod,
_ => break,
};
self.advance();
let rhs = self.eval7()?;
lhs = Expr::Arith {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
};
}
Ok(lhs)
}
fn eval7(&mut self) -> Result<Expr, VimlError> {
let mut leaders = Vec::new();
loop {
match self.peek() {
Tok::Bang => {
self.advance();
leaders.push(UnaryOp::Not);
}
Tok::Minus => {
self.advance();
leaders.push(UnaryOp::Neg);
}
Tok::Plus => {
self.advance();
leaders.push(UnaryOp::Plus);
}
_ => break,
}
}
let mut e = self.primary()?;
e = self.postfix(e)?;
for op in leaders.into_iter().rev() {
e = Expr::Unary {
op,
expr: Box::new(e),
};
}
Ok(e)
}
fn primary(&mut self) -> Result<Expr, VimlError> {
match self.advance() {
Tok::Number(n) => Ok(Expr::Number(n)),
Tok::Float(f) => Ok(Expr::Float(f)),
Tok::Blob(bytes) => Ok(Expr::Call {
name: "list2blob".to_string(),
args: vec![Expr::List(
bytes.into_iter().map(|b| Expr::Number(b as i64)).collect(),
)],
}),
Tok::Str(s) => Ok(Expr::Str(s)),
Tok::InterpStr(parts) => self.lower_interp(parts),
Tok::Option(o) => Ok(Expr::Option(o)),
Tok::Env(e) => Ok(Expr::Env(e)),
Tok::Register(r) => Ok(Expr::Register(r)),
Tok::LParen => {
if self.at_vim9_lambda() {
return self.vim9_lambda();
}
let e = self.nested_eval1()?;
self.eat(&Tok::RParen)?;
Ok(e)
}
Tok::LBracket => self.list_literal(),
Tok::LBrace => {
if self.at_lambda() {
self.lambda()
} else {
self.dict_literal()
}
}
Tok::HashBrace => self.literal_dict(),
Tok::Ident(name) => {
if matches!(self.peek(), Tok::LParen) {
self.advance();
let args = self.arg_list(&Tok::RParen)?;
Ok(Expr::Call { name, args })
} else if vim9_active() {
match name.as_str() {
"true" => Ok(Expr::Var("v:true".to_string())),
"false" => Ok(Expr::Var("v:false".to_string())),
"null" => Ok(Expr::Var("v:null".to_string())),
"null_string" => Ok(Expr::Str(String::new())),
"null_list" => Ok(Expr::List(Vec::new())),
"null_dict" => Ok(Expr::Dict(Vec::new())),
"null_blob" => Ok(Expr::Call {
name: "list2blob".to_string(),
args: vec![Expr::List(Vec::new())],
}),
"null_function" | "null_partial" => Ok(Expr::Call {
name: "function".to_string(),
args: vec![Expr::Str(String::new())],
}),
_ => Ok(Expr::Var(name)),
}
} else {
Ok(Expr::Var(name))
}
}
other => Err(VimlError::msg(format!(
"E15: Invalid expression: unexpected {other:?}"
))),
}
}
fn lower_interp(&mut self, parts: Vec<InterpPart>) -> Result<Expr, VimlError> {
let mut segs = Vec::with_capacity(parts.len());
for part in parts {
match part {
InterpPart::Lit(s) => segs.push(Expr::Str(s)),
InterpPart::Expr(src) => segs.push(parse_expr(&src)?),
}
}
Ok(Expr::Interp(segs))
}
fn lparen_abuts_prev(&self) -> bool {
let i = self.i;
i > 0
&& self.toks.get(i).is_some_and(|t| t.kind == Tok::LParen)
&& self.toks[i].span == self.toks[i - 1].end
}
fn at_member_dot(&self) -> bool {
let i = self.i;
if i == 0 || i + 1 >= self.toks.len() {
return false;
}
let dot = &self.toks[i];
if dot.kind != Tok::Dot {
return false;
}
let prev = &self.toks[i - 1];
let next = &self.toks[i + 1];
let followed_by_call =
matches!(self.toks.get(i + 2), Some(t) if t.kind == Tok::LParen && t.span == next.end);
matches!(next.kind, Tok::Ident(_))
&& dot.span == prev.end && next.span == dot.end && !followed_by_call
}
fn postfix(&mut self, mut base: Expr) -> Result<Expr, VimlError> {
loop {
if self.at_member_dot()
&& !matches!(
base,
Expr::Number(_) | Expr::Float(_) | Expr::Str(_) | Expr::List(_)
)
{
self.advance(); if let Tok::Ident(key) = self.advance() {
base = Expr::Member {
base: Box::new(base),
key,
};
continue;
}
}
if matches!(self.peek(), Tok::LParen) && self.lparen_abuts_prev() {
self.advance(); let args = self.arg_list(&Tok::RParen)?;
base = Expr::CallExpr {
callee: Box::new(base),
args,
};
continue;
}
match self.peek() {
Tok::LBracket => {
self.advance();
base = self.subscript(base)?;
}
Tok::Arrow => {
self.advance();
let name = match self.advance() {
Tok::Ident(n) => n,
other => {
return Err(VimlError::msg(format!(
"E15: expected method name after '->', found {other:?}"
)))
}
};
self.eat(&Tok::LParen)?;
let args = self.arg_list(&Tok::RParen)?;
base = Expr::Method {
base: Box::new(base),
name,
args,
};
}
_ => break,
}
}
Ok(base)
}
fn subscript(&mut self, base: Expr) -> Result<Expr, VimlError> {
if matches!(self.peek(), Tok::Colon) {
self.advance();
let to = if matches!(self.peek(), Tok::RBracket) {
None
} else {
Some(Box::new(self.eval1()?))
};
self.eat(&Tok::RBracket)?;
return Ok(Expr::Slice {
base: Box::new(base),
from: None,
to,
});
}
let first = self.eval1()?;
if matches!(self.peek(), Tok::Colon) {
self.advance();
let to = if matches!(self.peek(), Tok::RBracket) {
None
} else {
Some(Box::new(self.eval1()?))
};
self.eat(&Tok::RBracket)?;
Ok(Expr::Slice {
base: Box::new(base),
from: Some(Box::new(first)),
to,
})
} else {
self.eat(&Tok::RBracket)?;
Ok(Expr::Index {
base: Box::new(base),
index: Box::new(first),
})
}
}
fn list_literal(&mut self) -> Result<Expr, VimlError> {
Ok(Expr::List(self.arg_list(&Tok::RBracket)?))
}
fn at_lambda(&self) -> bool {
let mut j = self.i;
if matches!(self.toks.get(j).map(|t| &t.kind), Some(Tok::Arrow)) {
return true; }
loop {
if !matches!(self.toks.get(j).map(|t| &t.kind), Some(Tok::Ident(_))) {
return false;
}
j += 1;
match self.toks.get(j).map(|t| &t.kind) {
Some(Tok::Arrow) => return true,
Some(Tok::Comma) => j += 1,
_ => return false,
}
}
}
fn at_vim9_lambda(&self) -> bool {
if !vim9_active() {
return false;
}
let mut j = self.i;
let mut depth = 1i32;
while depth > 0 {
match self.toks.get(j).map(|t| &t.kind) {
None | Some(Tok::Eof) => return false,
Some(Tok::LParen) | Some(Tok::LBracket) | Some(Tok::LBrace) => depth += 1,
Some(Tok::RParen) | Some(Tok::RBracket) | Some(Tok::RBrace) => depth -= 1,
_ => {}
}
j += 1;
}
match self.toks.get(j).map(|t| &t.kind) {
Some(Tok::FatArrow) => true,
Some(Tok::Colon) => {
let mut k = j + 1;
let mut d = 0i32;
while let Some(t) = self.toks.get(k) {
match &t.kind {
Tok::FatArrow if d == 0 => return true,
Tok::LParen | Tok::LBracket | Tok::LBrace => d += 1,
Tok::RParen | Tok::RBracket | Tok::RBrace => {
if d == 0 {
return false;
}
d -= 1;
}
Tok::Comma if d == 0 => return false,
Tok::Eof => return false,
_ => {}
}
k += 1;
}
false
}
_ => false,
}
}
fn vim9_lambda(&mut self) -> Result<Expr, VimlError> {
let mut params = Vec::new();
if !matches!(self.peek(), Tok::RParen) {
loop {
match self.advance() {
Tok::Ident(n) => match n.find(':') {
Some(pos) => {
params.push(n[..pos].to_string());
self.skip_type();
}
None => params.push(n),
},
other => {
return Err(VimlError::msg(format!(
"E15: expected lambda parameter, found {other:?}"
)))
}
}
if matches!(self.peek(), Tok::Colon) {
self.advance();
self.skip_type();
}
match self.peek() {
Tok::Comma => {
self.advance();
}
Tok::RParen => break,
other => {
return Err(VimlError::msg(format!(
"E15: expected ',' or ')' in lambda, found {other:?}"
)))
}
}
}
}
self.eat(&Tok::RParen)?;
if matches!(self.peek(), Tok::Colon) {
self.advance();
self.skip_type();
}
self.eat(&Tok::FatArrow)?;
let body = self.eval1()?;
Ok(Expr::Lambda {
params,
body: Box::new(body),
})
}
fn skip_type(&mut self) {
let mut angle = 0i32;
let mut paren = 0i32;
loop {
match self.peek() {
Tok::Cmp(CmpOp::Less, _) => {
angle += 1;
self.advance();
}
Tok::Cmp(CmpOp::Greater, _) if angle > 0 => {
angle -= 1;
self.advance();
}
Tok::LParen | Tok::LBracket => {
paren += 1;
self.advance();
}
Tok::RParen | Tok::RBracket if paren > 0 => {
paren -= 1;
self.advance();
}
Tok::Comma | Tok::RParen | Tok::FatArrow if angle == 0 && paren == 0 => break,
Tok::Eof => break,
_ => {
self.advance();
}
}
}
}
fn lambda(&mut self) -> Result<Expr, VimlError> {
let mut params = Vec::new();
if !matches!(self.peek(), Tok::Arrow) {
loop {
match self.advance() {
Tok::Ident(n) => params.push(n),
other => {
return Err(VimlError::msg(format!(
"E15: expected lambda parameter, found {other:?}"
)))
}
}
match self.peek() {
Tok::Comma => {
self.advance();
}
Tok::Arrow => break,
other => {
return Err(VimlError::msg(format!(
"E15: expected ',' or '->' in lambda, found {other:?}"
)))
}
}
}
}
self.eat(&Tok::Arrow)?;
let body = self.eval1()?;
self.eat(&Tok::RBrace)?;
Ok(Expr::Lambda {
params,
body: Box::new(body),
})
}
fn literal_dict(&mut self) -> Result<Expr, VimlError> {
let mut pairs = Vec::new();
if matches!(self.peek(), Tok::RBrace) {
self.advance();
return Ok(Expr::Dict(pairs));
}
loop {
let raw = match self.advance() {
Tok::Ident(s) => s,
Tok::Number(n) => n.to_string(),
Tok::Str(s) => s,
other => {
return Err(VimlError::msg(format!(
"E15: expected literal Dict key, found {other:?}"
)))
}
};
let (key, val) = if let Some(stripped) = raw.strip_suffix(':') {
(stripped.to_string(), self.nested_eval1()?)
} else if let Some(c) = raw.find(':') {
(raw[..c].to_string(), parse_expr(&raw[c + 1..])?)
} else {
self.eat(&Tok::Colon)?;
(raw, self.nested_eval1()?)
};
pairs.push((Expr::Str(key), val));
match self.advance() {
Tok::Comma => {
if matches!(self.peek(), Tok::RBrace) {
self.advance();
break;
}
}
Tok::RBrace => break,
other => {
return Err(VimlError::msg(format!(
"E15: expected ',' or '}}' in #{{}}, found {other:?}"
)))
}
}
}
Ok(Expr::Dict(pairs))
}
fn dict_literal(&mut self) -> Result<Expr, VimlError> {
let mut pairs = Vec::new();
if matches!(self.peek(), Tok::RBrace) {
self.advance();
return Ok(Expr::Dict(pairs));
}
let vim9 = vim9_active();
loop {
let key = if vim9 {
self.vim9_dict_key()?
} else {
let k = self.eval1()?;
self.eat(&Tok::Colon)?;
k
};
let val = self.nested_eval1()?;
pairs.push((key, val));
match self.advance() {
Tok::Comma => {
if matches!(self.peek(), Tok::RBrace) {
self.advance();
break;
}
}
Tok::RBrace => break,
other => {
return Err(VimlError::msg(format!(
"E15: expected ',' or '}}' in dict, found {other:?}"
)))
}
}
}
Ok(Expr::Dict(pairs))
}
fn vim9_dict_key(&mut self) -> Result<Expr, VimlError> {
if let Tok::Str(s) = self.peek().clone() {
self.advance();
self.eat(&Tok::Colon)?;
return Ok(Expr::Str(s));
}
if matches!(self.peek(), Tok::LBracket) {
self.advance();
let key = self.eval1()?;
self.eat(&Tok::RBracket)?;
self.eat(&Tok::Colon)?;
return Ok(key);
}
let start = self.toks[self.i].span;
let bytes = self.src.as_bytes();
let mut p = start;
while p < bytes.len()
&& (bytes[p].is_ascii_alphanumeric() || bytes[p] == b'_' || bytes[p] == b'-')
{
p += 1;
}
if p == start {
return Err(VimlError::msg(format!(
"E15: expected literal Dict key, found {:?}",
self.peek()
)));
}
let key = self.src[start..p].to_string();
let mut colon = p;
while colon < bytes.len() && (bytes[colon] == b' ' || bytes[colon] == b'\t') {
colon += 1;
}
if colon >= bytes.len() || bytes[colon] != b':' {
return Err(VimlError::msg(
"E720: Missing colon in Dictionary".to_string(),
));
}
while self.toks[self.i].span <= colon && !matches!(self.peek(), Tok::Eof) {
self.advance();
}
Ok(Expr::Str(key))
}
fn arg_list(&mut self, close: &Tok) -> Result<Vec<Expr>, VimlError> {
let mut args = Vec::new();
if self.peek() == close {
self.advance();
return Ok(args);
}
loop {
args.push(self.nested_eval1()?);
match self.advance() {
Tok::Comma => {
if self.peek() == close {
self.advance();
break;
}
}
ref t if t == close => break,
other => {
return Err(VimlError::msg(format!(
"E15: expected ',' or {close:?}, found {other:?}"
)))
}
}
}
Ok(args)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_modifiers_are_stripped() {
assert!(matches!(
parse_stmt("silent! colorscheme molokai").unwrap(),
Stmt::Colorscheme(n) if n == "molokai"
));
assert!(matches!(
parse_stmt("silent noautocmd verbose 9 set number").unwrap(),
Stmt::Set(a) if a == "number"
));
assert!(matches!(parse_stmt("silent").unwrap(), Stmt::Expr(_)));
assert!(matches!(
parse_stmt("Silentcmd arg").unwrap(),
Stmt::UserCmd(_)
));
}
#[test]
fn tolerant_parse_skips_bad_statements() {
let src = "set number\nlet x = \"oops\ncolorscheme molokai\n";
let (stmts, errs) = parse_program_lines_tolerant(src);
assert_eq!(errs.len(), 1, "one statement skipped");
assert!(stmts
.iter()
.any(|(_, s)| matches!(s, Stmt::Set(a) if a == "number")));
assert!(stmts
.iter()
.any(|(_, s)| matches!(s, Stmt::Colorscheme(n) if n == "molokai")));
}
#[test]
fn precedence_add_mul() {
match parse_expr("1 + 2 * 3").unwrap() {
Expr::Arith {
op: ArithOp::Add,
rhs,
..
} => assert!(matches!(
*rhs,
Expr::Arith {
op: ArithOp::Mul,
..
}
)),
e => panic!("bad tree: {e:?}"),
}
}
#[test]
fn dict_member_dot_vs_concat() {
match parse_expr("d.key").unwrap() {
Expr::Member { key, .. } => assert_eq!(key, "key"),
e => panic!("expected Member, got {e:?}"),
}
assert!(matches!(parse_expr("d.a.b").unwrap(), Expr::Member { .. }));
assert!(matches!(
parse_expr("a . b").unwrap(),
Expr::Arith {
op: ArithOp::Concat,
..
}
));
assert!(matches!(
parse_expr("'x' .. 'y'").unwrap(),
Expr::Arith {
op: ArithOp::Concat,
..
}
));
}
#[test]
fn literal_key_dict() {
match parse_expr("#{a: 1, name: 'x'}").unwrap() {
Expr::Dict(pairs) => {
assert_eq!(pairs.len(), 2);
assert!(matches!(&pairs[0].0, Expr::Str(s) if s == "a"));
assert!(matches!(&pairs[1].0, Expr::Str(s) if s == "name"));
}
e => panic!("expected Dict, got {e:?}"),
}
assert!(matches!(parse_expr("#{}").unwrap(), Expr::Dict(_)));
}
#[test]
fn one_line_blocks() {
assert!(matches!(
parse_program("if 1 | echo 'y' | endif").unwrap().as_slice(),
[Stmt::If { .. }]
));
match parse_program("let x = 5 | if x > 3 | echo 'big' | endif")
.unwrap()
.as_slice()
{
[Stmt::Let { .. }, Stmt::If { .. }] => {}
s => panic!("expected [Let, If], got {s:?}"),
}
assert!(matches!(
parse_program("for i in [1] | echo i | endfor")
.unwrap()
.as_slice(),
[Stmt::For { .. }]
));
assert_eq!(parse_program("let a = 1 | echo a").unwrap().len(), 2);
}
#[test]
fn line_continuation() {
let prog = parse_program("let x = [1,\n \\ 2,\n \\ 3]").unwrap();
match &prog[0] {
Stmt::Let {
expr: Expr::List(items),
..
} => assert_eq!(items.len(), 3),
s => panic!("expected 3-item list, got {s:?}"),
}
let lines = parse_program_lines("let a = 1\nlet b = [10,\n \\ 20]\nlet c = 3").unwrap();
assert_eq!(lines[0].0, 1);
assert_eq!(lines[1].0, 2); assert_eq!(lines[2].0, 4); }
#[test]
fn lambda_vs_dict() {
assert!(matches!(
parse_expr("{x -> x + 1}").unwrap(),
Expr::Lambda { .. }
));
assert!(matches!(
parse_expr("{-> 42}").unwrap(),
Expr::Lambda { .. }
));
match parse_expr("{a, b -> a - b}").unwrap() {
Expr::Lambda { params, .. } => assert_eq!(params, vec!["a", "b"]),
e => panic!("expected lambda, got {e:?}"),
}
assert!(matches!(parse_expr("{'a': 1}").unwrap(), Expr::Dict(_)));
assert!(matches!(
parse_expr("{'k': v, 'j': w}").unwrap(),
Expr::Dict(_)
));
assert!(matches!(parse_expr("{}").unwrap(), Expr::Dict(_)));
}
#[test]
fn collections_and_stmts() {
assert!(matches!(parse_expr("[1, 2, 3]").unwrap(), Expr::List(_)));
assert!(matches!(parse_expr("x[1:2]").unwrap(), Expr::Slice { .. }));
assert!(matches!(parse_stmt("echo 1 + 1").unwrap(), Stmt::Echo(_)));
assert!(matches!(parse_stmt("let x = 5").unwrap(), Stmt::Let { .. }));
}
}