1125 lines
43 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|