doot/crates/doot-lang/src/parser.rs

1125 lines
43 KiB
Rust

//! Parser for the doot language.
use crate::ast::*;
use crate::lexer::Token;
use chumsky::Parser as _;
use chumsky::prelude::*;
use std::collections::HashMap;
/// Parses tokens into an AST.
pub struct Parser;
type ParserInput = crate::lexer::Spanned<Token>;
impl Parser {
/// Parses a token stream into a program AST.
#[tracing::instrument(skip_all)]
pub fn parse(tokens: Vec<ParserInput>) -> Result<Program, Vec<Simple<Token>>> {
let stream = tokens
.into_iter()
.map(|t| (t.node, t.span))
.collect::<Vec<_>>();
let len = stream.last().map(|(_, s)| s.end).unwrap_or(0);
let stream = chumsky::Stream::from_iter(len..len + 1, stream.into_iter());
Self::program_parser().parse(stream)
}
fn program_parser() -> impl chumsky::Parser<Token, Program, Error = Simple<Token>> {
Self::statement_parser()
.repeated()
.map(|statements| Program { statements })
.then_ignore(end())
}
fn statement_parser() -> impl chumsky::Parser<Token, Spanned<Statement>, Error = Simple<Token>>
{
recursive(|stmt| {
let whitespace = just(Token::Newline).repeated();
let var_decl = Self::var_decl_parser().map(Statement::VarDecl);
let fn_decl = Self::fn_decl_parser(stmt.clone()).map(Statement::FnDecl);
let struct_decl = Self::struct_decl_parser(stmt.clone()).map(Statement::StructDecl);
let enum_decl = Self::enum_decl_parser().map(Statement::EnumDecl);
let type_alias = Self::type_alias_parser().map(Statement::TypeAlias);
let import = Self::import_parser().map(Statement::Import);
let dotfile = Self::dotfile_parser().map(|d| Statement::Dotfile(Box::new(d)));
let package = Self::package_parser().map(|p| Statement::Package(Box::new(p)));
let brew = Self::brew_parser().map(Statement::Brew);
let secret = Self::secret_parser().map(Statement::Secret);
let encrypted = Self::encrypted_parser().map(Statement::Encrypted);
let hook = Self::hook_parser().map(Statement::Hook);
let simple_hook = Self::simple_hook_parser().map(Statement::Hook);
let macro_decl = Self::macro_decl_parser(stmt.clone()).map(Statement::MacroDecl);
let macro_call = Self::macro_call_parser().map(Statement::MacroCall);
let for_loop = Self::for_loop_parser(stmt.clone()).map(Statement::ForLoop);
let if_stmt = Self::if_parser(stmt.clone()).map(Statement::If);
let match_stmt = Self::match_parser().map(Statement::Match);
let return_stmt = just(Token::Return)
.ignore_then(Self::expr_parser().or_not())
.map(Statement::Return);
let expr_stmt = Self::expr_parser().map(Statement::Expr);
choice((
fn_decl,
struct_decl,
enum_decl,
type_alias,
import,
dotfile,
package,
brew,
secret,
encrypted,
hook,
simple_hook,
macro_decl,
macro_call,
for_loop,
if_stmt,
match_stmt,
return_stmt,
var_decl,
expr_stmt,
))
.map_with_span(Spanned::new)
.padded_by(whitespace)
})
}
fn var_decl_parser() -> impl chumsky::Parser<Token, VarDecl, Error = Simple<Token>> {
Self::ident_parser()
.then(
just(Token::Colon)
.ignore_then(Self::type_annotation_parser())
.or_not(),
)
.then_ignore(just(Token::Eq))
.then(Self::expr_parser())
.map(|((name, ty), value)| VarDecl { name, ty, value })
}
fn fn_decl_parser(
stmt: impl chumsky::Parser<Token, Spanned<Statement>, Error = Simple<Token>> + Clone,
) -> impl chumsky::Parser<Token, FnDecl, Error = Simple<Token>> {
let is_async = select! { Token::Ident(s) if s == "async" => true }
.or_not()
.map(|a| a.is_some());
is_async
.then_ignore(just(Token::Fn))
.then(Self::ident_parser())
.then(Self::fn_params_parser())
.then(
just(Token::Arrow)
.ignore_then(Self::type_annotation_parser())
.or_not(),
)
.then_ignore(just(Token::Colon))
.then(Self::block_parser(stmt))
.map(|((((is_async, name), params), return_type), body)| FnDecl {
name,
is_async,
params,
return_type,
body,
})
}
fn fn_params_parser() -> impl chumsky::Parser<Token, Vec<FnParam>, Error = Simple<Token>> {
let param = Self::ident_parser()
.then_ignore(just(Token::Colon))
.then(Self::type_annotation_parser())
.then(just(Token::Eq).ignore_then(Self::expr_parser()).or_not())
.map(|((name, ty), default)| FnParam { name, ty, default });
param
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LParen), just(Token::RParen))
}
fn struct_decl_parser(
stmt: impl chumsky::Parser<Token, Spanned<Statement>, Error = Simple<Token>> + Clone,
) -> impl chumsky::Parser<Token, StructDecl, Error = Simple<Token>> {
let field = Self::ident_parser()
.then_ignore(just(Token::Colon))
.then(Self::type_annotation_parser())
.then(just(Token::Eq).ignore_then(Self::expr_parser()).or_not())
.map(|((name, ty), default)| StructField { name, ty, default });
let method = Self::fn_decl_parser(stmt);
just(Token::Struct)
.ignore_then(Self::ident_parser())
.then_ignore(just(Token::Colon))
.then_ignore(just(Token::Newline).repeated())
.then_ignore(Self::indent_parser())
.then(
choice((field.map(Either::Left), method.map(Either::Right)))
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated(),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|(name, members)| {
let mut fields = Vec::new();
let mut methods = Vec::new();
for m in members {
match m {
Either::Left(f) => fields.push(f),
Either::Right(m) => methods.push(m),
}
}
StructDecl {
name,
fields,
methods,
}
})
}
fn enum_decl_parser() -> impl chumsky::Parser<Token, EnumDecl, Error = Simple<Token>> {
let variant = Self::ident_parser()
.then(
Self::type_annotation_parser()
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LParen), just(Token::RParen))
.or_not(),
)
.map(|(name, fields)| EnumVariant { name, fields });
just(Token::Enum)
.ignore_then(Self::ident_parser())
.then_ignore(just(Token::Colon))
.then_ignore(just(Token::Newline).repeated())
.then(
variant
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|(name, variants)| EnumDecl { name, variants })
}
fn type_alias_parser() -> impl chumsky::Parser<Token, TypeAlias, Error = Simple<Token>> {
just(Token::Type)
.ignore_then(Self::ident_parser())
.then_ignore(just(Token::Eq))
.then(Self::type_annotation_parser())
.map(|(name, ty)| TypeAlias { name, ty })
}
fn import_parser() -> impl chumsky::Parser<Token, Import, Error = Simple<Token>> {
just(Token::Import)
.ignore_then(select! { Token::Str(s) => s })
.then(just(Token::As).ignore_then(Self::ident_parser()).or_not())
.map(|(path, alias)| Import { path, alias })
}
fn indent_parser() -> impl chumsky::Parser<Token, (), Error = Simple<Token>> + Clone {
select! { Token::Indent(_) => () }.or_not().ignored()
}
fn field_name_parser() -> impl chumsky::Parser<Token, String, Error = Simple<Token>> + Clone {
Self::ident_parser()
.or(just(Token::When).to("when".to_string()))
// `brew` is a keyword (for the `brew:` block) but is also a valid
// package-manager field name inside `package:` blocks.
.or(just(Token::Brew).to("brew".to_string()))
}
fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> {
let field = Self::field_name_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser().map_with_span(|expr, span| (expr, span)));
just(Token::Dotfile)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
field
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|fields| {
let mut dotfile = Dotfile {
source: Expr::Literal(Literal::None),
target: Expr::Literal(Literal::None),
when: None,
template: None,
permissions: Vec::new(),
owner: None,
deploy: DeployMode::default(),
link_patterns: Vec::new(),
copy_patterns: Vec::new(),
source_span: None,
target_span: None,
when_span: None,
};
for (name, (value, span)) in fields {
match name.as_str() {
"source" => {
dotfile.source = value;
dotfile.source_span = Some(span);
}
"target" => {
dotfile.target = value;
dotfile.target_span = Some(span);
}
"when" => {
dotfile.when = Some(value);
dotfile.when_span = Some(span);
}
"template" => {
if let Expr::Literal(Literal::Bool(b)) = value {
dotfile.template = Some(b);
}
}
"permissions" => {
dotfile.permissions = expr_to_permission_rules(&value);
}
"deploy" => {
if let Expr::Literal(Literal::Str(s)) = value {
dotfile.deploy = match s.as_str() {
"link" => DeployMode::Link,
_ => DeployMode::Copy,
};
}
}
"link" => {
dotfile.link_patterns = expr_to_string_list(&value);
}
"copy" => {
dotfile.copy_patterns = expr_to_string_list(&value);
}
"owner" => {
if let Expr::Literal(Literal::Str(s)) = value {
dotfile.owner = Some(s);
}
}
_ => {}
}
}
dotfile
})
}
fn package_parser() -> impl chumsky::Parser<Token, Package, Error = Simple<Token>> {
let inline = just(Token::Package)
.ignore_then(just(Token::Colon))
.ignore_then(Self::expr_parser())
.map(|name| Package {
default: Some(name),
brew: None,
cask: None,
apt: None,
pacman: None,
yay: None,
xbps: None,
when: None,
});
let field = Self::field_name_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser());
let block = just(Token::Package)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
field
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|fields| {
let mut pkg = Package {
default: None,
brew: None,
cask: None,
apt: None,
pacman: None,
yay: None,
xbps: None,
when: None,
};
for (name, value) in fields {
match name.as_str() {
"default" => pkg.default = Some(value),
"brew" => pkg.brew = Some(PackageSpec { name: value }),
"cask" => pkg.cask = Some(PackageSpec { name: value }),
"apt" => pkg.apt = Some(PackageSpec { name: value }),
"pacman" => pkg.pacman = Some(PackageSpec { name: value }),
"yay" => pkg.yay = Some(PackageSpec { name: value }),
"xbps" => pkg.xbps = Some(PackageSpec { name: value }),
"when" => pkg.when = Some(value),
_ => {}
}
}
pkg
});
inline.or(block)
}
/// Parses a `brew:` block holding brew-only configuration (`taps`, `formulae`).
fn brew_parser() -> impl chumsky::Parser<Token, BrewConfig, Error = Simple<Token>> {
let field = Self::field_name_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser());
just(Token::Brew)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
field
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|fields| {
let mut cfg = BrewConfig::default();
for (name, value) in fields {
match name.as_str() {
"taps" => cfg.taps = Some(value),
"formulae" => cfg.formulae = Some(value),
_ => {}
}
}
cfg
})
}
fn secret_parser() -> impl chumsky::Parser<Token, Secret, Error = Simple<Token>> {
let field = Self::field_name_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser());
just(Token::Secret)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
field
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|fields| {
let mut secret = Secret {
source: Expr::Literal(Literal::None),
target: Expr::Literal(Literal::None),
mode: None,
};
for (name, value) in fields {
match name.as_str() {
"source" => secret.source = value,
"target" => secret.target = value,
"mode" => {
if let Expr::Literal(Literal::Int(m)) = value {
secret.mode = Some(m as u32);
}
}
_ => {}
}
}
secret
})
}
fn encrypted_parser() -> impl chumsky::Parser<Token, EncryptedVars, Error = Simple<Token>> {
// file("path") syntax
let file_entry = Self::ident_parser()
.then_ignore(just(Token::Eq))
.then(
select! { Token::Ident(s) if s == "file" => () }.ignore_then(
Self::expr_parser().delimited_by(just(Token::LParen), just(Token::RParen)),
),
)
.map(|(name, path_expr)| EncryptedEntry::File(name, path_expr));
// Plain inline var: KEY = "base64..."
let var_entry = Self::ident_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser())
.map(|(name, expr)| EncryptedEntry::Var(name, expr));
let entry = file_entry.or(var_entry);
just(Token::Encrypted)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
entry
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|entries| EncryptedVars { entries })
}
fn hook_parser() -> impl chumsky::Parser<Token, Hook, Error = Simple<Token>> {
let stage = Self::ident_parser().map(|s| match s.as_str() {
"BeforeDeploy" => HookStage::BeforeDeploy,
"AfterDeploy" => HookStage::AfterDeploy,
"BeforePackage" => HookStage::BeforePackage,
"AfterPackage" => HookStage::AfterPackage,
_ => HookStage::AfterDeploy,
});
let field = Self::field_name_parser()
.then_ignore(just(Token::Eq))
.then(choice((
stage.map(Either::Left),
Self::expr_parser().map(Either::Right),
)));
just(Token::Hook)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
field
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|fields| {
let mut hook = Hook {
stage: HookStage::AfterDeploy,
run: Expr::Literal(Literal::None),
when: None,
};
for (name, value) in fields {
match (name.as_str(), value) {
("stage", Either::Left(s)) => hook.stage = s,
("run", Either::Right(e)) => hook.run = e,
("when", Either::Right(e)) => hook.when = Some(e),
_ => {}
}
}
hook
})
}
fn simple_hook_parser() -> impl chumsky::Parser<Token, Hook, Error = Simple<Token>> {
let stage_token = choice((
just(Token::BeforeDeploy).to(HookStage::BeforeDeploy),
just(Token::AfterDeploy).to(HookStage::AfterDeploy),
just(Token::BeforePackage).to(HookStage::BeforePackage),
just(Token::AfterPackage).to(HookStage::AfterPackage),
));
stage_token
.then_ignore(just(Token::Colon))
.then(Self::expr_parser())
.map(|(stage, run)| Hook {
stage,
run,
when: None,
})
}
fn macro_decl_parser(
stmt: impl chumsky::Parser<Token, Spanned<Statement>, Error = Simple<Token>> + Clone,
) -> impl chumsky::Parser<Token, MacroDecl, Error = Simple<Token>> {
just(Token::Macro)
.ignore_then(Self::ident_parser())
.then_ignore(just(Token::Bang))
.then(
Self::ident_parser()
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LParen), just(Token::RParen)),
)
.then_ignore(just(Token::Colon))
.then(Self::block_parser(stmt))
.map(|((name, params), body)| MacroDecl { name, params, body })
}
fn macro_call_parser() -> impl chumsky::Parser<Token, MacroCall, Error = Simple<Token>> {
Self::ident_parser()
.then_ignore(just(Token::Bang))
.then(
Self::expr_parser()
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LParen), just(Token::RParen)),
)
.map(|(name, args)| MacroCall { name, args })
}
fn for_loop_parser(
stmt: impl chumsky::Parser<Token, Spanned<Statement>, Error = Simple<Token>> + Clone,
) -> impl chumsky::Parser<Token, ForLoop, Error = Simple<Token>> {
just(Token::For)
.ignore_then(Self::ident_parser())
.then_ignore(just(Token::In))
.then(Self::expr_parser())
.then_ignore(just(Token::Colon))
.then(Self::block_parser(stmt))
.map(|((var, iter), body)| ForLoop { var, iter, body })
}
fn if_parser(
stmt: impl chumsky::Parser<Token, Spanned<Statement>, Error = Simple<Token>> + Clone,
) -> impl chumsky::Parser<Token, IfStatement, Error = Simple<Token>> {
just(Token::If)
.ignore_then(Self::expr_parser())
.then_ignore(just(Token::Colon))
.then(Self::block_parser(stmt.clone()))
.then(
just(Token::Else)
.ignore_then(just(Token::Colon))
.ignore_then(Self::block_parser(stmt))
.or_not(),
)
.map(|((condition, then_body), else_body)| IfStatement {
condition,
then_body,
else_body,
})
}
fn match_parser() -> impl chumsky::Parser<Token, MatchStatement, Error = Simple<Token>> {
let pattern = choice((
select! {
Token::Int(n) => Pattern::Literal(Literal::Int(n)),
Token::Float(n) => Pattern::Literal(Literal::Float(n.into_inner())),
Token::Str(s) => Pattern::Literal(Literal::Str(s)),
Token::Bool(b) => Pattern::Literal(Literal::Bool(b)),
},
Self::ident_parser()
.then_ignore(just(Token::DoubleColon))
.then(Self::ident_parser())
.map(|(ty, variant)| Pattern::EnumVariant { ty, variant }),
select! { Token::Ident(s) if s == "_" => Pattern::Wildcard },
Self::ident_parser().map(Pattern::Ident),
));
let arm = pattern
.then_ignore(just(Token::FatArrow))
.then(Self::expr_parser())
.map(|(pattern, body)| MatchArm { pattern, body });
Self::ident_parser()
.then_ignore(just(Token::Eq))
.then_ignore(just(Token::Match))
.then(Self::expr_parser())
.then_ignore(just(Token::Colon))
.then_ignore(just(Token::Newline).repeated())
.then(
arm.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|((_, expr), arms)| MatchStatement { expr, arms })
}
fn block_parser(
stmt: impl chumsky::Parser<Token, Spanned<Statement>, Error = Simple<Token>> + Clone,
) -> impl chumsky::Parser<Token, Vec<Spanned<Statement>>, Error = Simple<Token>> {
just(Token::Newline)
.repeated()
.ignore_then(filter(|t| matches!(t, Token::Indent(_))))
.ignore_then(stmt.repeated().at_least(1))
.then_ignore(just(Token::Dedent).or_not())
}
fn type_annotation_parser() -> impl chumsky::Parser<Token, TypeAnnotation, Error = Simple<Token>>
{
recursive(|ty| {
let simple = Self::ident_parser().map(TypeAnnotation::Simple);
let list = ty
.clone()
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.map(|t| TypeAnnotation::List(Box::new(t)));
let literal_str = select! { Token::Str(s) => TypeAnnotation::Literal(Literal::Str(s)) };
let base = choice((list, literal_str, simple));
let optional = base
.clone()
.then(select! { Token::Ident(s) if s == "?" => () }.or_not())
.map(|(t, opt)| {
if opt.is_some() {
TypeAnnotation::Optional(Box::new(t))
} else {
t
}
});
optional
.clone()
.then(just(Token::Pipe).ignore_then(optional.clone()).repeated())
.map(|(first, rest)| {
if rest.is_empty() {
first
} else {
let mut types = vec![first];
types.extend(rest);
TypeAnnotation::Union(types)
}
})
})
}
fn expr_parser() -> impl chumsky::Parser<Token, Expr, Error = Simple<Token>> {
recursive(|expr| {
let literal = select! {
Token::Int(n) => Expr::Literal(Literal::Int(n)),
Token::Float(n) => Expr::Literal(Literal::Float(n.into_inner())),
Token::Str(s) => {
if s.contains('{') && s.contains('}') {
Self::parse_interpolated(&s)
} else {
Expr::Literal(Literal::Str(s))
}
},
Token::Bool(b) => Expr::Literal(Literal::Bool(b)),
};
let ident = Self::ident_parser().map(Expr::Ident);
let list = expr
.clone()
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.map(Expr::List);
// Allow newlines/indent/dedent inside struct braces for multi-line init
let brace_ws = just(Token::Newline)
.or(filter(|t: &Token| matches!(t, Token::Indent(_))))
.or(just(Token::Dedent))
.repeated();
let struct_init = Self::ident_parser()
.then(
just(Token::LBrace)
.ignore_then(brace_ws.clone())
.ignore_then(
Self::ident_parser()
.then_ignore(just(Token::Eq))
.then(expr.clone())
.separated_by(just(Token::Comma).then_ignore(brace_ws.clone()))
.allow_trailing(),
)
.then_ignore(brace_ws)
.then_ignore(just(Token::RBrace)),
)
.map(|(name, fields)| {
let map: HashMap<_, _> = fields.into_iter().collect();
Expr::StructInit(name, map)
});
let enum_variant = Self::ident_parser()
.then_ignore(just(Token::DoubleColon))
.then(Self::ident_parser())
.map(|(ty, variant)| Expr::EnumVariant(ty, variant));
let home_path = just(Token::Tilde)
.ignore_then(just(Token::Slash).ignore_then(expr.clone()).or_not())
.map(|path| {
Expr::HomePath(Box::new(
path.unwrap_or(Expr::Literal(Literal::Str(String::new()))),
))
});
let paren = expr
.clone()
.delimited_by(just(Token::LParen), just(Token::RParen));
let lambda = just(Token::Pipe)
.ignore_then(
Self::ident_parser()
.then(
just(Token::Colon)
.ignore_then(Self::type_annotation_parser())
.or_not(),
)
.map(|(name, ty)| FnParam {
name,
ty: ty.unwrap_or(TypeAnnotation::Simple("any".to_string())),
default: None,
})
.separated_by(just(Token::Comma)),
)
.then_ignore(just(Token::Pipe))
.then(expr.clone())
.map(|(params, body)| Expr::Lambda(params, Box::new(body)));
let if_expr = just(Token::If)
.ignore_then(expr.clone())
.then_ignore(just(Token::Then))
.then(expr.clone())
.then(just(Token::Else).ignore_then(expr.clone()).or_not())
.map(|((cond, then_expr), else_expr)| {
Expr::If(Box::new(cond), Box::new(then_expr), else_expr.map(Box::new))
});
let await_expr = just(Token::Await)
.ignore_then(expr.clone())
.map(|e| Expr::Await(Box::new(e)));
let atom = choice((
await_expr,
if_expr,
lambda,
home_path,
struct_init,
enum_variant,
list,
literal,
ident,
paren,
));
let call_or_access = atom
.then(
choice((
expr.clone()
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LParen), just(Token::RParen))
.map(CallOrAccess::Call),
just(Token::Dot)
.ignore_then(Self::ident_parser())
.then(
expr.clone()
.separated_by(just(Token::Comma))
.allow_trailing()
.delimited_by(just(Token::LParen), just(Token::RParen))
.or_not(),
)
.map(|(name, args)| {
if let Some(args) = args {
CallOrAccess::MethodCall(name, args)
} else {
CallOrAccess::Field(name)
}
}),
expr.clone()
.delimited_by(just(Token::LBracket), just(Token::RBracket))
.map(CallOrAccess::Index),
))
.repeated(),
)
.foldl(|e, access| match access {
CallOrAccess::Call(args) => Expr::Call(Box::new(e), args),
CallOrAccess::MethodCall(name, args) => {
Expr::MethodCall(Box::new(e), name, args)
}
CallOrAccess::Field(name) => Expr::Field(Box::new(e), name),
CallOrAccess::Index(idx) => Expr::Index(Box::new(e), Box::new(idx)),
});
let unary_ops = choice((
just(Token::Minus).to(UnaryOp::Neg),
just(Token::Bang).to(UnaryOp::Not),
))
.repeated()
.collect::<Vec<_>>();
let unary = unary_ops
.then(call_or_access)
.map(|(ops, expr)| {
ops.into_iter()
.rev()
.fold(expr, |e, op| Expr::Unary(op, Box::new(e)))
})
.boxed();
let path_op = unary
.clone()
.then(just(Token::Slash).ignore_then(unary.clone()).repeated())
.foldl(|a, b| Expr::Path(Box::new(a), Box::new(b)))
.boxed();
let product = path_op
.clone()
.then(
choice((
just(Token::Star).to(BinOp::Mul),
just(Token::Percent).to(BinOp::Mod),
))
.then(path_op.clone())
.repeated(),
)
.foldl(|a, (op, b)| Expr::Binary(Box::new(a), op, Box::new(b)))
.boxed();
let sum = product
.clone()
.then(
choice((
just(Token::Plus).to(BinOp::Add),
just(Token::Minus).to(BinOp::Sub),
))
.then(product.clone())
.repeated(),
)
.foldl(|a, (op, b)| Expr::Binary(Box::new(a), op, Box::new(b)))
.boxed();
let comparison = sum
.clone()
.then(
choice((
just(Token::EqEq).to(BinOp::Eq),
just(Token::NotEq).to(BinOp::NotEq),
just(Token::LtEq).to(BinOp::LtEq),
just(Token::GtEq).to(BinOp::GtEq),
just(Token::Lt).to(BinOp::Lt),
just(Token::Gt).to(BinOp::Gt),
))
.then(sum.clone())
.repeated(),
)
.foldl(|a, (op, b)| Expr::Binary(Box::new(a), op, Box::new(b)))
.boxed();
let and_expr = comparison
.clone()
.then(just(Token::And).ignore_then(comparison.clone()).repeated())
.foldl(|a, b| Expr::Binary(Box::new(a), BinOp::And, Box::new(b)))
.boxed();
let or_expr = and_expr
.clone()
.then(just(Token::Or).ignore_then(and_expr.clone()).repeated())
.foldl(|a, b| Expr::Binary(Box::new(a), BinOp::Or, Box::new(b)))
.boxed();
or_expr
.clone()
.then(
just(Token::QuestionQuestion)
.ignore_then(or_expr.clone())
.repeated(),
)
.foldl(|a, b| Expr::Binary(Box::new(a), BinOp::NullCoalesce, Box::new(b)))
})
}
fn ident_parser() -> impl chumsky::Parser<Token, String, Error = Simple<Token>> + Clone {
select! { Token::Ident(s) => s }
}
fn parse_interpolated(s: &str) -> Expr {
let mut parts = Vec::new();
let mut current = String::new();
let mut in_expr = false;
let mut expr_depth = 0;
let mut expr_str = String::new();
for c in s.chars() {
if in_expr {
if c == '{' {
expr_depth += 1;
expr_str.push(c);
} else if c == '}' {
if expr_depth == 0 {
in_expr = false;
parts.push(InterpolatedPart::Expr(Expr::Ident(expr_str.clone())));
expr_str.clear();
} else {
expr_depth -= 1;
expr_str.push(c);
}
} else {
expr_str.push(c);
}
} else if c == '{' {
if !current.is_empty() {
parts.push(InterpolatedPart::Literal(current.clone()));
current.clear();
}
in_expr = true;
} else {
current.push(c);
}
}
if !current.is_empty() {
parts.push(InterpolatedPart::Literal(current));
}
if parts.len() == 1
&& let InterpolatedPart::Literal(s) = &parts[0]
{
return Expr::Literal(Literal::Str(s.clone()));
}
Expr::Interpolated(parts)
}
}
enum CallOrAccess {
Call(Vec<Expr>),
MethodCall(String, Vec<Expr>),
Field(String),
Index(Expr),
}
enum Either<L, R> {
Left(L),
Right(R),
}
fn expr_to_string_list(expr: &Expr) -> Vec<String> {
match expr {
Expr::List(items) => items
.iter()
.filter_map(|e| {
if let Expr::Literal(Literal::Str(s)) = e {
Some(s.clone())
} else {
None
}
})
.collect(),
Expr::Literal(Literal::Str(s)) => vec![s.clone()],
_ => Vec::new(),
}
}
fn expr_to_permission_rules(expr: &Expr) -> Vec<PermissionRule> {
match expr {
// Single mode: permissions = 0o755
Expr::Literal(Literal::Int(mode)) => {
vec![PermissionRule::Single(*mode as u32)]
}
// Array of rules: permissions = [["*.sh", 0o755], ["secret/*", 0o600]]
Expr::List(items) => items
.iter()
.filter_map(|e| {
match e {
// [pattern, mode] pair
Expr::List(pair) if pair.len() == 2 => {
if let (
Expr::Literal(Literal::Str(pattern)),
Expr::Literal(Literal::Int(mode)),
) = (&pair[0], &pair[1])
{
Some(PermissionRule::Pattern {
pattern: pattern.clone(),
mode: *mode as u32,
})
} else {
None
}
}
// Single mode in array (less common but supported)
Expr::Literal(Literal::Int(mode)) => Some(PermissionRule::Single(*mode as u32)),
_ => None,
}
})
.collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::Lexer;
fn parse_source(src: &str) -> Program {
let tokens = Lexer::lex(src).expect("lexer failed");
Parser::parse(tokens).expect("parser failed")
}
#[test]
fn test_encrypted_inline_vars() {
let src =
"encrypted:\n API_KEY = \"base64ciphertext\"\n DB_PASS = \"anotherciphertext\"\n";
let program = parse_source(src);
assert_eq!(program.statements.len(), 1);
if let Statement::Encrypted(enc) = &program.statements[0].node {
assert_eq!(enc.entries.len(), 2);
assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY"));
assert!(matches!(&enc.entries[1], EncryptedEntry::Var(name, _) if name == "DB_PASS"));
} else {
panic!("expected Encrypted statement");
}
}
#[test]
fn test_brew_block_and_cask_field() {
// `brew:` block with taps + formulae lists.
let program = parse_source(
"brew:\n taps = [\"homebrew/cask-fonts\"]\n formulae = [\"mas\", \"trash\"]\n",
);
assert_eq!(program.statements.len(), 1);
match &program.statements[0].node {
Statement::Brew(cfg) => {
assert!(cfg.taps.is_some());
assert!(cfg.formulae.is_some());
}
other => panic!("expected Brew statement, got {other:?}"),
}
// `cask` is a package field; `brew` still works as a field name despite
// being a keyword now.
let program = parse_source("package:\n brew = \"ripgrep\"\n cask = \"firefox\"\n");
match &program.statements[0].node {
Statement::Package(pkg) => {
assert!(pkg.brew.is_some());
assert!(pkg.cask.is_some());
}
other => panic!("expected Package statement, got {other:?}"),
}
}
#[test]
fn test_encrypted_file_entries() {
let src = "encrypted:\n SSH_KEY = file(\"secrets/id_rsa.age\")\n CONFIG = file(\"secrets/app.conf.age\")\n";
let program = parse_source(src);
assert_eq!(program.statements.len(), 1);
if let Statement::Encrypted(enc) = &program.statements[0].node {
assert_eq!(enc.entries.len(), 2);
assert!(matches!(&enc.entries[0], EncryptedEntry::File(name, _) if name == "SSH_KEY"));
assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "CONFIG"));
} else {
panic!("expected Encrypted statement");
}
}
#[test]
fn test_encrypted_mixed_entries() {
let src = "encrypted:\n API_KEY = \"base64ciphertext\"\n SSH_KEY = file(\"secrets/id_rsa.age\")\n TOKEN = \"anotherbase64\"\n";
let program = parse_source(src);
assert_eq!(program.statements.len(), 1);
if let Statement::Encrypted(enc) = &program.statements[0].node {
assert_eq!(enc.entries.len(), 3);
assert!(matches!(&enc.entries[0], EncryptedEntry::Var(name, _) if name == "API_KEY"));
assert!(matches!(&enc.entries[1], EncryptedEntry::File(name, _) if name == "SSH_KEY"));
assert!(matches!(&enc.entries[2], EncryptedEntry::Var(name, _) if name == "TOKEN"));
} else {
panic!("expected Encrypted statement");
}
}
}