Add optional parameter declarations (including default values)

This commit is contained in:
Olivier 'reivilibre' 2025-05-18 14:42:48 +01:00
parent 5a115b3a20
commit 7cb653ab67
21 changed files with 440 additions and 94 deletions

View File

@ -4,9 +4,17 @@ use std::collections::BTreeMap;
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Template {
pub param_defs: Option<Vec<ParameterDefinition>>,
pub blocks: Vec<Block>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ParameterDefinition {
pub name: IStr,
pub loc: Locator,
pub default: Option<Expression>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum Block {
HtmlElement(HtmlElement),

View File

@ -1,5 +1,21 @@
//
Hornbeam = { SOI ~ wsnl* ~ BlockContent* ~ ws* ~ EOI }
Hornbeam = { SOI ~ wsnl* ~ PreambleList? ~ wsnl* ~ HornbeamBlockList ~ ws* ~ EOI }
// Will eventually expand to other types of preamble content
PreambleList = {
// accept `declare` keyword. We expect `PEEK_ALL` to be empty, but future definitions might change this.
PEEK_ALL ~ "declare" ~ lineEnd ~
// then accept the first definition. We must have at least one definition.
// We accept the new indentation level with the `PUSH` here
PEEK_ALL ~ PUSH(" "+ | "\t"+) ~ ParameterDefinition ~
// Now accept any number of extra definitions at the same indentation level.
(PEEK_ALL ~ ParameterDefinition)* ~
// Drop the indentation when exiting the preamble block
DROP
}
HornbeamBlockList = { BlockContent* }
NewBlock = _{
PEEK_ALL ~ PUSH(" "+ | "\t"+) ~ BlockContent ~
@ -18,6 +34,13 @@ BlockContent = _{
DefineFragment
}
// `param $x`
// `param $x = 1 + 1`
// TODO: type annotation: `param $x: int`
ParameterDefinition = {
"param" ~ ws+ ~ "$" ~ Identifier ~ (ws* ~ "=" ~ ws* ~ Expr)? ~ lineEnd
}
Element = {
ElementName ~ cssClass* ~ domId? ~ (ws+ ~ AttrMapLiteral)? ~ lineEnd ~ (NewBlock | NewSlotBlock)?
}

View File

@ -2,8 +2,8 @@
use crate::ast::{
Binding, Block, ComponentElement, DefineExpandSlot, DefineFragment, ElementAttributeFlags,
Expression, ForBlock, HtmlElement, IfBlock, MatchBinding, MatchBlock, SetStatement, StringExpr,
StringPiece, Template,
Expression, ForBlock, HtmlElement, IfBlock, MatchBinding, MatchBlock, ParameterDefinition,
SetStatement, StringExpr, StringPiece, Template,
};
use crate::{intern, IStr, Locator};
use lazy_static::lazy_static;
@ -61,8 +61,51 @@ lazy_static! {
#[pest_consume::parser]
impl HornbeamParser {
fn Hornbeam(input: Node) -> PCResult<Template> {
let blocks = HornbeamParser::helper_blocks(input.into_children())?;
Ok(Template { blocks })
Ok(match_nodes!(input.into_children();
[PreambleList(param_defs), HornbeamBlockList(blocks), EOI(_)] => {
Template { param_defs, blocks }
},
[HornbeamBlockList(blocks), EOI(_)] => {
Template { param_defs: None, blocks }
}
))
}
fn PreambleList(input: Node) -> PCResult<Option<Vec<ParameterDefinition>>> {
let param_defs: Vec<ParameterDefinition> = match_nodes!(input.into_children();
[ParameterDefinition(param_defs)..] => param_defs.collect()
);
// Templates with no parameter definitions are treated as untyped.
// (Mostly for backwards compat)
Ok(if param_defs.is_empty() {
None
} else {
Some(param_defs)
})
}
fn ParameterDefinition(input: Node) -> PCResult<ParameterDefinition> {
let loc = nodeloc(&input);
Ok(match_nodes!(input.into_children();
[Identifier(name), Expr(default)] => {
ParameterDefinition {
name,
loc,
default: Some(default),
}
},
[Identifier(name)] => {
ParameterDefinition {
name,
loc,
default: None,
}
},
))
}
fn HornbeamBlockList(input: Node) -> PCResult<Vec<Block>> {
HornbeamParser::helper_blocks(input.into_children())
}
fn Element(input: Node) -> PCResult<Block> {
@ -617,6 +660,23 @@ div
.unwrap());
}
#[test]
fn param_defs() {
assert_yaml_snapshot!(parse_template(
r#"
declare
// We need form information
param $form
// Also wouldn't hurt to have a default value on this other parameter
param $user = None
"#,
"inp"
)
.unwrap());
}
#[test]
fn supply_slots_to_components_only() {
assert_debug_snapshot!(parse_template(

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\nfor $x in $xs\n for $y in $ys\n \"Woot\"\n empty\n \"no ys\"\nempty\n \"no xs\"\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- ForBlock:
binding:
@ -44,4 +45,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\ndiv\n fragment BobbyDazzler\n span\n \"Wow!\"\n fragment MainPart\n slot :main\n div\n optional slot :footer\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- HtmlElement:
name: div
@ -64,4 +65,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\nif 1 + 1 == 2\n div\n \"Phew, safe!\"\nelse if 1 + 2 - 1 == 3 // not too far off, I suppose\n Warning\n \"Not quite, but fairly close. What kind of world is this?\"\nelse // peculiar.\n \"Not even close, eh?\"\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- IfBlock:
condition:
@ -76,4 +77,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\nif 10 / 2 == 5 or $point.x == 42 // div for a div?\n div\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- IfBlock:
condition:
@ -54,4 +55,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\ndiv\n span\n @header-wow\n MainBody\n ''\n @body-welcome\n @body-msg{count = $messages.len()}\n ''\n Footer\n @footer-copyright{year = today().year}\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- HtmlElement:
name: div
@ -90,4 +91,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\nif $x\n match $y\n None =>\n \"None\"\n Some($z) =>\n \"Some(${$z})\"\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- IfBlock:
condition:
@ -50,4 +51,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -0,0 +1,18 @@
---
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\ndeclare\n // We need form information\n param $form\n\n // Also wouldn't hurt to have a default value on this other parameter\n\n param $user = None\n \"#,\n \"inp\").unwrap()"
---
param_defs:
- name: form
loc:
filename: inp
line: 4
column: 5
default: ~
- name: user
loc:
filename: inp
line: 8
column: 5
default: NoneLiteral
blocks: []

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\ndiv\n span\n raw \"<u>wow $x ${$x} @wowage{}</u>\"\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- HtmlElement:
name: div
@ -42,4 +43,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\n// This is a simple Hornbeam template that just shows a <div>\ndiv\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- HtmlElement:
name: div
@ -13,4 +14,3 @@ blocks:
filename: inp
line: 3
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\nMyComponent\n :someslot\n ''\n ${\"abc\" + \"def${ 1 + 1 }\"}\n Not too bad now.\n ''\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- ComponentElement:
name: MyComponent
@ -34,4 +35,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -2,6 +2,7 @@
source: hornbeam_grammar/src/parser.rs
expression: "parse_template(r#\"\nMyComponent\n :someslot\n \"That's better!\"\n \"#,\n \"inp\").unwrap()"
---
param_defs: ~
blocks:
- ComponentElement:
name: MyComponent
@ -15,4 +16,3 @@ blocks:
filename: inp
line: 2
column: 1

View File

@ -475,12 +475,61 @@ impl<'a, O: OutputSystem + Send, LS: LocalisationSystem + Sync + Send> Interpret
// TODO check slots
// ...
// check params
// check params and evaluate defaults
if let Some(param_defs) = &module.param_defs {
for param_def in param_defs {
if evaled_args.contains_key(param_def.name.as_str()) {
continue;
}
let Some(default_expr) = &param_def.default else {
return Err(InterpreterError::TypeError {
context: "Call".to_string(),
conflict: format!(
"missing required parameter {} for {name:?}.",
param_def.name
),
location: step.locator.clone(),
});
};
// Push a temporary empty scope
// TODO in the future allow evaluation with some scope if desired.
self.scopes.push(Scope {
variables: BTreeMap::new(),
slots: BTreeMap::new(),
});
evaled_args.insert(
String::from(param_def.name.as_str()),
self.evaluate_expression(
scope_idx + 1,
default_expr,
&param_def.locator,
)?,
);
self.scopes.pop();
}
for supplied_param in evaled_args.keys() {
if !param_defs
.iter()
.any(|def| def.name.as_str() == supplied_param)
{
return Err(InterpreterError::TypeError {
context: "Call".to_string(),
conflict: format!(
"provided non-existent parameter {} for {name:?}.",
supplied_param
),
location: Locator::empty(),
});
}
}
}
self.scopes.push(Scope {
variables: evaled_args,
slots: filled_in_slots,
});
let next_scope_idx = self.scopes.len() - 1;
self.run_steps(next_scope_idx, &module.steps).await?;
@ -902,7 +951,55 @@ impl<'a, O: OutputSystem + Send, LS: LocalisationSystem + Sync + Send> Interpret
location: Locator::empty(),
});
};
// TODO slot + params check
// TODO deduplicate with `Call` step
if let Some(param_defs) = &main.param_defs {
for param_def in param_defs {
if self.scopes[0]
.variables
.contains_key(param_def.name.as_str())
{
continue;
}
let Some(default_expr) = &param_def.default else {
return Err(InterpreterError::TypeError {
context: "main entrypoint".to_string(),
conflict: format!(
"missing required parameter {} for entrypoint {:?}.",
param_def.name, self.entrypoint
),
location: Locator::empty(),
});
};
// Push a temporary empty scope
// TODO in the future allow evaluation with some scope if desired.
self.scopes.push(Scope {
variables: BTreeMap::new(),
slots: BTreeMap::new(),
});
let value = self.evaluate_expression(1, default_expr, &param_def.locator)?;
self.scopes[0]
.variables
.insert(String::from(param_def.name.as_str()), value);
self.scopes.pop();
}
for supplied_param in self.scopes[0].variables.keys() {
if !param_defs
.iter()
.any(|def| def.name.as_str() == supplied_param)
{
return Err(InterpreterError::TypeError {
context: "main entrypoint".to_string(),
conflict: format!(
"provided non-existent parameter {} for entrypoint {:?}.",
supplied_param, self.entrypoint
),
location: Locator::empty(),
});
}
}
}
self.run_steps(0, &main.steps).await?;
Ok(())
}

View File

@ -19,6 +19,7 @@ fn simple_test_struct() -> SimpleTestStruct {
}
}
#[track_caller]
fn simple_render(template: &str) -> String {
let mut templates = LoadedTemplates::new(DebugLocalisationSystem);
templates
@ -112,3 +113,18 @@ match $unused_var
"#
))
}
#[test]
fn snapshot_006() {
assert_snapshot!(simple_render(
r#"
declare
param $default_param = "default value!"
param $five
param $sts
"$default_param"
"#
))
}

View File

@ -0,0 +1,5 @@
---
source: hornbeam_interpreter/tests/snapshots.rs
expression: "simple_render(r#\"\ndeclare\n param $default_param = \"default value!\"\n\n\"$default_param\"\n \"#)"
---
default value!

View File

@ -1,4 +1,4 @@
use crate::ir::{Step, StepDef, TemplateFunction};
use crate::ir::{ParamDef, Step, StepDef, TemplateFunction};
use hornbeam_grammar::ast::{
Binding, Block, Expression, HtmlElement, MatchBinding, StringExpr, StringPiece, Template,
};
@ -36,7 +36,7 @@ pub enum AstToIrError {
pub(crate) fn pull_out_entrypoints(
mut template: Template,
template_name: &str,
) -> Result<BTreeMap<String, Vec<Block>>, AstToIrError> {
) -> Result<BTreeMap<String, Template>, AstToIrError> {
let mut functions = BTreeMap::new();
for child in &mut template.blocks {
@ -45,7 +45,7 @@ pub(crate) fn pull_out_entrypoints(
match functions.entry(template_name.to_owned()) {
Entry::Vacant(ve) => {
ve.insert(template.blocks);
ve.insert(template);
}
Entry::Occupied(_) => {
return Err(AstToIrError::SemanticError {
@ -66,7 +66,7 @@ pub(crate) fn pull_out_entrypoints(
fn pull_out_entrypoints_from_block(
block: &mut Block,
template_name: &str,
target: &mut BTreeMap<String, Vec<Block>>,
target: &mut BTreeMap<String, Template>,
) -> Result<(), AstToIrError> {
match block {
Block::HtmlElement(he) => {
@ -122,7 +122,10 @@ fn pull_out_entrypoints_from_block(
// function.
let mut blocks = Vec::new();
std::mem::swap(&mut blocks, &mut frag.blocks);
ve.insert(blocks);
ve.insert(Template {
blocks,
param_defs: None,
});
}
Entry::Occupied(_) => {
return Err(AstToIrError::SemanticError {
@ -143,22 +146,25 @@ fn pull_out_entrypoints_from_block(
/// Step 2. Compile the AST to IR steps.
pub(crate) fn compile_functions(
functions: &BTreeMap<String, Vec<Block>>,
functions: &BTreeMap<String, Template>,
) -> Result<BTreeMap<String, TemplateFunction>, AstToIrError> {
let mut result = BTreeMap::new();
for (func_name, func_blocks) in functions {
for (func_name, func_template) in functions {
let mut steps = Vec::new();
for block in func_blocks {
for block in &func_template.blocks {
compile_ast_block_to_steps(block, &mut steps)?;
}
// TODO save more information
result.insert(
func_name.clone(),
TemplateFunction {
param_defs: None,
steps,
},
);
let param_defs = func_template.param_defs.as_ref().map(|defs| {
defs.iter()
.map(|def| ParamDef {
name: def.name.clone(),
default: def.default.clone(),
locator: def.loc.clone(),
})
.collect()
});
result.insert(func_name.clone(), TemplateFunction { param_defs, steps });
}
Ok(result)
}
@ -642,4 +648,24 @@ div.stylish#myid {size=42, stringy="yup", arb=$ritrary}
)
.unwrap());
}
#[test]
fn test_compile_params() {
let template = parse_template(
r#"
declare
param $ritary
param $three = 3
div {arb=$ritrary}
OtherComponent {param3=$three}
"#,
"inp",
)
.unwrap();
assert_yaml_snapshot!(compile_functions(
&pull_out_entrypoints(template, "TemplateName").unwrap()
)
.unwrap());
}
}

View File

@ -23,6 +23,7 @@ pub struct ParamDef {
pub name: IStr,
// TODO type information
pub default: Option<Expression>,
pub locator: Locator,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]

View File

@ -0,0 +1,85 @@
---
source: hornbeam_ir/src/ast_to_ir.rs
expression: "compile_functions(&pull_out_entrypoints(template,\n \"TemplateName\").unwrap()).unwrap()"
---
TemplateName:
param_defs:
- name: ritary
default: ~
locator:
filename: inp
line: 3
column: 5
- name: three
default:
IntLiteral:
val: 3
locator:
filename: inp
line: 5
column: 5
steps:
- WriteLiteral:
escape: false
text: "<div"
locator:
filename: inp
line: 7
column: 1
- WriteLiteral:
escape: false
text: " arb=\""
locator:
filename: inp
line: 7
column: 1
- WriteEval:
escape: true
expr:
Variable:
name: ritrary
loc:
filename: inp
line: 7
column: 10
locator:
filename: inp
line: 7
column: 1
- WriteLiteral:
escape: false
text: "\""
locator:
filename: inp
line: 7
column: 1
- WriteLiteral:
escape: false
text: ">"
locator:
filename: inp
line: 7
column: 1
- Call:
name: OtherComponent
args:
param3:
Variable:
name: three
loc:
filename: inp
line: 8
column: 28
slots:
main: []
locator:
filename: inp
line: 8
column: 5
- WriteLiteral:
escape: false
text: "</div>"
locator:
filename: inp
line: 7
column: 1

View File

@ -3,67 +3,74 @@ source: hornbeam_ir/src/ast_to_ir.rs
expression: "pull_out_entrypoints(template, \"TemplateName\").unwrap()"
---
TemplateName:
- HtmlElement:
name: div
children:
- DefineFragment:
name: TemplateName__Frag1
blocks: []
loc:
filename: inp
line: 3
column: 5
- DefineFragment:
name: TemplateName__Footer
blocks: []
loc:
filename: inp
line: 9
column: 5
classes: []
dom_id: ~
attributes: {}
loc:
filename: inp
line: 2
column: 1
param_defs: ~
blocks:
- HtmlElement:
name: div
children:
- DefineFragment:
name: TemplateName__Frag1
blocks: []
loc:
filename: inp
line: 3
column: 5
- DefineFragment:
name: TemplateName__Footer
blocks: []
loc:
filename: inp
line: 9
column: 5
classes: []
dom_id: ~
attributes: {}
loc:
filename: inp
line: 2
column: 1
TemplateName__Footer:
- Text:
pieces:
- Literal: Or even adjacent ones
param_defs: ~
blocks:
- Text:
pieces:
- Literal: Or even adjacent ones
TemplateName__Frag1:
- HtmlElement:
name: span
children:
- Text:
pieces:
- Literal: This is a fragment!!
classes: []
dom_id: ~
attributes: {}
loc:
filename: inp
line: 4
column: 9
- DefineFragment:
name: TemplateName__Frag2
blocks: []
loc:
filename: inp
line: 6
column: 9
param_defs: ~
blocks:
- HtmlElement:
name: span
children:
- Text:
pieces:
- Literal: This is a fragment!!
classes: []
dom_id: ~
attributes: {}
loc:
filename: inp
line: 4
column: 9
- DefineFragment:
name: TemplateName__Frag2
blocks: []
loc:
filename: inp
line: 6
column: 9
TemplateName__Frag2:
- HtmlElement:
name: div
children:
- Text:
pieces:
- Literal: "There's no problem having nested fragments!"
classes: []
dom_id: ~
attributes: {}
loc:
filename: inp
line: 7
column: 13
param_defs: ~
blocks:
- HtmlElement:
name: div
children:
- Text:
pieces:
- Literal: "There's no problem having nested fragments!"
classes: []
dom_id: ~
attributes: {}
loc:
filename: inp
line: 7
column: 13