//! 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; impl Parser { /// Parses a token stream into a program AST. #[tracing::instrument(skip_all)] pub fn parse(tokens: Vec) -> Result>> { let stream = tokens .into_iter() .map(|t| (t.node, t.span)) .collect::>(); 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> { Self::statement_parser() .repeated() .map(|statements| Program { statements }) .then_ignore(end()) } fn statement_parser() -> impl chumsky::Parser, Error = Simple> { 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> { 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, Error = Simple> + Clone, ) -> impl chumsky::Parser> { 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, Error = Simple> { 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, Error = Simple> + Clone, ) -> impl chumsky::Parser> { 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> { 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> { 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> { 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> + Clone { select! { Token::Indent(_) => () }.or_not().ignored() } fn field_name_parser() -> impl chumsky::Parser> + 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> { 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> { 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> { 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> { 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> { // 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> { 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> { 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, Error = Simple> + Clone, ) -> impl chumsky::Parser> { 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> { 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, Error = Simple> + Clone, ) -> impl chumsky::Parser> { 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, Error = Simple> + Clone, ) -> impl chumsky::Parser> { 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> { 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, Error = Simple> + Clone, ) -> impl chumsky::Parser>, Error = Simple> { 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> { 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> { 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::>(); 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> + 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), MethodCall(String, Vec), Field(String), Index(Expr), } enum Either { Left(L), Right(R), } fn expr_to_string_list(expr: &Expr) -> Vec { 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 { 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"); } } }