From beac224f5ba864db03473f1a319ff313e6baa0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 15 Mar 2026 12:11:30 +0100 Subject: [PATCH 1/4] Add lexing of '?' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- src/lexer/mod.rs | 25 +++++++++++++++++++++++++ src/parser/mod.rs | 8 +++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index a35e096..6fb66a2 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -238,6 +238,7 @@ pub enum TokenOperator { GreaterOrEqual, Lesser, LesserOrEqual, + QuestionMark, } #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -567,6 +568,7 @@ fn parse_operator<'input>(input: &mut Input<'input>) -> PResult<'input, Template "=".value(TokenOperator::Equal), cut_err(fail), )), + '?' => empty.value(TokenOperator::QuestionMark), _ => fail, }, ) @@ -604,6 +606,7 @@ fn ident_terminator_check<'input>(input: &mut Input<'input>) -> PResult<'input, fn ident_terminator<'input>(input: &mut Input<'input>) -> PResult<'input, ()> { alt(( eof.void(), + parse_operator.void(), one_of(('{', '}')).void(), one_of(('(', ',', ')')).void(), one_of((' ', '\t', '\r', '\n')).void(), @@ -905,4 +908,26 @@ mod tests { ) "#); } + + #[test] + fn parse_question_mark() { + let input = "{{= foo? }}"; + let output = parse(input.into()); + + insta::assert_debug_snapshot!(output, @r#" + Ok( + ParsedTemplate { + tokens: [ + [LeftDelim]"{{" (0..2), + [WantsOutput]"=" (2..3), + [Whitespace]" " (3..4), + [Ident]"foo" (4..7), + [Operator(QuestionMark)]"?" (7..8), + [Whitespace]" " (8..9), + [RightDelim]"}}" (9..11), + ], + }, + ) + "#); + } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index ed4456f..8c12ffb 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7,6 +7,7 @@ use winnow::combinator::cut_err; use winnow::combinator::delimited; use winnow::combinator::dispatch; use winnow::combinator::expression; +use winnow::combinator::fail; use winnow::combinator::not; use winnow::combinator::opt; use winnow::combinator::peek; @@ -685,8 +686,9 @@ fn parse_expression<'input>( op: TokenOperator::$val, lhs: Box::new(lhs), rhs: Box::new(rhs) - })) - ),* + })), + )* + _ => fail } }; } @@ -737,6 +739,7 @@ mod tests { use winnow::combinator::fail; use winnow::stream::TokenSlice; + use crate::lexer::TokenKind; use crate::parser::AstError; use crate::parser::AstFailure; use crate::parser::TemplateAst; @@ -744,7 +747,6 @@ mod tests { use crate::parser::parse; use crate::parser::parse_block; use crate::parser::parse_end; - use crate::lexer::TokenKind; fn panic_pretty<'a>( input: &'_ str, From 9b87e7089fea89936185fdd7cdd560594b6b4365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 15 Mar 2026 12:34:35 +0100 Subject: [PATCH 2/4] Parse conditional access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- src/compiler/mod.rs | 4 +++- src/parser/mod.rs | 23 +++++++++++++++++++ ...rser__tests__check_conditional_access.snap | 15 ++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/parser/snapshots/nomo__parser__tests__check_conditional_access.snap diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index eb06315..6f5c945 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -1,9 +1,9 @@ use std::collections::BTreeMap; -use crate::parser::TemplateAstExpr; use crate::input::NomoInput; use crate::lexer::TemplateToken; use crate::lexer::TokenOperator; +use crate::parser::TemplateAstExpr; use crate::value::NomoValue; pub struct EmitMachine { @@ -424,6 +424,7 @@ fn emit_ast_expr( | TemplateAstExpr::Literal { .. } | TemplateAstExpr::FunctionCall { .. } | TemplateAstExpr::Operation { .. } + | TemplateAstExpr::ConditionalAccess { .. } | TemplateAstExpr::VariableAccess { .. } => eval.push(Instruction::Abort), } } @@ -475,6 +476,7 @@ fn emit_expr_load( slot: emit_slot, }); } + TemplateAstExpr::ConditionalAccess { .. } => todo!(), TemplateAstExpr::Invalid { .. } => eval.push(Instruction::Abort), TemplateAstExpr::StaticContent { .. } | TemplateAstExpr::Interpolation { .. } => { unreachable!("Invalid AST here") diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8c12ffb..d45eea7 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2,6 +2,7 @@ use thiserror::Error; use winnow::Parser; use winnow::RecoverableParser; use winnow::combinator::Infix::Left; +use winnow::combinator::Postfix; use winnow::combinator::alt; use winnow::combinator::cut_err; use winnow::combinator::delimited; @@ -249,6 +250,7 @@ pub enum TemplateAstExpr<'input> { value_expression: Box>, }, ForElse, + ConditionalAccess(TemplateToken), VariableAccess(TemplateToken), IfConditional { expression: Box>, @@ -709,6 +711,16 @@ fn parse_expression<'input>( Left Lesser => 15, Left LesserOrEqual => 15, ] + }).postfix(dispatch! { surrounded(ws, parse_operator); + TokenOperator::QuestionMark => Postfix(22, |input, rhs| { + match rhs { + TemplateAstExpr::VariableAccess(access) => Ok(TemplateAstExpr::ConditionalAccess(access)), + _ => Err(AstError::from_input(input)), + } + + + }), + _ => fail }), ) .parse_next(input) @@ -1048,4 +1060,15 @@ mod tests { insta::assert_debug_snapshot!(ast); } + + #[test] + fn check_conditional_access() { + let input = "{{= foo? }}"; + + let parsed = crate::lexer::parse(input.into()).unwrap(); + + let ast = panic_pretty(input, parse(parsed.tokens())); + + insta::assert_debug_snapshot!(ast); + } } diff --git a/src/parser/snapshots/nomo__parser__tests__check_conditional_access.snap b/src/parser/snapshots/nomo__parser__tests__check_conditional_access.snap new file mode 100644 index 0000000..f575f85 --- /dev/null +++ b/src/parser/snapshots/nomo__parser__tests__check_conditional_access.snap @@ -0,0 +1,15 @@ +--- +source: src/parser/mod.rs +expression: ast +--- +TemplateAst { + root: [ + Interpolation { + prev_whitespace_content: None, + expression: ConditionalAccess( + [Ident]"foo" (4..7), + ), + post_whitespace_content: None, + }, + ], +} From 662e5745887c90d082ef260dc42ebacdf55701ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 15 Mar 2026 12:35:45 +0100 Subject: [PATCH 3/4] Add undefined value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- src/value.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/value.rs b/src/value.rs index 2d70084..c587f82 100644 --- a/src/value.rs +++ b/src/value.rs @@ -31,6 +31,7 @@ pub enum NomoValue { Iterator { value: Box>, }, + Undefined, } impl NomoValue { @@ -241,6 +242,7 @@ impl NomoValue { NomoValue::SignedInteger { value } => Some(value.to_string()), NomoValue::Float { value } => Some(value.to_string()), NomoValue::Iterator { .. } => None, + NomoValue::Undefined => None, } } } @@ -281,6 +283,7 @@ impl std::fmt::Debug for NomoValue { .debug_struct("Iterator") .field("value", &"Iterator") .finish(), + Self::Undefined => f.debug_tuple("Undefined").finish(), } } } From 9940881e46881209835e769d78d58f9e570cf3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 15 Mar 2026 12:46:00 +0100 Subject: [PATCH 4/4] Add conditional value emitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- src/compiler/mod.rs | 11 +++- ...mo__compiler__tests__check_if_else_if.snap | 2 + src/eval/mod.rs | 51 +++++++++++++++++-- tests/cases/condition.3-instructions.snap | 2 + tests/cases/identifiers.3-instructions.snap | 14 +++-- tests/cases/if_else_if.3-instructions.snap | 2 + tests/cases/interpolation.3-instructions.snap | 1 + tests/cases/multiple.3-instructions.snap | 2 + tests/cases/simple_for.3-instructions.snap | 4 ++ .../cases/trim_whitespace.3-instructions.snap | 4 +- 10 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 6f5c945..98e350e 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -57,6 +57,7 @@ pub enum Instruction { LoadFromContextToSlot { name: NomoInput, slot: VariableSlot, + fail_on_not_found: bool, }, EmitFromSlot { slot: VariableSlot, @@ -440,6 +441,14 @@ fn emit_expr_load( eval.push(Instruction::LoadFromContextToSlot { name: template_token.source().clone(), slot: emit_slot, + fail_on_not_found: true, + }); + } + TemplateAstExpr::ConditionalAccess(template_token) => { + eval.push(Instruction::LoadFromContextToSlot { + name: template_token.source().clone(), + slot: emit_slot, + fail_on_not_found: false, }); } TemplateAstExpr::Literal { source, value } => { @@ -476,7 +485,6 @@ fn emit_expr_load( slot: emit_slot, }); } - TemplateAstExpr::ConditionalAccess { .. } => todo!(), TemplateAstExpr::Invalid { .. } => eval.push(Instruction::Abort), TemplateAstExpr::StaticContent { .. } | TemplateAstExpr::Interpolation { .. } => { unreachable!("Invalid AST here") @@ -523,6 +531,7 @@ mod tests { slot: VariableSlot { index: 0, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { diff --git a/src/compiler/snapshots/nomo__compiler__tests__check_if_else_if.snap b/src/compiler/snapshots/nomo__compiler__tests__check_if_else_if.snap index de57cc6..5cf26c0 100644 --- a/src/compiler/snapshots/nomo__compiler__tests__check_if_else_if.snap +++ b/src/compiler/snapshots/nomo__compiler__tests__check_if_else_if.snap @@ -20,6 +20,7 @@ VMInstructions { slot: VariableSlot { index: 1, }, + fail_on_not_found: true, }, JumpIfNotTrue { emit_slot: VariableSlot { @@ -51,6 +52,7 @@ VMInstructions { slot: VariableSlot { index: 3, }, + fail_on_not_found: true, }, JumpIfNotTrue { emit_slot: VariableSlot { diff --git a/src/eval/mod.rs b/src/eval/mod.rs index 96ae66e..62a013f 100644 --- a/src/eval/mod.rs +++ b/src/eval/mod.rs @@ -93,15 +93,34 @@ pub fn execute( match instr { Instruction::NoOp => (), Instruction::AppendContent { content } => output.push_str(content), - Instruction::LoadFromContextToSlot { name, slot } => { - let value = scopes - .get_scoped(name) - .ok_or(EvaluationError::UnknownVariable(name.clone()))?; + Instruction::LoadFromContextToSlot { + name, + slot, + fail_on_not_found, + } => { + let value = scopes.get_scoped(name); + + let value = if let Some(val) = value { + val + } else { + if *fail_on_not_found { + return Err(EvaluationError::UnknownVariable(name.clone())); + } else { + &NomoValue::Undefined + } + }; scopes.insert_into_slot(*slot, value.clone()); } Instruction::EmitFromSlot { slot } => { - let value = scopes.get(slot).try_to_string().unwrap(); + let value = scopes.get(slot); + let value = if let Some(value) = value.try_to_string() { + value + } else if matches!(value, NomoValue::Undefined) { + String::new() + } else { + panic!("Unknown variable"); + }; output.push_str(&value); } @@ -286,4 +305,26 @@ mod tests { ) "#); } + + #[test] + fn check_conditional_access() { + let input = "Hello {{= unknown? }}"; + + let parsed = crate::lexer::parse(input.into()).unwrap(); + + let ast = crate::parser::parse(parsed.tokens()).unwrap(); + + let emit = crate::compiler::emit_machine(ast); + + let context = Context::new(); + let function_map = FunctionMap::default(); + + let output = execute(&function_map, &emit, &context); + + insta::assert_debug_snapshot!(output, @r#" + Ok( + "Hello ", + ) + "#); + } } diff --git a/tests/cases/condition.3-instructions.snap b/tests/cases/condition.3-instructions.snap index a317547..50c3a5f 100644 --- a/tests/cases/condition.3-instructions.snap +++ b/tests/cases/condition.3-instructions.snap @@ -22,6 +22,7 @@ VMInstructions { slot: VariableSlot { index: 1, }, + fail_on_not_found: true, }, JumpIfNotTrue { emit_slot: VariableSlot { @@ -56,6 +57,7 @@ VMInstructions { slot: VariableSlot { index: 3, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { diff --git a/tests/cases/identifiers.3-instructions.snap b/tests/cases/identifiers.3-instructions.snap index f3d8ddc..6d13773 100644 --- a/tests/cases/identifiers.3-instructions.snap +++ b/tests/cases/identifiers.3-instructions.snap @@ -4,12 +4,12 @@ expression: emit info: input: "{{= _name }}\n{{= a_name }}\n{{= name }}\n{{= _name1 }}\n{{= _namE }}\n{{= name1 }}" context: - name1: Foo - _name: Foo - _namE: Foo a_name: Foo - name: Foo + _name: Foo _name1: Foo + _namE: Foo + name1: Foo + name: Foo --- VMInstructions { labels: {}, @@ -19,6 +19,7 @@ VMInstructions { slot: VariableSlot { index: 0, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { @@ -33,6 +34,7 @@ VMInstructions { slot: VariableSlot { index: 1, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { @@ -47,6 +49,7 @@ VMInstructions { slot: VariableSlot { index: 2, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { @@ -61,6 +64,7 @@ VMInstructions { slot: VariableSlot { index: 3, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { @@ -75,6 +79,7 @@ VMInstructions { slot: VariableSlot { index: 4, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { @@ -89,6 +94,7 @@ VMInstructions { slot: VariableSlot { index: 5, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { diff --git a/tests/cases/if_else_if.3-instructions.snap b/tests/cases/if_else_if.3-instructions.snap index 5504ab9..338a105 100644 --- a/tests/cases/if_else_if.3-instructions.snap +++ b/tests/cases/if_else_if.3-instructions.snap @@ -26,6 +26,7 @@ VMInstructions { slot: VariableSlot { index: 1, }, + fail_on_not_found: true, }, JumpIfNotTrue { emit_slot: VariableSlot { @@ -57,6 +58,7 @@ VMInstructions { slot: VariableSlot { index: 3, }, + fail_on_not_found: true, }, JumpIfNotTrue { emit_slot: VariableSlot { diff --git a/tests/cases/interpolation.3-instructions.snap b/tests/cases/interpolation.3-instructions.snap index c47d18a..4465366 100644 --- a/tests/cases/interpolation.3-instructions.snap +++ b/tests/cases/interpolation.3-instructions.snap @@ -20,6 +20,7 @@ VMInstructions { slot: VariableSlot { index: 0, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { diff --git a/tests/cases/multiple.3-instructions.snap b/tests/cases/multiple.3-instructions.snap index ef538c1..2b475e7 100644 --- a/tests/cases/multiple.3-instructions.snap +++ b/tests/cases/multiple.3-instructions.snap @@ -21,6 +21,7 @@ VMInstructions { slot: VariableSlot { index: 0, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { @@ -35,6 +36,7 @@ VMInstructions { slot: VariableSlot { index: 1, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { diff --git a/tests/cases/simple_for.3-instructions.snap b/tests/cases/simple_for.3-instructions.snap index f082c47..415d516 100644 --- a/tests/cases/simple_for.3-instructions.snap +++ b/tests/cases/simple_for.3-instructions.snap @@ -39,6 +39,7 @@ VMInstructions { slot: VariableSlot { index: 4, }, + fail_on_not_found: true, }, CreateIteratorFromSlotToSlot { iterator_slot: VariableSlot { @@ -78,6 +79,7 @@ VMInstructions { slot: VariableSlot { index: 6, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { @@ -104,6 +106,7 @@ VMInstructions { slot: VariableSlot { index: 11, }, + fail_on_not_found: true, }, CreateIteratorFromSlotToSlot { iterator_slot: VariableSlot { @@ -143,6 +146,7 @@ VMInstructions { slot: VariableSlot { index: 13, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot { diff --git a/tests/cases/trim_whitespace.3-instructions.snap b/tests/cases/trim_whitespace.3-instructions.snap index 31112d0..f484bbf 100644 --- a/tests/cases/trim_whitespace.3-instructions.snap +++ b/tests/cases/trim_whitespace.3-instructions.snap @@ -4,8 +4,8 @@ expression: emit info: input: "{{ if test -}}\n Hello {{= stuff -}}\n{{- end }}" context: - stuff: Hemera test: true + stuff: Hemera --- VMInstructions { labels: { @@ -22,6 +22,7 @@ VMInstructions { slot: VariableSlot { index: 1, }, + fail_on_not_found: true, }, JumpIfNotTrue { emit_slot: VariableSlot { @@ -42,6 +43,7 @@ VMInstructions { slot: VariableSlot { index: 3, }, + fail_on_not_found: true, }, EmitFromSlot { slot: VariableSlot {