diff --git a/TriasDev.Templify.Tests/Engine/CompatEdgeCharacterizationTests.cs b/TriasDev.Templify.Tests/Engine/CompatEdgeCharacterizationTests.cs new file mode 100644 index 0000000..759970e --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/CompatEdgeCharacterizationTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals; +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +/// +/// Characterization tests that pin the CURRENT (post-migration) behavior of edge cases where the new +/// expression engine intentionally differs from the legacy ConditionalEvaluator. These are +/// accepted, deliberate changes (F2/F3/F4); the tests exist to lock the behavior against regressions. +/// +public class CompatEdgeCharacterizationTests +{ + private static bool EvalInline(string expr, Dictionary data) + { + IReadOnlyList tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), InlineConditionDialect.Instance) + .EvaluateBool(node); + } + + // F2: In the inline dialect a variable-to-variable comparison now resolves the RHS as a variable + // (rather than treating it as an opaque literal). This is an intentional change from the legacy engine. + [Fact] + public void F2_Inline_VariableToVariableEquality_ResolvesRhs_IsTrue() + { + Assert.True(EvalInline("(A = B)", new() { ["A"] = "x", ["B"] = "x" })); + } + + // F3: In the inline dialect, an ordered comparison between incomparable operands yields false + // (IComparable.CompareTo throws for the mismatched types, and TryCompare maps that to false). + // Intentional change: incomparable => false rather than an error. + [Fact] + public void F3_Inline_IncomparableGreaterOrEqual_IsFalse() + { + Assert.False(EvalInline("(Amount >= \"abc\")", new() { ["Amount"] = 10 })); + } + + // F4: The operator keywords are reserved. A quoted literal still compares as a string... + [Fact] + public void F4_Default_QuotedReservedWord_ComparesAsLiteral_IsTrue() + { + Assert.True(new ConditionEvaluator().Evaluate( + "Category = \"empty\"", new Dictionary { ["Category"] = "empty" })); + } + + // ...but the same word UNQUOTED is parsed as the reserved `empty` keyword, which is not a valid + // right-hand operand — so the expression fails to parse and Evaluate returns false. Intentional + // change: unquoted reserved words are no longer treated as bareword string literals. + [Fact] + public void F4_Default_UnquotedReservedWord_IsNotLiteral_IsFalse() + { + Assert.False(new ConditionEvaluator().Evaluate( + "Category = empty", new Dictionary { ["Category"] = "empty" })); + } +} diff --git a/TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs b/TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs new file mode 100644 index 0000000..25ef41f --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionEvaluatorCoreTests +{ + private static ConditionEvaluatorCore Default(Dictionary data) + => new(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance); + + private static readonly ConditionOperatorRegistry _reg = ConditionOperatorRegistry.Shared; + + [Fact] + public void Equal_MatchingStrings_IsTrue() + { + var node = new OperatorNode(_reg.FindInfix("=")!, + new ConditionNode[] { new VariableNode("Status"), new LiteralNode("Active") }); + Assert.True(Default(new() { ["Status"] = "Active" }).EvaluateBool(node)); + } + + [Fact] + public void And_ShortCircuits_And_Combines() + { + var left = new OperatorNode(_reg.FindInfix(">")!, + new ConditionNode[] { new VariableNode("Count"), new LiteralNode(0) }); + var node = new OperatorNode(_reg.FindInfix("and")!, + new ConditionNode[] { left, new VariableNode("IsOn") }); + Assert.True(Default(new() { ["Count"] = 5, ["IsOn"] = true }).EvaluateBool(node)); + Assert.False(Default(new() { ["Count"] = 0, ["IsOn"] = true }).EvaluateBool(node)); + } + + [Fact] + public void Not_NegatesOperand() + { + var node = new OperatorNode(_reg.FindPrefix("not")!, + new ConditionNode[] { new VariableNode("IsOff") }); + Assert.True(Default(new() { ["IsOff"] = false }).EvaluateBool(node)); + } + + [Fact] + public void Greater_UsesNumericComparison() + { + var node = new OperatorNode(_reg.FindInfix(">")!, + new ConditionNode[] { new VariableNode("Count"), new LiteralNode(3) }); + Assert.True(Default(new() { ["Count"] = 5 }).EvaluateBool(node)); + } +} diff --git a/TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs b/TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs new file mode 100644 index 0000000..d6776ac --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionLexerTests +{ + private static List Lex(string s) => new ConditionLexer().Tokenize(s).ToList(); + + [Fact] + public void Tokenize_Comparison_ProducesIdentifierOperatorString() + { + List t = Lex("Status = \"Active\""); + Assert.Equal(ConditionTokenType.Identifier, t[0].Type); + Assert.Equal("Status", t[0].Text); + Assert.Equal(ConditionTokenType.Operator, t[1].Type); + Assert.Equal("=", t[1].Text); + Assert.Equal(ConditionTokenType.String, t[2].Type); + Assert.Equal("Active", t[2].Text); + Assert.Equal(ConditionTokenType.End, t[3].Type); + } + + [Fact] + public void Tokenize_GreaterOrEqual_MatchesLongestOperator() + { + List t = Lex("Count >= 3"); + Assert.Equal(ConditionTokenType.Operator, t[1].Type); + Assert.Equal(">=", t[1].Text); + Assert.Equal(ConditionTokenType.Number, t[2].Type); + Assert.Equal(3, t[2].LiteralValue); + } + + [Fact] + public void Tokenize_ListLiteral_ProducesParensAndCommas() + { + List t = Lex("Status in (\"A\", \"B\")"); + Assert.Equal("in", t[1].Text); + Assert.Equal(ConditionTokenType.LParen, t[2].Type); + Assert.Equal(ConditionTokenType.String, t[3].Type); + Assert.Equal(ConditionTokenType.Comma, t[4].Type); + Assert.Equal(ConditionTokenType.RParen, t[6].Type); + } + + [Fact] + public void Tokenize_WordOperators_AreLowercasedOperators() + { + List t = Lex("A AND B IS EMPTY"); + Assert.Equal(ConditionTokenType.Operator, t[1].Type); + Assert.Equal("and", t[1].Text); + Assert.Equal("is", t[3].Text); + Assert.Equal("empty", t[4].Text); + } + + [Fact] + public void Tokenize_CurlyQuotes_AreNormalized() + { + List t = Lex("Status = “Active”"); + Assert.Equal(ConditionTokenType.String, t[2].Type); + Assert.Equal("Active", t[2].Text); + } + + [Fact] + public void Tokenize_BooleanAndNull_ProduceLiteralTokens() + { + List t = Lex("Flag = true"); + Assert.Equal(ConditionTokenType.Boolean, t[2].Type); + Assert.Equal(true, t[2].LiteralValue); + } +} diff --git a/TriasDev.Templify.Tests/Engine/ConditionParserTests.cs b/TriasDev.Templify.Tests/Engine/ConditionParserTests.cs new file mode 100644 index 0000000..b63014e --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/ConditionParserTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionParserTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void AndBindsTighterThanOr() + { + // false or (true and true) => true ; if or bound tighter, (false or true) and false path differs. + Assert.True(Eval("A or B and C", new() { ["A"] = false, ["B"] = true, ["C"] = true })); + Assert.False(Eval("A or B and C", new() { ["A"] = false, ["B"] = true, ["C"] = false })); + } + + [Fact] + public void ParenthesesOverridePrecedence() + { + Assert.False(Eval("(A or B) and C", new() { ["A"] = false, ["B"] = true, ["C"] = false })); + Assert.True(Eval("(A or B) and C", new() { ["A"] = false, ["B"] = true, ["C"] = true })); + } + + [Fact] + public void ComparisonBindsTighterThanNot() + { + // not Status = "Active" => not (Status = "Active") + Assert.False(Eval("not Status = \"Active\"", new() { ["Status"] = "Active" })); + Assert.True(Eval("not Status = \"Active\"", new() { ["Status"] = "Inactive" })); + } + + [Fact] + public void MalformedExpression_Throws() + { + var tokens = new ConditionLexer().Tokenize("Status ="); + Assert.Throws(() => + new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens)); + } +} diff --git a/TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs b/TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs new file mode 100644 index 0000000..33fe253 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionValueOpsTests +{ + [Fact] + public void AreEqual_SameStrings_ReturnsTrue() + => Assert.True(ConditionValueOps.AreEqual("Active", "Active")); + + [Fact] + public void AreEqual_DifferentCaseStrings_ReturnsFalse() + => Assert.False(ConditionValueOps.AreEqual("active", "Active")); + + [Fact] + public void AreEqual_BoolAndLowercaseLiteral_ReturnsTrue() + => Assert.True(ConditionValueOps.AreEqual(true, "true")); + + [Fact] + public void AreEqual_BothNull_ReturnsTrue() + => Assert.True(ConditionValueOps.AreEqual(null, null)); + + [Fact] + public void AreEqual_OneNull_ReturnsFalse() + => Assert.False(ConditionValueOps.AreEqual(null, "x")); +} diff --git a/TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs b/TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs new file mode 100644 index 0000000..84fccd5 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class ExistenceOperatorsTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void Exists_PresentVariable_IsTrue() + => Assert.True(Eval("Notes exists", new() { ["Notes"] = "x" })); + + [Fact] + public void Exists_MissingVariable_IsFalse() + => Assert.False(Eval("Notes exists", new())); + + [Fact] + public void IsEmpty_EmptyString_IsTrue() + => Assert.True(Eval("Notes is empty", new() { ["Notes"] = " " })); + + [Fact] + public void IsEmpty_EmptyCollection_IsTrue() + => Assert.True(Eval("Items is empty", new() { ["Items"] = new List() })); + + [Fact] + public void IsEmpty_MissingVariable_IsTrue() + => Assert.True(Eval("Notes is empty", new())); + + [Fact] + public void IsNotEmpty_NonEmpty_IsTrue() + => Assert.True(Eval("Notes is not empty", new() { ["Notes"] = "x" })); + + [Fact] + public void PresentButNull_ExistsFalse_IsEmptyTrue() + { + var data = new Dictionary { ["Notes"] = null }; + var ctx = new GlobalEvaluationContext(data!); + ConditionNode existsNode = Build("Notes exists"); + ConditionNode emptyNode = Build("Notes is empty"); + // 'Notes' resolves (key present) → exists true; value null → is empty true. + Assert.True(new ConditionEvaluatorCore(ctx, DefaultConditionDialect.Instance).EvaluateBool(existsNode)); + Assert.True(new ConditionEvaluatorCore(ctx, DefaultConditionDialect.Instance).EvaluateBool(emptyNode)); + } + + private static ConditionNode Build(string expr) + => new ConditionParser(ConditionOperatorRegistry.Shared).Parse(new ConditionLexer().Tokenize(expr)); +} diff --git a/TriasDev.Templify.Tests/Engine/ExistenceValidationTests.cs b/TriasDev.Templify.Tests/Engine/ExistenceValidationTests.cs new file mode 100644 index 0000000..982f839 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/ExistenceValidationTests.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals; + +namespace TriasDev.Templify.Tests.Engine; + +public class ExistenceValidationTests +{ + private static bool IsValid(string expression) => new ConditionalEvaluator().Validate(expression).IsValid; + + [Fact] + public void Validate_Exists_IsValid() + => Assert.True(IsValid("Notes exists")); + + [Fact] + public void Validate_IsEmpty_IsValid() + => Assert.True(IsValid("Notes is empty")); + + [Fact] + public void Validate_IsNotEmpty_IsValid() + => Assert.True(IsValid("Notes is not empty")); + + [Fact] + public void Validate_ComparisonAndExists_IsValid() + => Assert.True(IsValid("Status = \"Active\" and Notes exists")); +} diff --git a/TriasDev.Templify.Tests/Engine/InlineDialectTests.cs b/TriasDev.Templify.Tests/Engine/InlineDialectTests.cs new file mode 100644 index 0000000..7cfb798 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/InlineDialectTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class InlineDialectTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), InlineConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void And_TwoBooleans() + => Assert.True(Eval("(var1 and var2)", new() { ["var1"] = true, ["var2"] = true })); + + [Fact] + public void Or_TwoBooleans() + => Assert.True(Eval("(var1 or var2)", new() { ["var1"] = false, ["var2"] = true })); + + [Fact] + public void Not_Boolean() + => Assert.True(Eval("(not IsActive)", new() { ["IsActive"] = false })); + + [Fact] + public void Comparison_Numeric() + => Assert.True(Eval("(Count > 0)", new() { ["Count"] = 5 })); + + [Fact] + public void Nested_Grouping() + => Assert.True(Eval("((var1 or var2) and var3)", new() { ["var1"] = false, ["var2"] = true, ["var3"] = true })); + + [Fact] + public void BareStringVariable_IsFalse_InInlineDialect() + => Assert.False(Eval("(Name)", new() { ["Name"] = "Alice" })); // Inline truthiness: only bool true is true + + [Fact] + public void NewOperators_WorkInInlineDialect() + => Assert.True(Eval("(Status in (\"A\", \"B\"))", new() { ["Status"] = "B" })); +} diff --git a/TriasDev.Templify.Tests/Engine/MembershipOperatorTests.cs b/TriasDev.Templify.Tests/Engine/MembershipOperatorTests.cs new file mode 100644 index 0000000..d1819e9 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/MembershipOperatorTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class MembershipOperatorTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void In_CollectionVariable_Matches() + => Assert.True(Eval("Status in Roles", new() { ["Status"] = "Admin", ["Roles"] = new List { "User", "Admin" } })); + + [Fact] + public void In_CollectionVariable_NoMatch() + => Assert.False(Eval("Status in Roles", new() { ["Status"] = "Guest", ["Roles"] = new List { "User", "Admin" } })); + + [Fact] + public void In_ListLiteral_Matches() + => Assert.True(Eval("Status in (\"Active\", \"Pending\")", new() { ["Status"] = "Pending" })); + + [Fact] + public void In_CommaString_Matches() + => Assert.True(Eval("Status in \"Active,Pending\"", new() { ["Status"] = "Active" })); + + [Fact] + public void NotIn_Negation_Works() + => Assert.True(Eval("not Status in Roles", new() { ["Status"] = "Guest", ["Roles"] = new List { "User", "Admin" } })); +} diff --git a/TriasDev.Templify.Tests/Engine/NewSyntaxValidationTests.cs b/TriasDev.Templify.Tests/Engine/NewSyntaxValidationTests.cs new file mode 100644 index 0000000..2c30ac3 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/NewSyntaxValidationTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals; + +namespace TriasDev.Templify.Tests.Engine; + +/// +/// Verifies that the public accepts the operator syntax the +/// expression engine supports, matching what Evaluate accepts (parser-based validation, F1). +/// +public class NewSyntaxValidationTests +{ + private readonly ConditionEvaluator _evaluator = new(); + + [Theory] + [InlineData("Status in (\"Active\", \"Pending\")")] // Previously broken: whitespace after the comma. + [InlineData("Status in (\"A\",\"B\")")] + [InlineData("Role in Roles")] + [InlineData("not Role in Roles")] + [InlineData("Email contains \"@x\"")] + [InlineData("Name startswith \"A\"")] + [InlineData("Notes exists")] + [InlineData("Notes is empty")] + [InlineData("Notes is not empty")] + [InlineData("(A or B) and C")] + public void Validate_NewSyntax_IsValid(string expression) + { + ConditionValidationResult result = _evaluator.Validate(expression); + + Assert.True(result.IsValid, $"Expected '{expression}' to be valid."); + Assert.Empty(result.Issues); + } + + [Fact] + public void Validate_TrailingOperator_StillReportsMissingOperand() + { + ConditionValidationResult result = _evaluator.Validate("Status ="); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => i.Type == ConditionValidationIssueType.MissingOperand); + } +} diff --git a/TriasDev.Templify.Tests/Engine/SingleQuoteLexingTests.cs b/TriasDev.Templify.Tests/Engine/SingleQuoteLexingTests.cs new file mode 100644 index 0000000..aa34639 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/SingleQuoteLexingTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class SingleQuoteLexingTests +{ + private static bool Eval(string expr, Dictionary data) + { + IReadOnlyList tokens = new ConditionLexer(allowSingleQuotedStrings: true).Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), InlineConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void InlinePath_SingleQuotedString_MatchesTrue() + => Assert.True(Eval("(Status = 'Active')", new() { ["Status"] = "Active" })); + + [Fact] + public void InlinePath_SingleQuotedString_MatchesFalse() + => Assert.False(Eval("(Status = 'Active')", new() { ["Status"] = "Inactive" })); + + [Fact] + public void InlinePath_MixedQuoteStyles_BothWork() + => Assert.True(Eval( + "(A = 'x' or B = \"y\")", + new() { ["A"] = "x", ["B"] = "z" })); + + [Fact] + public void InlinePath_MixedQuoteStyles_OtherBranch_BothWork() + => Assert.True(Eval( + "(A = 'x' or B = \"y\")", + new() { ["A"] = "notx", ["B"] = "y" })); + + [Fact] + public void Lexer_SingleQuoteEscape_ProducesSingleStringToken() + { + IReadOnlyList tokens = new ConditionLexer(allowSingleQuotedStrings: true).Tokenize("('a\\'b')"); + + ConditionToken stringToken = tokens.Single(t => t.Type == ConditionTokenType.String); + Assert.Equal("a'b", stringToken.Text); + } + + [Fact] + public void Lexer_DefaultOff_SingleQuoteIsNotStringDelimiter() + { + IReadOnlyList tokens = new ConditionLexer().Tokenize("'x'"); + + Assert.NotEqual(ConditionTokenType.String, tokens[0].Type); + } +} diff --git a/TriasDev.Templify.Tests/Engine/StrictResolutionTests.cs b/TriasDev.Templify.Tests/Engine/StrictResolutionTests.cs new file mode 100644 index 0000000..bfac4c6 --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/StrictResolutionTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class StrictResolutionTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void In_MissingCollection_IsFalse() + => Assert.False(Eval("Status in Roles", new() { ["Status"] = "X" })); + + [Fact] + public void In_BothOperandsMissing_IsFalse() + => Assert.False(Eval("Status in Roles", new())); + + [Fact] + public void In_ResolvedCollection_StillMatches() + => Assert.True(Eval("Status in Roles", new() { ["Status"] = "Admin", ["Roles"] = new List { "User", "Admin" } })); + + [Fact] + public void Contains_MissingRightOperand_IsFalse() + => Assert.False(Eval("Desc contains Sub", new() { ["Desc"] = "hello" })); + + [Fact] + public void Contains_MissingLeftOperand_IsFalse() + => Assert.False(Eval("Desc contains Sub", new())); + + [Fact] + public void Contains_LiteralRightOperand_StillMatches() + => Assert.True(Eval("Desc contains \"ell\"", new() { ["Desc"] = "hello" })); +} diff --git a/TriasDev.Templify.Tests/Engine/StringOperatorsTests.cs b/TriasDev.Templify.Tests/Engine/StringOperatorsTests.cs new file mode 100644 index 0000000..16dfe5c --- /dev/null +++ b/TriasDev.Templify.Tests/Engine/StringOperatorsTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class StringOperatorsTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void Contains_Substring_IsTrue() + => Assert.True(Eval("Description contains \"urgent\"", new() { ["Description"] = "this is urgent" })); + + [Fact] + public void Contains_IsCaseSensitive() + => Assert.False(Eval("Description contains \"URGENT\"", new() { ["Description"] = "this is urgent" })); + + [Fact] + public void StartsWith_Prefix_IsTrue() + => Assert.True(Eval("Phone startswith \"+49\"", new() { ["Phone"] = "+49 151" })); + + [Fact] + public void EndsWith_Suffix_IsTrue() + => Assert.True(Eval("File endswith \".pdf\"", new() { ["File"] = "report.pdf" })); +} diff --git a/TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs b/TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs deleted file mode 100644 index 5eb717c..0000000 --- a/TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -using System.Reflection; -using TriasDev.Templify.Core; - -namespace TriasDev.Templify.Tests.Expressions; - -public class BooleanExpressionParserTests -{ - // Use reflection to access internal types - private static readonly Type _parserType = typeof(DocumentTemplateProcessor).Assembly - .GetType("TriasDev.Templify.Expressions.BooleanExpressionParser")!; - - private static readonly Type _expressionType = typeof(DocumentTemplateProcessor).Assembly - .GetType("TriasDev.Templify.Expressions.BooleanExpression")!; - - private object CreateParser() - { - return Activator.CreateInstance(_parserType)!; - } - - private object? Parse(object parser, string text) - { - MethodInfo? parseMethod = _parserType.GetMethod("Parse"); - return parseMethod?.Invoke(parser, new object[] { text }); - } - - [Fact] - public void Parse_WithSimpleVariable_ReturnsNull() - { - // Arrange - simple variables should not be parsed as expressions - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "IsActive"); - - // Assert - Assert.Null(result); - } - - [Fact] - public void Parse_WithAndExpression_ReturnsExpression() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "(var1 and var2)"); - - // Assert - Assert.NotNull(result); - Assert.IsAssignableFrom(_expressionType, result); - } - - [Fact] - public void Parse_WithOrExpression_ReturnsExpression() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "(var1 or var2)"); - - // Assert - Assert.NotNull(result); - Assert.IsAssignableFrom(_expressionType, result); - } - - [Fact] - public void Parse_WithNotExpression_ReturnsExpression() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "(not IsActive)"); - - // Assert - Assert.NotNull(result); - Assert.IsAssignableFrom(_expressionType, result); - } - - [Fact] - public void Parse_WithComparisonGreaterThan_ReturnsExpression() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "(Count > 0)"); - - // Assert - Assert.NotNull(result); - Assert.IsAssignableFrom(_expressionType, result); - } - - [Fact] - public void Parse_WithComparisonEquals_ReturnsExpression() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "(Status == \"active\")"); - - // Assert - Assert.NotNull(result); - Assert.IsAssignableFrom(_expressionType, result); - } - - [Fact] - public void Parse_WithComparisonSingleEquals_ReturnsExpression() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "(Status = \"active\")"); - - // Assert - Assert.NotNull(result); - Assert.IsAssignableFrom(_expressionType, result); - } - - [Fact] - public void Parse_WithNestedExpression_ReturnsExpression() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, "((var1 or var2) and var3)"); - - // Assert - Assert.NotNull(result); - Assert.IsAssignableFrom(_expressionType, result); - } - - [Fact] - public void Parse_WithEmptyString_ReturnsNull() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, ""); - - // Assert - Assert.Null(result); - } - - [Fact] - public void Parse_WithWhitespace_ReturnsNull() - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, " "); - - // Assert - Assert.Null(result); - } - - [Theory] - [InlineData("(var1 and var2)")] - [InlineData("(var1 or var2)")] - [InlineData("(not var1)")] - [InlineData("(Count > 5)")] - [InlineData("(Count >= 5)")] - [InlineData("(Count < 10)")] - [InlineData("(Count <= 10)")] - [InlineData("(Status == \"active\")")] - [InlineData("(Status != \"inactive\")")] - public void Parse_WithValidExpressions_ReturnsExpression(string expression) - { - // Arrange - object parser = CreateParser(); - - // Act - object? result = Parse(parser, expression); - - // Assert - Assert.NotNull(result); - } - - [Theory] - [InlineData("var1")] // No parentheses - [InlineData("IsActive")] // Simple variable - [InlineData("Customer.Name")] // Nested property - public void Parse_WithoutParentheses_ReturnsNull(string text) - { - // Arrange - expressions must start with parenthesis - object parser = CreateParser(); - - // Act - object? result = Parse(parser, text); - - // Assert - Assert.Null(result); - } -} diff --git a/TriasDev.Templify.Tests/Integration/NewOperatorsIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/NewOperatorsIntegrationTests.cs new file mode 100644 index 0000000..972b9d8 --- /dev/null +++ b/TriasDev.Templify.Tests/Integration/NewOperatorsIntegrationTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals; +using TriasDev.Templify.Core; +using TriasDev.Templify.Tests.Helpers; + +namespace TriasDev.Templify.Tests.Integration; + +/// +/// End-to-end integration tests for the new condition operators (in, contains, startswith, +/// endswith, exists, is empty, is not empty) and grouping, exercised through the public +/// standalone API and through document-level template processing. +/// +public class NewOperatorsIntegrationTests +{ + private readonly IConditionEvaluator _evaluator = new ConditionEvaluator(); + + [Fact] + public void StandaloneApi_In_ListLiteral() + => Assert.True(_evaluator.Evaluate("Status in (\"Active\", \"Pending\")", + new Dictionary { ["Status"] = "Active" })); + + [Fact] + public void StandaloneApi_Contains() + => Assert.True(_evaluator.Evaluate("Email contains \"@trias\"", + new Dictionary { ["Email"] = "a@trias.dev" })); + + [Fact] + public void StandaloneApi_Exists_And_IsEmpty() + { + var data = new Dictionary { ["Name"] = "Alice" }; + Assert.True(_evaluator.Evaluate("Name exists", data)); + Assert.True(_evaluator.Evaluate("Missing is empty", data)); + } + + [Fact] + public void StandaloneApi_Grouping_And_Precedence() + { + var data = new Dictionary { ["A"] = false, ["B"] = true, ["C"] = false }; + Assert.True(_evaluator.Evaluate("A or B and C = false", data)); // and binds tighter than or + Assert.False(_evaluator.Evaluate("(A or B) and C", data)); + } + + [Fact] + public void StandaloneApi_NotIn() + => Assert.True(_evaluator.Evaluate("not Role in Roles", + new Dictionary { ["Role"] = "Guest", ["Roles"] = new List { "Admin", "User" } })); + + [Fact] + public void ProcessTemplate_IfInOperator_ConditionTrue_KeepsContent() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#if Role in Roles}}"); + builder.AddParagraph("VISIBLE"); + builder.AddParagraph("{{/if}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Role"] = "Admin", + ["Roles"] = new List { "Admin", "User" } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + Assert.Equal(1, verifier.GetParagraphCount()); + Assert.Equal("VISIBLE", verifier.GetParagraphText(0)); + } +} diff --git a/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs b/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs index 959f858..684f919 100644 --- a/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs +++ b/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs @@ -1,7 +1,6 @@ // Copyright (c) 2025 TriasDev GmbH & Co. KG // Licensed under the MIT License. See LICENSE file in the project root for full license information. -using System.Collections; using System.Text; using TriasDev.Templify.Core; using TriasDev.Templify.Placeholders; @@ -37,13 +36,21 @@ internal sealed class ConditionalEvaluator /// Validates a conditional expression for syntactic correctness. /// This is a pure syntax check that does not require a data context. /// + /// + /// Validation is parser-based so that it accepts exactly what + /// accepts (including the operator syntax added by the expression engine, e.g. in, contains, + /// exists). Two independent pre-checks that the parser does not naturally surface are kept: + /// empty/whitespace expressions and unbalanced quotes (the lexer is permissive about unterminated + /// strings, so the parser would not report those). When the parser rejects an expression, a heuristic + /// structural analysis classifies the failure into a typed issue. + /// /// The expression to validate. /// A validation result indicating whether the expression is valid and any issues found. internal ConditionValidationResult Validate(string expression) { List issues = new(); - // Rule 1: Empty expression + // Pre-check (a): Empty/whitespace expression. if (string.IsNullOrWhiteSpace(expression)) { issues.Add(new ConditionValidationIssue( @@ -52,7 +59,8 @@ internal ConditionValidationResult Validate(string expression) return ConditionValidationResult.Failure(issues); } - // Rule 2: Unbalanced quotes + // Pre-check (b): Unbalanced quotes. The lexer accepts unterminated strings, so the parser + // would NOT catch this — the pre-check must remain. string normalized = NormalizeQuotes(expression); int quoteCount = 0; foreach (char c in normalized) @@ -70,24 +78,69 @@ internal ConditionValidationResult Validate(string expression) "Expression contains unbalanced quotes.")); } - // Tokenize + // Attempt a real parse. Validate must accept everything the parser (and therefore Evaluate) + // accepts, including the new operator syntax. + if (TryParse(expression)) + { + // Parser accepts the expression; only pre-check issues (if any) remain. + return issues.Count == 0 + ? ConditionValidationResult.Success() + : ConditionValidationResult.Failure(issues); + } + + // Parser rejected the expression. Fall back to the heuristic structural analysis to classify + // the failure into a typed issue, combined with any pre-check issues. + AnalyzeStructure(expression, issues); + + if (issues.Count == 0) + { + // Parser failed but the heuristic found nothing specific; still report the expression as + // invalid rather than silently succeeding. + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.MissingOperand, + "Expression is not a valid condition.")); + } + + return ConditionValidationResult.Failure(issues); + } + + /// + /// Attempts to fully parse the expression using the expression engine. + /// + /// true if the parser accepts the expression; otherwise false. + private static bool TryParse(string expression) + { + try + { + IReadOnlyList tokens = new Engine.ConditionLexer().Tokenize(expression); + new Engine.ConditionParser(Engine.ConditionOperatorRegistry.Shared).Parse(tokens); + return true; + } + catch (Engine.ConditionParseException) + { + return false; + } + } + + /// + /// Heuristic structural analysis used only when the parser rejects an expression. It reproduces the + /// legacy whitespace-split classification so parser failures map onto the historical typed issues + /// (, ConsecutiveOperators, + /// ConsecutiveOperands, UnknownOperator). Discovered issues are appended to + /// . + /// + private void AnalyzeStructure(string expression, List issues) + { List tokens = ParseExpression(expression); if (tokens.Count == 0) { - if (issues.Count == 0) - { - issues.Add(new ConditionValidationIssue( - ConditionValidationIssueType.EmptyExpression, - "Expression is empty.")); - } - - return ConditionValidationResult.Failure(issues); + return; } - // Walk tokens and check structure - // Classify: "operator" (comparison/logical), "not", or "operand" - string? previousType = null; // "operand", "comparison", "logical", "not" + // Walk tokens and check structure. + // Classify: "operator" (comparison/logical), "not", "postfix", or "operand". + string? previousType = null; string? previousToken = null; for (int i = 0; i < tokens.Count; i++) @@ -99,6 +152,16 @@ internal ConditionValidationResult Validate(string expression) { currentType = "not"; } + else if (string.Equals(token, "exists", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "empty", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "is", StringComparison.OrdinalIgnoreCase)) + { + // Postfix existence/emptiness keywords (`exists`, `is empty`, `is not empty`). + // Kept distinct from "comparison"/"logical" so they are exempt from the + // trailing-operator check (an expression is allowed to end in one of these) + // and from the consecutive-operator/operand checks around them. + currentType = "postfix"; + } else if (IsComparisonOperator(token)) { currentType = "comparison"; @@ -163,10 +226,6 @@ internal ConditionValidationResult Validate(string expression) $"Operator '{previousToken}' is missing a right-hand operand.", previousToken)); } - - return issues.Count == 0 - ? ConditionValidationResult.Success() - : ConditionValidationResult.Failure(issues); } /// @@ -206,22 +265,16 @@ public bool Evaluate(string expression, IEvaluationContext context) return false; } - // Parse the expression into tokens - List tokens = ParseExpression(expression); - - if (tokens.Count == 0) + try { - return false; + IReadOnlyList tokens = new Engine.ConditionLexer().Tokenize(expression); + Engine.ConditionNode node = new Engine.ConditionParser(Engine.ConditionOperatorRegistry.Shared).Parse(tokens); + return new Engine.ConditionEvaluatorCore(context, Engine.DefaultConditionDialect.Instance).EvaluateBool(node); } - - // If it's a simple variable reference, evaluate it directly - if (tokens.Count == 1) + catch (Engine.ConditionParseException) { - return EvaluateVariable(tokens[0], context, out _); + return false; } - - // Process complex expression with operators - return EvaluateTokens(tokens, context); } /// @@ -282,346 +335,6 @@ private List ParseExpression(string expression) return tokens; } - /// - /// Evaluates a list of tokens with operators. - /// - private bool EvaluateTokens(List tokens, IEvaluationContext context) - { - // Check if expression starts with NOT - int startIndex = 0; - bool negateNext = false; - if (tokens[0].ToLower() == NotOperator) - { - negateNext = true; - startIndex = 1; - } - - // Get the initial variable value - bool result = EvaluateVariable(tokens[startIndex], context, out object? currentValue); - - string? lastOperator = null; - string? pendingLogicalOperator = null; - - for (int i = startIndex + 1; i < tokens.Count; i++) - { - string token = tokens[i]; - - // Check if it's an operator - if (IsLogicalOperator(token)) - { - pendingLogicalOperator = token.ToLower(); - lastOperator = token.ToLower(); - } - else if (IsComparisonOperator(token)) - { - lastOperator = token.ToLower(); - } - else if (token.ToLower() == NotOperator) - { - result = !result; - negateNext = !negateNext; - } - else - { - // This is a value/variable to compare - if (lastOperator != null) - { - // Get the value (either from data or as literal) - object? nextValue = ResolveValueOrLiteral(token, context); - - // Check if next operation is a comparison (for chained expressions like "var1 or var2 eq value") - bool isComparisonFollowing = false; - if (IsLogicalOperator(lastOperator) && i + 1 < tokens.Count) - { - isComparisonFollowing = IsComparisonOperator(tokens[i + 1]); - } - - if (isComparisonFollowing) - { - // This is a variable for the next comparison - currentValue = nextValue; - continue; - } - - // Perform the operation - switch (lastOperator) - { - case OrOperator: - result = result || EvaluateValue(nextValue); - break; - - case AndOperator: - result = result && EvaluateValue(nextValue); - break; - - case EqOperator: - case EqOperatorDouble: - { - bool comparisonResult = AreEqual(currentValue, nextValue); - if (negateNext) - { - comparisonResult = !comparisonResult; - negateNext = false; - } - result = ApplyLogicalOperator(result, comparisonResult, pendingLogicalOperator); - pendingLogicalOperator = null; - break; - } - - case NeOperator: - { - bool comparisonResult = !AreEqual(currentValue, nextValue); - if (negateNext) - { - comparisonResult = !comparisonResult; - negateNext = false; - } - result = ApplyLogicalOperator(result, comparisonResult, pendingLogicalOperator); - pendingLogicalOperator = null; - break; - } - - case GtOperator: - { - bool comparisonResult = IsGreaterThan(currentValue, nextValue); - if (negateNext) - { - comparisonResult = !comparisonResult; - negateNext = false; - } - result = ApplyLogicalOperator(result, comparisonResult, pendingLogicalOperator); - pendingLogicalOperator = null; - break; - } - - case LtOperator: - { - bool comparisonResult = IsLessThan(currentValue, nextValue); - if (negateNext) - { - comparisonResult = !comparisonResult; - negateNext = false; - } - result = ApplyLogicalOperator(result, comparisonResult, pendingLogicalOperator); - pendingLogicalOperator = null; - break; - } - - case GteOperator: - { - bool comparisonResult = IsGreaterThan(currentValue, nextValue) || AreEqual(currentValue, nextValue); - if (negateNext) - { - comparisonResult = !comparisonResult; - negateNext = false; - } - result = ApplyLogicalOperator(result, comparisonResult, pendingLogicalOperator); - pendingLogicalOperator = null; - break; - } - - case LteOperator: - { - bool comparisonResult = IsLessThan(currentValue, nextValue) || AreEqual(currentValue, nextValue); - if (negateNext) - { - comparisonResult = !comparisonResult; - negateNext = false; - } - result = ApplyLogicalOperator(result, comparisonResult, pendingLogicalOperator); - pendingLogicalOperator = null; - break; - } - } - - lastOperator = null; - } - } - } - - // If negateNext is still true at the end, it means we had "not Variable" with no comparison - // In this case, negate the result - if (negateNext && lastOperator == null) - { - result = !result; - } - - return result; - } - - /// - /// Applies a logical operator between two boolean values. - /// - private bool ApplyLogicalOperator(bool currentResult, bool comparisonResult, string? pendingOperator) - { - if (pendingOperator == null) - { - return comparisonResult; - } - - return pendingOperator == OrOperator - ? currentResult || comparisonResult - : currentResult && comparisonResult; - } - - /// - /// Resolves a value from context or returns it as a literal. - /// - private object? ResolveValueOrLiteral(string token, IEvaluationContext context) - { - // Try to resolve as variable first - if (context.TryResolveVariable(token, out object? value)) - { - return value; - } - - // Return as literal - return token; - } - - /// - /// Evaluates a variable from the evaluation context. - /// - private bool EvaluateVariable(string variablePath, IEvaluationContext context, out object? value) - { - if (context.TryResolveVariable(variablePath, out value)) - { - return EvaluateValue(value); - } - - value = null; - return false; - } - - /// - /// Evaluates a value as a boolean. - /// Follows OpenXMLTemplates rules: - /// - null → false - /// - bool → its value - /// - "true"/"false" → true/false - /// - 1/0 → true/false - /// - "1"/"0" → true/false - /// - empty string/whitespace → false - /// - empty collection → false - /// - non-empty string → true - /// - non-empty collection → true - /// - private bool EvaluateValue(object? value) - { - if (value == null) - { - return false; - } - - if (value is bool boolValue) - { - return boolValue; - } - - if (value is string stringValue) - { - if (string.IsNullOrWhiteSpace(stringValue)) - { - return false; - } - - string lowerValue = stringValue.ToLower(); - if (lowerValue == "false" || lowerValue == "0") - { - return false; - } - - if (lowerValue == "true" || lowerValue == "1") - { - return true; - } - - // Non-empty string - return true; - } - - if (value is int intValue) - { - return intValue switch - { - 0 => false, - 1 => true, - _ => true // Non-zero/one integers are true - }; - } - - if (value is ICollection collection) - { - return collection.Count > 0; - } - - // Any other non-null value - return true; - } - - /// - /// Compares two values for equality. - /// - private bool AreEqual(object? left, object? right) - { - if (left == null && right == null) - { - return true; - } - - if (left == null || right == null) - { - return false; - } - - // Use case-insensitive comparison for booleans because C#'s - // bool.ToString() returns "True"/"False" while template literals - // are lowercase "true"/"false". - if (left is bool || right is bool || IsBooleanLiteral(left) || IsBooleanLiteral(right)) - { - return string.Equals(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase); - } - - return left.ToString() == right.ToString(); - } - - private static bool IsBooleanLiteral(object? value) - { - string? str = value as string; - return str != null && (str.Equals("true", StringComparison.OrdinalIgnoreCase) - || str.Equals("false", StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Checks if left is greater than right. - /// - private bool IsGreaterThan(object? left, object? right) - { - try - { - return double.Parse(left?.ToString() ?? "0") > double.Parse(right?.ToString() ?? "0"); - } - catch - { - return false; - } - } - - /// - /// Checks if left is less than right. - /// - private bool IsLessThan(object? left, object? right) - { - try - { - return double.Parse(left?.ToString() ?? "0") < double.Parse(right?.ToString() ?? "0"); - } - catch - { - return false; - } - } - private bool IsLogicalOperator(string token) { string lower = token.ToLower(); @@ -633,7 +346,8 @@ private bool IsComparisonOperator(string token) string lower = token.ToLower(); return lower == EqOperator || lower == EqOperatorDouble || lower == NeOperator || lower == GtOperator || lower == LtOperator || - lower == GteOperator || lower == LteOperator; + lower == GteOperator || lower == LteOperator || + lower == "in" || lower == "contains" || lower == "startswith" || lower == "endswith"; } /// diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionDialect.cs b/TriasDev.Templify/Conditionals/Engine/ConditionDialect.cs new file mode 100644 index 0000000..ad7fdf8 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionDialect.cs @@ -0,0 +1,96 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; +using System.Globalization; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Externally-observable semantic policy for an entry point (truthiness + base comparison). +internal abstract class ConditionDialect +{ + /// Coerces a value to a boolean (truthiness). + public abstract bool ToBool(object? value); + + /// Equality used by the =/!= operators. + public abstract bool AreEqual(object? left, object? right); + + /// Ordered comparison used by >/</>=/<=. + /// Returns false when the operands are not comparable in this dialect. + public abstract bool TryCompare(object? left, object? right, out int cmp); +} + +/// Semantics for {{#if}}, text templates, and the standalone API (rich truthiness, numeric compare). +internal sealed class DefaultConditionDialect : ConditionDialect +{ + public static readonly DefaultConditionDialect Instance = new(); + + public override bool ToBool(object? value) + { + if (value == null) + { return false; } + if (value is bool b) + { return b; } + + if (value is string s) + { + if (string.IsNullOrWhiteSpace(s)) + { return false; } + string lower = s.ToLowerInvariant(); + if (lower == "false" || lower == "0") + { return false; } + if (lower == "true" || lower == "1") + { return true; } + return true; + } + + if (value is int i) + { return i != 0; } + if (value is ICollection c) + { return c.Count > 0; } + return true; + } + + public override bool AreEqual(object? left, object? right) => ConditionValueOps.AreEqual(left, right); + + public override bool TryCompare(object? left, object? right, out int cmp) + { + cmp = 0; + if (double.TryParse(left?.ToString() ?? "0", NumberStyles.Float, CultureInfo.InvariantCulture, out double l) + && double.TryParse(right?.ToString() ?? "0", NumberStyles.Float, CultureInfo.InvariantCulture, out double r)) + { + cmp = l.CompareTo(r); + return true; + } + return false; + } +} + +/// Semantics for inline {{(...)}} placeholders (bool-only truthiness, IComparable compare). +internal sealed class InlineConditionDialect : ConditionDialect +{ + public static readonly InlineConditionDialect Instance = new(); + + public override bool ToBool(object? value) => value is bool b && b; + + public override bool AreEqual(object? left, object? right) => Equals(left, right); + + public override bool TryCompare(object? left, object? right, out int cmp) + { + cmp = 0; + if (left == null || right == null) + { + cmp = left == null ? (right == null ? 0 : -1) : 1; + return true; + } + + if (left is IComparable lc && right is IComparable) + { + try + { cmp = lc.CompareTo(right); return true; } + catch (ArgumentException) { return false; } + catch (InvalidCastException) { return false; } + } + return false; + } +} diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionEvaluatorCore.cs b/TriasDev.Templify/Conditionals/Engine/ConditionEvaluatorCore.cs new file mode 100644 index 0000000..bbb94c5 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionEvaluatorCore.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Walks a AST, resolving variables and applying operators. +internal sealed class ConditionEvaluatorCore +{ + private readonly IEvaluationContext _context; + + public ConditionEvaluatorCore(IEvaluationContext context, ConditionDialect dialect) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + Dialect = dialect ?? throw new ArgumentNullException(nameof(dialect)); + } + + public ConditionDialect Dialect { get; } + + public bool TryResolveVariable(string path, out object? value) => _context.TryResolveVariable(path, out value); + + /// Evaluates a node to its boolean truth value. + public bool EvaluateBool(ConditionNode node) + { + return node switch + { + OperatorNode op => op.Operator.Evaluate(this, op.Operands), + LiteralNode lit => Dialect.ToBool(lit.Value), + VariableNode var => Dialect.ToBool(Resolve(var)), + _ => false + }; + } + + /// Evaluates a node to its underlying value (for operand comparison). + public object? EvaluateValue(ConditionNode node) + { + return node switch + { + LiteralNode lit => lit.Value, + VariableNode var => ResolveOrLiteral(var), + ListNode list => MaterializeList(list), + OperatorNode op => op.Operator.Evaluate(this, op.Operands), + _ => null + }; + } + + private object? Resolve(VariableNode var) + { + _context.TryResolveVariable(var.Path, out object? value); + return value; + } + + /// + /// Resolves a node's value for operators that must not fall back to a variable's path text + /// when it is missing (e.g. in, string operators, emptiness checks). A + /// that fails to resolve yields null (missing = absent) + /// rather than the literal-fallback behavior used by comparison operators. + /// + public object? ResolveValueStrict(ConditionNode node) + { + if (node is VariableNode v) + { + return TryResolveVariable(v.Path, out object? value) ? value : null; + } + return EvaluateValue(node); + } + + /// + /// Resolves a variable for use as a comparison operand, falling back to its own path + /// text as a string literal when it cannot be resolved. This preserves the historical + /// ConditionalEvaluator behavior where unquoted bareword comparison operands + /// (e.g. the Active in Status = Active) are treated as string literals + /// when they do not match a known variable. Truthiness checks (see ) + /// intentionally do NOT fall back this way, so a lone missing variable still evaluates to false. + /// + private object? ResolveOrLiteral(VariableNode var) + { + return _context.TryResolveVariable(var.Path, out object? value) ? value : var.Path; + } + + private List MaterializeList(ListNode list) + { + List items = new(list.Items.Count); + foreach (ConditionNode item in list.Items) + { + items.Add(EvaluateValue(item)); + } + return items; + } +} diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionLexer.cs b/TriasDev.Templify/Conditionals/Engine/ConditionLexer.cs new file mode 100644 index 0000000..33fc8ce --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionLexer.cs @@ -0,0 +1,175 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Text; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Converts a condition-expression string into a flat token list. +internal sealed class ConditionLexer +{ + private static readonly HashSet _wordOperators = new(StringComparer.OrdinalIgnoreCase) + { + "and", "or", "not", "in", "contains", "startswith", "endswith", "exists", "is", "empty" + }; + + private readonly bool _allowSingleQuotedStrings; + + /// + /// Creates a new . + /// + /// + /// When , single quotes (') delimit string literals in addition to + /// double quotes, mirroring the legacy inline {{(...)}} expression parser. Defaults to + /// , which preserves the original behavior used by {{#if}}/text/standalone + /// condition evaluation, where a single quote is not a string delimiter. + /// + public ConditionLexer(bool allowSingleQuotedStrings = false) + { + _allowSingleQuotedStrings = allowSingleQuotedStrings; + } + + public IReadOnlyList Tokenize(string expression) + { + string text = NormalizeQuotes(expression ?? string.Empty); + List tokens = new(); + int i = 0; + + while (i < text.Length) + { + char c = text[i]; + + if (char.IsWhiteSpace(c)) + { i++; continue; } + + if (c == '(') + { tokens.Add(new ConditionToken(ConditionTokenType.LParen, "(")); i++; continue; } + if (c == ')') + { tokens.Add(new ConditionToken(ConditionTokenType.RParen, ")")); i++; continue; } + if (c == ',') + { tokens.Add(new ConditionToken(ConditionTokenType.Comma, ",")); i++; continue; } + + if (c == '"' || (_allowSingleQuotedStrings && c == '\'')) + { + char quote = c; + i++; + StringBuilder sb = new(); + while (i < text.Length && text[i] != quote) + { + if (text[i] == '\\' && i + 1 < text.Length && text[i + 1] == quote) + { sb.Append(quote); i += 2; continue; } + sb.Append(text[i]); + i++; + } + i++; // closing quote (if present) + tokens.Add(new ConditionToken(ConditionTokenType.String, sb.ToString())); + continue; + } + + string? symbol = MatchSymbolOperator(text, i); + if (symbol != null) + { + tokens.Add(new ConditionToken(ConditionTokenType.Operator, symbol)); + i += symbol.Length; + continue; + } + + if (IsWordChar(c) || (c == '-' && i + 1 < text.Length && char.IsDigit(text[i + 1]))) + { + int start = i; + if (c == '-') + { i++; } + while (i < text.Length && IsWordChar(text[i])) + { i++; } + string word = text.Substring(start, i - start); + tokens.Add(ClassifyWord(word)); + continue; + } + + // Unknown character: emit as an Operator token so validation/parse can reject it. + tokens.Add(new ConditionToken(ConditionTokenType.Operator, c.ToString())); + i++; + } + + tokens.Add(new ConditionToken(ConditionTokenType.End, string.Empty)); + return tokens; + } + + private static ConditionToken ClassifyWord(string word) + { + if (_wordOperators.Contains(word)) + { + return new ConditionToken(ConditionTokenType.Operator, word.ToLowerInvariant()); + } + + if (word.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + return new ConditionToken(ConditionTokenType.Boolean, word, true); + } + + if (word.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + return new ConditionToken(ConditionTokenType.Boolean, word, false); + } + + if (word.Equals("null", StringComparison.OrdinalIgnoreCase)) + { + return new ConditionToken(ConditionTokenType.Null, word, null); + } + + if (TryParseNumber(word, out object? number)) + { + return new ConditionToken(ConditionTokenType.Number, word, number); + } + + return new ConditionToken(ConditionTokenType.Identifier, word); + } + + private static bool TryParseNumber(string word, out object? value) + { + if (!word.Contains('.') && int.TryParse(word, NumberStyles.Integer, CultureInfo.InvariantCulture, out int iv)) + { + value = iv; + return true; + } + + if (double.TryParse(word, NumberStyles.Float, CultureInfo.InvariantCulture, out double dv)) + { + value = dv; + return true; + } + + value = null; + return false; + } + + private static string? MatchSymbolOperator(string text, int i) + { + string[] twoChar = { ">=", "<=", "==", "!=" }; + if (i + 1 < text.Length) + { + string pair = text.Substring(i, 2); + foreach (string op in twoChar) + { + if (pair == op) + { return op; } + } + } + + char c = text[i]; + if (c == '=' || c == '>' || c == '<') + { return c.ToString(); } + return null; + } + + private static bool IsWordChar(char c) + => char.IsLetterOrDigit(c) || c == '_' || c == '.' || c == '[' || c == ']' || c == '@'; + + private static string NormalizeQuotes(string expression) + { + return expression + .Replace('“', '"').Replace('”', '"').Replace('„', '"').Replace('‟', '"') + .Replace('‘', '\'').Replace('’', '\'').Replace('‚', '\'').Replace('‛', '\''); + } +} diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionNode.cs b/TriasDev.Templify/Conditionals/Engine/ConditionNode.cs new file mode 100644 index 0000000..22e0618 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionNode.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Base type for parsed condition-expression AST nodes. +internal abstract class ConditionNode +{ +} + +/// A literal value (string, number, bool, or null). +internal sealed class LiteralNode : ConditionNode +{ + public LiteralNode(object? value) => Value = value; + + public object? Value { get; } +} + +/// A variable reference resolved through the evaluation context (supports dotted/indexed paths). +internal sealed class VariableNode : ConditionNode +{ + public VariableNode(string path) => Path = path ?? throw new ArgumentNullException(nameof(path)); + + public string Path { get; } +} + +/// A list literal, e.g. ("A", "B"). Valid as the right-hand side of in. +internal sealed class ListNode : ConditionNode +{ + public ListNode(IReadOnlyList items) => Items = items ?? throw new ArgumentNullException(nameof(items)); + + public IReadOnlyList Items { get; } +} + +/// An operator application with its operand nodes. +internal sealed class OperatorNode : ConditionNode +{ + public OperatorNode(IConditionOperator @operator, IReadOnlyList operands) + { + Operator = @operator ?? throw new ArgumentNullException(nameof(@operator)); + Operands = operands ?? throw new ArgumentNullException(nameof(operands)); + } + + public IConditionOperator Operator { get; } + + public IReadOnlyList Operands { get; } +} diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs b/TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs new file mode 100644 index 0000000..2f93e85 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine.Operators; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Central registry mapping tokens to operators. Register once; the parser reads it. +internal sealed class ConditionOperatorRegistry +{ + private readonly Dictionary _infix = new(StringComparer.Ordinal); + private readonly Dictionary _prefix = new(StringComparer.Ordinal); + private readonly List _postfix = new(); + + public static ConditionOperatorRegistry Shared { get; } = CreateDefault(); + + public IReadOnlyList PostfixOperators => _postfix; + + public IConditionOperator? FindInfix(string token) => _infix.GetValueOrDefault(token); + + public IConditionOperator? FindPrefix(string token) => _prefix.GetValueOrDefault(token); + + public void Register(IConditionOperator op) + { + switch (op.Fixity) + { + case OperatorFixity.Infix: + foreach (string token in op.Tokens) + { _infix[token] = op; } + break; + case OperatorFixity.Prefix: + foreach (string token in op.Tokens) + { _prefix[token] = op; } + break; + case OperatorFixity.Postfix: + _postfix.Add(op); + break; + } + } + + private static ConditionOperatorRegistry CreateDefault() + { + ConditionOperatorRegistry r = new(); + r.Register(new OrOperator()); + r.Register(new AndOperator()); + r.Register(new NotOperator()); + r.Register(new EqualOperator()); + r.Register(new NotEqualOperator()); + r.Register(new GreaterOperator()); + r.Register(new LessOperator()); + r.Register(new GreaterOrEqualOperator()); + r.Register(new LessOrEqualOperator()); + r.Register(new InOperator()); + r.Register(new ContainsOperator()); + r.Register(new StartsWithOperator()); + r.Register(new EndsWithOperator()); + r.Register(new ExistsOperator()); + r.Register(new IsEmptyOperator()); + r.Register(new IsNotEmptyOperator()); + return r; + } +} diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionParser.cs b/TriasDev.Templify/Conditionals/Engine/ConditionParser.cs new file mode 100644 index 0000000..c15096c --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionParser.cs @@ -0,0 +1,164 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Thrown when a condition expression cannot be parsed. +internal sealed class ConditionParseException : Exception +{ + public ConditionParseException(string message) : base(message) { } +} + +/// Precedence-climbing (Pratt) parser turning tokens into a AST. +internal sealed class ConditionParser +{ + private readonly ConditionOperatorRegistry _registry; + private IReadOnlyList _tokens = Array.Empty(); + private int _pos; + + public ConditionParser(ConditionOperatorRegistry registry) + => _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + + public ConditionNode Parse(IReadOnlyList tokens) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _pos = 0; + ConditionNode node = ParseExpression(0); + if (Current.Type != ConditionTokenType.End) + { + throw new ConditionParseException($"Unexpected token '{Current.Text}'."); + } + return node; + } + + private ConditionToken Current => _tokens[_pos]; + + private ConditionToken Advance() => _tokens[_pos++]; + + private ConditionNode ParseExpression(int minPrecedence) + { + ConditionNode left = ParsePrefix(); + + while (true) + { + ConditionNode? postfixed = TryApplyPostfix(left); + if (postfixed != null) + { left = postfixed; continue; } + + if (Current.Type != ConditionTokenType.Operator) + { break; } + IConditionOperator? infix = _registry.FindInfix(Current.Text); + if (infix == null || infix.Precedence < minPrecedence) + { break; } + + Advance(); + ConditionNode right = ParseExpression(infix.Precedence + 1); + left = new OperatorNode(infix, new[] { left, right }); + } + + return left; + } + + private ConditionNode ParsePrefix() + { + if (Current.Type == ConditionTokenType.Operator) + { + IConditionOperator? prefix = _registry.FindPrefix(Current.Text); + if (prefix != null) + { + Advance(); + ConditionNode operand = ParseExpression(prefix.Precedence); + return new OperatorNode(prefix, new[] { operand }); + } + } + + return ParsePrimary(); + } + + private ConditionNode ParsePrimary() + { + ConditionToken token = Current; + switch (token.Type) + { + case ConditionTokenType.LParen: + return ParseParenthesized(); + case ConditionTokenType.String: + Advance(); + return new LiteralNode(token.Text); + case ConditionTokenType.Number: + case ConditionTokenType.Boolean: + case ConditionTokenType.Null: + Advance(); + return new LiteralNode(token.LiteralValue); + case ConditionTokenType.Identifier: + Advance(); + return new VariableNode(token.Text); + default: + throw new ConditionParseException($"Expected an operand but found '{token.Text}'."); + } + } + + private ConditionNode ParseParenthesized() + { + Advance(); // consume '(' + ConditionNode first = ParseExpression(0); + + if (Current.Type == ConditionTokenType.Comma) + { + List items = new() { first }; + while (Current.Type == ConditionTokenType.Comma) + { + Advance(); + items.Add(ParseExpression(0)); + } + Expect(ConditionTokenType.RParen); + return new ListNode(items); + } + + Expect(ConditionTokenType.RParen); + return first; + } + + private ConditionNode? TryApplyPostfix(ConditionNode left) + { + foreach (IConditionOperator op in OrderedByTokenCountDescending()) + { + if (MatchesSequence(op.Tokens)) + { + _pos += op.Tokens.Count; + return new OperatorNode(op, new[] { left }); + } + } + return null; + } + + private IEnumerable OrderedByTokenCountDescending() + { + // Longest sequence first so "is not empty" wins over any "is ..." prefix. + List ordered = new(_registry.PostfixOperators); + ordered.Sort((a, b) => b.Tokens.Count.CompareTo(a.Tokens.Count)); + return ordered; + } + + private bool MatchesSequence(IReadOnlyList sequence) + { + for (int i = 0; i < sequence.Count; i++) + { + ConditionToken t = _tokens[Math.Min(_pos + i, _tokens.Count - 1)]; + if (t.Type != ConditionTokenType.Operator || !string.Equals(t.Text, sequence[i], StringComparison.Ordinal)) + { + return false; + } + } + return true; + } + + private void Expect(ConditionTokenType type) + { + if (Current.Type != type) + { + throw new ConditionParseException($"Expected {type} but found '{Current.Text}'."); + } + Advance(); + } +} diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionToken.cs b/TriasDev.Templify/Conditionals/Engine/ConditionToken.cs new file mode 100644 index 0000000..4e034e3 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionToken.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// The kind of a lexed condition token. +internal enum ConditionTokenType +{ + Identifier, + String, + Number, + Boolean, + Null, + Operator, + LParen, + RParen, + Comma, + End +} + +/// A single lexed token. +internal sealed class ConditionToken +{ + public ConditionToken(ConditionTokenType type, string text, object? literalValue = null) + { + Type = type; + Text = text; + LiteralValue = literalValue; + } + + public ConditionTokenType Type { get; } + + public string Text { get; } + + public object? LiteralValue { get; } +} diff --git a/TriasDev.Templify/Conditionals/Engine/ConditionValueOps.cs b/TriasDev.Templify/Conditionals/Engine/ConditionValueOps.cs new file mode 100644 index 0000000..06beff0 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/ConditionValueOps.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// +/// Dialect-independent value helpers (equality, string coercion) used directly by the +/// in operator and the string operators, and by 's +/// AreEqual implementation. This class does NOT govern =/!= in the Inline +/// dialect, which compares operands via object.Equals instead. +/// +internal static class ConditionValueOps +{ + /// Compares two values for equality (bool-aware, case-insensitive booleans, ordinal otherwise). + public static bool AreEqual(object? left, object? right) + { + if (left == null && right == null) + { + return true; + } + + if (left == null || right == null) + { + return false; + } + + if (left is bool || right is bool || IsBooleanLiteral(left) || IsBooleanLiteral(right)) + { + return string.Equals(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase); + } + + return left.ToString() == right.ToString(); + } + + /// Coerces a value to its ordinal string form (empty string for null). + public static string ToStr(object? value) => value?.ToString() ?? string.Empty; + + private static bool IsBooleanLiteral(object? value) + { + string? str = value as string; + return str != null && (str.Equals("true", StringComparison.OrdinalIgnoreCase) + || str.Equals("false", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/TriasDev.Templify/Conditionals/Engine/IConditionOperator.cs b/TriasDev.Templify/Conditionals/Engine/IConditionOperator.cs new file mode 100644 index 0000000..5d7a4bd --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/IConditionOperator.cs @@ -0,0 +1,23 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// +/// A pluggable condition operator. Adding a new operator means implementing this and +/// registering it in ; the parser is not modified. +/// +internal interface IConditionOperator +{ + /// Token sequence that denotes this operator (e.g. ["="], ["is","empty"]). + IReadOnlyList Tokens { get; } + + /// Binding precedence (higher binds tighter). + int Precedence { get; } + + /// Operator position relative to its operands. + OperatorFixity Fixity { get; } + + /// Evaluates the operator against its operand nodes. + bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands); +} diff --git a/TriasDev.Templify/Conditionals/Engine/OperatorFixity.cs b/TriasDev.Templify/Conditionals/Engine/OperatorFixity.cs new file mode 100644 index 0000000..139c137 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/OperatorFixity.cs @@ -0,0 +1,12 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Where an operator sits relative to its operands. +internal enum OperatorFixity +{ + Prefix, + Infix, + Postfix +} diff --git a/TriasDev.Templify/Conditionals/Engine/Operators/ComparisonOperators.cs b/TriasDev.Templify/Conditionals/Engine/Operators/ComparisonOperators.cs new file mode 100644 index 0000000..5a90487 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/Operators/ComparisonOperators.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +internal abstract class ComparisonOperatorBase : IConditionOperator +{ + public abstract IReadOnlyList Tokens { get; } + public int Precedence => 4; + public OperatorFixity Fixity => OperatorFixity.Infix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => Compare(core, core.EvaluateValue(operands[0]), core.EvaluateValue(operands[1])); + protected abstract bool Compare(ConditionEvaluatorCore core, object? left, object? right); +} + +internal sealed class EqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "=", "==" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) => core.Dialect.AreEqual(l, r); +} + +internal sealed class NotEqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "!=" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) => !core.Dialect.AreEqual(l, r); +} + +internal sealed class GreaterOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { ">" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c > 0; +} + +internal sealed class LessOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "<" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c < 0; +} + +internal sealed class GreaterOrEqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { ">=" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c >= 0; +} + +internal sealed class LessOrEqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "<=" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c <= 0; +} diff --git a/TriasDev.Templify/Conditionals/Engine/Operators/ExistenceOperators.cs b/TriasDev.Templify/Conditionals/Engine/Operators/ExistenceOperators.cs new file mode 100644 index 0000000..8728d0b --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/Operators/ExistenceOperators.cs @@ -0,0 +1,62 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +/// Postfix Foo exists: variable is present in the context, regardless of value. +internal sealed class ExistsOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "exists" }; + public int Precedence => 5; + public OperatorFixity Fixity => OperatorFixity.Postfix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + { + if (operands[0] is VariableNode variable) + { + return core.TryResolveVariable(variable.Path, out _); + } + return core.EvaluateValue(operands[0]) != null; + } +} + +/// Postfix Foo is empty: null / empty-or-whitespace string / empty collection (missing = empty). +internal sealed class IsEmptyOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "is", "empty" }; + public int Precedence => 5; + public OperatorFixity Fixity => OperatorFixity.Postfix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => IsEmptyValue(core.ResolveValueStrict(operands[0])); + + internal static bool IsEmptyValue(object? value) + { + if (value == null) + { + return true; + } + if (value is string s) + { + return string.IsNullOrWhiteSpace(s); + } + if (value is ICollection c) + { + return c.Count == 0; + } + return false; + } +} + +/// Postfix Foo is not empty: negation of is empty. +internal sealed class IsNotEmptyOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "is", "not", "empty" }; + public int Precedence => 5; + public OperatorFixity Fixity => OperatorFixity.Postfix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => !IsEmptyOperator.IsEmptyValue(core.ResolveValueStrict(operands[0])); +} diff --git a/TriasDev.Templify/Conditionals/Engine/Operators/LogicalOperators.cs b/TriasDev.Templify/Conditionals/Engine/Operators/LogicalOperators.cs new file mode 100644 index 0000000..9ca199d --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/Operators/LogicalOperators.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +internal sealed class OrOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "or" }; + public int Precedence => 1; + public OperatorFixity Fixity => OperatorFixity.Infix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => core.EvaluateBool(operands[0]) || core.EvaluateBool(operands[1]); +} + +internal sealed class AndOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "and" }; + public int Precedence => 2; + public OperatorFixity Fixity => OperatorFixity.Infix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => core.EvaluateBool(operands[0]) && core.EvaluateBool(operands[1]); +} + +internal sealed class NotOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "not" }; + public int Precedence => 3; + public OperatorFixity Fixity => OperatorFixity.Prefix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => !core.EvaluateBool(operands[0]); +} diff --git a/TriasDev.Templify/Conditionals/Engine/Operators/MembershipOperator.cs b/TriasDev.Templify/Conditionals/Engine/Operators/MembershipOperator.cs new file mode 100644 index 0000000..5998f4c --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/Operators/MembershipOperator.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +/// Collection membership: scalar in source. Source may be a collection variable, +/// a list literal ("A","B"), or a comma-separated string "A,B". +internal sealed class InOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "in" }; + public int Precedence => 4; + public OperatorFixity Fixity => OperatorFixity.Infix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + { + object? left = core.ResolveValueStrict(operands[0]); + object? right = core.ResolveValueStrict(operands[1]); + + foreach (object? candidate in EnumerateCandidates(right)) + { + if (ConditionValueOps.AreEqual(left, candidate)) + { + return true; + } + } + return false; + } + + private static IEnumerable EnumerateCandidates(object? right) + { + if (right is null) + { + // Missing collection/scalar never matches (strict resolution: absent, not empty-equal-to-null). + yield break; + } + + if (right is string s) + { + foreach (string part in s.Split(',')) + { + yield return part.Trim(); + } + yield break; + } + + if (right is IEnumerable enumerable) + { + foreach (object? item in enumerable) + { + yield return item; + } + yield break; + } + + yield return right; + } +} diff --git a/TriasDev.Templify/Conditionals/Engine/Operators/StringOperators.cs b/TriasDev.Templify/Conditionals/Engine/Operators/StringOperators.cs new file mode 100644 index 0000000..bc684e1 --- /dev/null +++ b/TriasDev.Templify/Conditionals/Engine/Operators/StringOperators.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +internal abstract class StringOperatorBase : IConditionOperator +{ + public abstract IReadOnlyList Tokens { get; } + public int Precedence => 4; + public OperatorFixity Fixity => OperatorFixity.Infix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + { + object? leftValue = core.ResolveValueStrict(operands[0]); + object? rightValue = core.ResolveValueStrict(operands[1]); + if (leftValue is null || rightValue is null) + { + // Missing operand: short-circuit to false rather than coercing to an empty string. + return false; + } + + return Test(ConditionValueOps.ToStr(leftValue), ConditionValueOps.ToStr(rightValue)); + } + + protected abstract bool Test(string left, string right); +} + +internal sealed class ContainsOperator : StringOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "contains" }; + protected override bool Test(string left, string right) => left.Contains(right, StringComparison.Ordinal); +} + +internal sealed class StartsWithOperator : StringOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "startswith" }; + protected override bool Test(string left, string right) => left.StartsWith(right, StringComparison.Ordinal); +} + +internal sealed class EndsWithOperator : StringOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "endswith" }; + protected override bool Test(string left, string right) => left.EndsWith(right, StringComparison.Ordinal); +} diff --git a/TriasDev.Templify/Conditionals/IConditionEvaluator.cs b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs index 988900f..c53f5ad 100644 --- a/TriasDev.Templify/Conditionals/IConditionEvaluator.cs +++ b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs @@ -14,7 +14,8 @@ namespace TriasDev.Templify.Conditionals; /// for use in standalone scenarios without Word document processing. /// /// -/// Supported operators: =, ==, !=, >, <, >=, <=, and, or, not +/// Supported operators: =, ==, !=, >, <, >=, <=, and, or, not, +/// in, contains, startswith, endswith, exists, is empty, is not empty, and parentheses for grouping. /// /// /// Examples: diff --git a/TriasDev.Templify/Expressions/BooleanExpression.cs b/TriasDev.Templify/Expressions/BooleanExpression.cs deleted file mode 100644 index 91d7d8f..0000000 --- a/TriasDev.Templify/Expressions/BooleanExpression.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -namespace TriasDev.Templify.Expressions; - -/// -/// Represents a boolean expression that can be evaluated against a data context. -/// -internal abstract class BooleanExpression -{ - /// - /// Evaluates the expression against the provided data context. - /// - /// The data context for variable resolution. - /// The boolean result of the expression. - public abstract bool Evaluate(IDataContext context); -} - -/// -/// Represents a simple variable reference expression. -/// -internal sealed class VariableExpression : BooleanExpression -{ - public string VariableName { get; } - - public VariableExpression(string variableName) - { - VariableName = variableName ?? throw new ArgumentNullException(nameof(variableName)); - } - - public override bool Evaluate(IDataContext context) - { - object? value = context.GetValue(VariableName); - return value is bool boolValue && boolValue; - } -} - -/// -/// Represents a logical AND expression. -/// -internal sealed class AndExpression : BooleanExpression -{ - public BooleanExpression Left { get; } - public BooleanExpression Right { get; } - - public AndExpression(BooleanExpression left, BooleanExpression right) - { - Left = left ?? throw new ArgumentNullException(nameof(left)); - Right = right ?? throw new ArgumentNullException(nameof(right)); - } - - public override bool Evaluate(IDataContext context) - { - return Left.Evaluate(context) && Right.Evaluate(context); - } -} - -/// -/// Represents a logical OR expression. -/// -internal sealed class OrExpression : BooleanExpression -{ - public BooleanExpression Left { get; } - public BooleanExpression Right { get; } - - public OrExpression(BooleanExpression left, BooleanExpression right) - { - Left = left ?? throw new ArgumentNullException(nameof(left)); - Right = right ?? throw new ArgumentNullException(nameof(right)); - } - - public override bool Evaluate(IDataContext context) - { - return Left.Evaluate(context) || Right.Evaluate(context); - } -} - -/// -/// Represents a logical NOT expression. -/// -internal sealed class NotExpression : BooleanExpression -{ - public BooleanExpression Operand { get; } - - public NotExpression(BooleanExpression operand) - { - Operand = operand ?? throw new ArgumentNullException(nameof(operand)); - } - - public override bool Evaluate(IDataContext context) - { - return !Operand.Evaluate(context); - } -} - -/// -/// Represents a comparison expression. -/// -internal sealed class ComparisonExpression : BooleanExpression -{ - public string VariableName { get; } - public ComparisonOperator Operator { get; } - public object? Value { get; } - - public ComparisonExpression(string variableName, ComparisonOperator op, object? value) - { - VariableName = variableName ?? throw new ArgumentNullException(nameof(variableName)); - Operator = op; - Value = value; - } - - public override bool Evaluate(IDataContext context) - { - object? leftValue = context.GetValue(VariableName); - - return Operator switch - { - ComparisonOperator.Equal => Equals(leftValue, Value), - ComparisonOperator.NotEqual => !Equals(leftValue, Value), - ComparisonOperator.GreaterThan => Compare(leftValue, Value) > 0, - ComparisonOperator.GreaterThanOrEqual => Compare(leftValue, Value) >= 0, - ComparisonOperator.LessThan => Compare(leftValue, Value) < 0, - ComparisonOperator.LessThanOrEqual => Compare(leftValue, Value) <= 0, - _ => false - }; - } - - private static int Compare(object? left, object? right) - { - if (left == null && right == null) - { - return 0; - } - - if (left == null) - { - return -1; - } - - if (right == null) - { - return 1; - } - - if (left is IComparable leftComparable && right is IComparable) - { - try - { - return leftComparable.CompareTo(right); - } - catch (ArgumentException) - { - return 0; - } - catch (InvalidCastException) - { - return 0; - } - } - - return 0; - } -} - -/// -/// Comparison operators for expressions. -/// -internal enum ComparisonOperator -{ - Equal, - NotEqual, - GreaterThan, - GreaterThanOrEqual, - LessThan, - LessThanOrEqual -} - -/// -/// Interface for data context used in expression evaluation. -/// -internal interface IDataContext -{ - object? GetValue(string variableName); -} diff --git a/TriasDev.Templify/Expressions/BooleanExpressionParser.cs b/TriasDev.Templify/Expressions/BooleanExpressionParser.cs deleted file mode 100644 index 065fd3c..0000000 --- a/TriasDev.Templify/Expressions/BooleanExpressionParser.cs +++ /dev/null @@ -1,395 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -using System.Text; - -namespace TriasDev.Templify.Expressions; - -/// -/// Parses boolean expressions from text. -/// Supports: and, or, not, =, ==, !=, >, >=, <, <=, parentheses -/// Examples: -/// - (var1 and var2) -/// - (var1 or var2) -/// - (not IsActive) -/// - (Count > 0) -/// - ((var1 or var2) and var3) -/// -internal sealed class BooleanExpressionParser -{ - private string _text = string.Empty; - private int _position; - - /// - /// Parses a boolean expression from text. - /// - /// The expression text (without surrounding {{ }} and format specifier). - /// The parsed expression, or null if parsing fails. - public BooleanExpression? Parse(string text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - _text = text.Trim(); - _position = 0; - - // Check if it starts with '(' - if not, it's a simple variable - if (!_text.StartsWith("(")) - { - return null; // Not an expression, just a variable - } - - try - { - return ParseOrExpression(); - } - catch (FormatException) - { - return null; - } - catch (ArgumentException) - { - return null; - } - catch (InvalidOperationException) - { - return null; - } - } - - private BooleanExpression? ParseOrExpression() - { - BooleanExpression? left = ParseAndExpression(); - if (left == null) - { - return null; - } - - while (ConsumeKeyword("or")) - { - BooleanExpression? right = ParseAndExpression(); - if (right == null) - { - return null; - } - - left = new OrExpression(left, right); - } - - return left; - } - - private BooleanExpression? ParseAndExpression() - { - BooleanExpression? left = ParseUnaryExpression(); - if (left == null) - { - return null; - } - - while (ConsumeKeyword("and")) - { - BooleanExpression? right = ParseUnaryExpression(); - if (right == null) - { - return null; - } - - left = new AndExpression(left, right); - } - - return left; - } - - private BooleanExpression? ParseUnaryExpression() - { - SkipWhitespace(); - - // Check for 'not' - if (ConsumeKeyword("not")) - { - BooleanExpression? operand = ParsePrimaryExpression(); - return operand == null ? null : new NotExpression(operand); - } - - return ParsePrimaryExpression(); - } - - private BooleanExpression? ParsePrimaryExpression() - { - SkipWhitespace(); - - // Check for parenthesized expression - if (Consume('(')) - { - BooleanExpression? expr = ParseOrExpression(); - if (expr == null || !Consume(')')) - { - return null; - } - return expr; - } - - // Try to parse comparison - string? identifier = ParseIdentifier(); - if (identifier == null) - { - return null; - } - - SkipWhitespace(); - - // Check for comparison operators - if (TryParseComparisonOperator(out ComparisonOperator op)) - { - SkipWhitespace(); - object? value = ParseValue(); - return new ComparisonExpression(identifier, op, value); - } - - // Simple variable reference - return new VariableExpression(identifier); - } - - private bool TryParseComparisonOperator(out ComparisonOperator op) - { - op = ComparisonOperator.Equal; - - if (Consume("==")) - { - op = ComparisonOperator.Equal; - return true; - } - if (Consume("!=")) - { - op = ComparisonOperator.NotEqual; - return true; - } - // Single = (must be after == and != checks to avoid consuming first = of ==) - if (Consume('=')) - { - op = ComparisonOperator.Equal; - return true; - } - if (Consume(">=")) - { - op = ComparisonOperator.GreaterThanOrEqual; - return true; - } - if (Consume("<=")) - { - op = ComparisonOperator.LessThanOrEqual; - return true; - } - if (Consume('>')) - { - op = ComparisonOperator.GreaterThan; - return true; - } - if (Consume('<')) - { - op = ComparisonOperator.LessThan; - return true; - } - - return false; - } - - private object? ParseValue() - { - SkipWhitespace(); - - // Try to parse number - if (char.IsDigit(Peek()) || Peek() == '-') - { - return ParseNumber(); - } - - // Try to parse string literal - if (Peek() == '"' || Peek() == '\'') - { - return ParseStringLiteral(); - } - - // Try to parse boolean literal - if (ConsumeKeyword("true")) - { - return true; - } - if (ConsumeKeyword("false")) - { - return false; - } - - // Try to parse null - if (ConsumeKeyword("null")) - { - return null; - } - - // Parse as identifier (another variable reference) - return ParseIdentifier(); - } - - private object? ParseNumber() - { - StringBuilder sb = new StringBuilder(); - - if (Peek() == '-') - { - sb.Append(Consume()); - } - - while (char.IsDigit(Peek()) || Peek() == '.') - { - sb.Append(Consume()); - } - - string numberStr = sb.ToString(); - - if (numberStr.Contains('.')) - { - if (double.TryParse(numberStr, out double d)) - { - return d; - } - } - else - { - if (int.TryParse(numberStr, out int i)) - { - return i; - } - } - - return null; - } - - private string? ParseStringLiteral() - { - char quote = Consume(); - StringBuilder sb = new StringBuilder(); - - while (Peek() != quote && Peek() != '\0') - { - char c = Consume(); - if (c == '\\' && Peek() == quote) - { - sb.Append(Consume()); - } - else - { - sb.Append(c); - } - } - - if (Peek() == quote) - { - Consume(); - return sb.ToString(); - } - - return null; - } - - private string? ParseIdentifier() - { - StringBuilder sb = new StringBuilder(); - - // Identifiers can contain letters, digits, underscores, dots, and brackets - while (char.IsLetterOrDigit(Peek()) || Peek() == '_' || Peek() == '.' || Peek() == '[' || Peek() == ']') - { - sb.Append(Consume()); - } - - return sb.Length > 0 ? sb.ToString() : null; - } - - private bool ConsumeKeyword(string keyword) - { - SkipWhitespace(); - - int savedPosition = _position; - - foreach (char c in keyword) - { - if (char.ToLowerInvariant(Peek()) != char.ToLowerInvariant(c)) - { - _position = savedPosition; - return false; - } - Consume(); - } - - // Make sure the keyword is not part of a larger identifier - if (char.IsLetterOrDigit(Peek()) || Peek() == '_') - { - _position = savedPosition; - return false; - } - - return true; - } - - private bool Consume(string text) - { - SkipWhitespace(); - - int savedPosition = _position; - - foreach (char c in text) - { - if (Peek() != c) - { - _position = savedPosition; - return false; - } - Consume(); - } - - return true; - } - - private bool Consume(char expected) - { - SkipWhitespace(); - - if (Peek() == expected) - { - Consume(); - return true; - } - - return false; - } - - private char Consume() - { - if (_position >= _text.Length) - { - return '\0'; - } - - return _text[_position++]; - } - - private char Peek() - { - if (_position >= _text.Length) - { - return '\0'; - } - - return _text[_position]; - } - - private void SkipWhitespace() - { - while (_position < _text.Length && char.IsWhiteSpace(_text[_position])) - { - _position++; - } - } -} diff --git a/TriasDev.Templify/Expressions/EvaluationContextAdapter.cs b/TriasDev.Templify/Expressions/EvaluationContextAdapter.cs deleted file mode 100644 index 9ee2ec1..0000000 --- a/TriasDev.Templify/Expressions/EvaluationContextAdapter.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2025 TriasDev GmbH & Co. KG -// Licensed under the MIT License. See LICENSE file in the project root for full license information. - -using TriasDev.Templify.Core; - -namespace TriasDev.Templify.Expressions; - -/// -/// Adapts IEvaluationContext to IDataContext for expression evaluation. -/// -internal sealed class EvaluationContextAdapter : IDataContext -{ - private readonly IEvaluationContext _context; - - public EvaluationContextAdapter(IEvaluationContext context) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - } - - public object? GetValue(string variableName) - { - _context.TryResolveVariable(variableName, out object? value); - return value; - } -} diff --git a/TriasDev.Templify/Visitors/PlaceholderVisitor.cs b/TriasDev.Templify/Visitors/PlaceholderVisitor.cs index 9d1b4b1..1ff53ac 100644 --- a/TriasDev.Templify/Visitors/PlaceholderVisitor.cs +++ b/TriasDev.Templify/Visitors/PlaceholderVisitor.cs @@ -5,7 +5,6 @@ using DocumentFormat.OpenXml.Wordprocessing; using TriasDev.Templify.Conditionals; using TriasDev.Templify.Core; -using TriasDev.Templify.Expressions; using TriasDev.Templify.Loops; using TriasDev.Templify.Markdown; using TriasDev.Templify.Placeholders; @@ -71,41 +70,26 @@ public void VisitPlaceholder(PlaceholderMatch placeholder, Paragraph paragraph, // Check if this is an expression if (placeholder.IsExpression) { - // Parse and evaluate the expression - var parser = new BooleanExpressionParser(); - BooleanExpression? expression = parser.Parse(placeholder.VariableName); - - if (expression != null) + try { - try - { - var dataContext = new EvaluationContextAdapter(context); - bool result = expression.Evaluate(dataContext); - value = result; - resolved = true; - } - catch (ArgumentException ex) - { - _warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, ex.Message)); - resolved = false; - value = null; - } - catch (InvalidOperationException ex) - { - _warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, ex.Message)); - resolved = false; - value = null; - } - catch (InvalidCastException ex) - { - _warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, ex.Message)); - resolved = false; - value = null; - } + IReadOnlyList tokens = + new Conditionals.Engine.ConditionLexer(allowSingleQuotedStrings: true).Tokenize(placeholder.VariableName); + Conditionals.Engine.ConditionNode node = + new Conditionals.Engine.ConditionParser(Conditionals.Engine.ConditionOperatorRegistry.Shared).Parse(tokens); + bool result = new Conditionals.Engine.ConditionEvaluatorCore(context, Conditionals.Engine.InlineConditionDialect.Instance) + .EvaluateBool(node); + value = result; + resolved = true; } - else + catch (Conditionals.Engine.ConditionParseException ex) + { + _warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, ex.Message)); + resolved = false; + value = null; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or InvalidCastException) { - _warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, "Failed to parse expression")); + _warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, ex.Message)); resolved = false; value = null; } diff --git a/docs/FAQ.md b/docs/FAQ.md index 815f094..df33bac 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -82,6 +82,8 @@ Or in Visual Studio: `Install-Package TriasDev.Templify` - ✅ **Formatting preservation**: Bold, italic, colors, fonts maintained - ✅ **Comparison operators**: `>`, `<`, `>=`, `<=`, `==`, `!=` - ✅ **Logical operators**: `and`, `or`, `not` +- ✅ **Membership, string, and existence operators**: `in`, `contains`, `startswith`, `endswith`, `exists`, `is empty`, `is not empty` +- ✅ **Grouping**: parentheses `()` for controlling evaluation order - ✅ **Localization**: Format specifiers adapt to cultures (en, de, fr, es, it, pt) - ✅ **JSON support**: Use JSON data instead of C# dictionaries - ✅ **Type coercion**: Automatic number/date conversions @@ -400,6 +402,9 @@ Can proceed: ✓ **Supported operators**: - Logical: `and`, `or`, `not` - Comparison: `=`, `==`, `!=`, `>`, `>=`, `<`, `<=` +- Membership: `in` (collection variable, list literal, or comma-separated string) +- String checks: `contains`, `startswith`, `endswith` (case-sensitive) +- Existence: `exists`, `is empty`, `is not empty` - Nested: `((var1 or var2) and var3)` See the [Boolean Expressions Guide](for-template-authors/boolean-expressions.md) for complete documentation. diff --git a/docs/for-developers/condition-evaluation.md b/docs/for-developers/condition-evaluation.md index e70b04e..a868885 100644 --- a/docs/for-developers/condition-evaluation.md +++ b/docs/for-developers/condition-evaluation.md @@ -117,6 +117,100 @@ Use `IConditionContext` when: Logical operators `and`, `or`, and `not` are case-insensitive. `AND`, `And`, `and` all work identically. +## Operators Reference + +Complete list of all supported operators, one example each. + +| Operator | Description | Example | +|----------|-------------|---------| +| `=` / `==` | Equals | `Status = "Active"` | +| `!=` | Not equals | `Status != "Deleted"` | +| `>` | Greater than | `Count > 0` | +| `<` | Less than | `Price < 100` | +| `>=` | Greater or equal | `Age >= 18` | +| `<=` | Less or equal | `Score <= 100` | +| `and` | Logical AND | `IsActive and HasAccess` | +| `or` | Logical OR | `IsAdmin or IsModerator` | +| `not` | Logical NOT | `not IsDeleted` | +| `in` | Membership check | `Role in Roles` | +| `contains` | Substring check | `Description contains "urgent"` | +| `startswith` | Prefix check | `Code startswith "US-"` | +| `endswith` | Suffix check | `FileName endswith ".pdf"` | +| `exists` | Variable is present | `Notes exists` | +| `is empty` | Variable is null/empty | `Notes is empty` | +| `is not empty` | Variable has a value | `Notes is not empty` | +| `(...)` | Grouping | `(A or B) and C` | + +### The `in` Operator + +`in` checks whether a value is a member of a collection. The right-hand side can take three forms: + +```csharp +// Collection variable +evaluator.Evaluate("Role in Roles", data); + +// List literal +evaluator.Evaluate("Status in (\"Active\", \"Pending\")", data); + +// Comma-separated string +evaluator.Evaluate("Status in \"Active,Pending\"", data); +``` + +To negate membership, use `not` as a prefix: `not Role in Roles`. + +### String Operators: `contains`, `startswith`, `endswith` + +```csharp +evaluator.Evaluate("Description contains \"urgent\"", data); +evaluator.Evaluate("Code startswith \"US-\"", data); +evaluator.Evaluate("FileName endswith \".pdf\"", data); +``` + +### Existence and Emptiness: `exists`, `is empty`, `is not empty` + +These are postfix operators — the keyword follows the variable. + +```csharp +evaluator.Evaluate("Notes exists", data); // true if the key is present, even if its value is null +evaluator.Evaluate("Notes is empty", data); // true if missing, null, whitespace/empty string, or an empty collection +evaluator.Evaluate("Notes is not empty", data); // opposite of "is empty" +``` + +A missing variable counts as empty. A variable that is present but `null` satisfies both `exists` and `is empty` at the same time. + +### Grouping with Parentheses + +Parentheses override default precedence: + +```csharp +evaluator.Evaluate("(IsActive or IsTrial) and not IsBanned", data); +``` + +!!! note "Case Sensitivity" + String operators (`contains`, `startswith`, `endswith`) and `in` element equality compare values with ordinal, **case-sensitive** semantics — the same rule used by `=`. The operator keywords themselves (`in`, `contains`, `exists`, etc.) are case-insensitive, like `and`/`or`/`not`. + +!!! note "Operator Precedence" + `and` binds tighter than `or` (`A or B and C` is `A or (B and C)`). Use parentheses to override the default precedence. + +## Reserved Words and Literal Quoting + +The operator keywords are **reserved words**. The following names are always parsed as operators, never as variable names or bareword text: + +``` +and, or, not, in, contains, startswith, endswith, exists, is, empty +``` + +Because these words are reserved, **string literals must be quoted**. Use `= "empty"` rather than `= empty`: + +```csharp +evaluator.Evaluate("Category = \"empty\"", data); // compares against the text "empty" -> true when Category is "empty" +evaluator.Evaluate("Category = empty", data); // "empty" is the reserved keyword, not a literal -> does not match +``` + +An unquoted reserved word on the right-hand side of a comparison is not a valid operand, so the expression fails to parse and `Evaluate` returns `false`. Always quote literals that could collide with a reserved word. + +In inline `{{(...)}}` expressions, **both sides of a comparison are resolved as variables-or-literals**: in `{{(A = B)}}`, both `A` and `B` are looked up in the data and the comparison succeeds when the resolved values are equal. Quote a side (`{{(A = "B")}}`) when you mean the literal text instead of a variable lookup. + ## Expression Syntax ### Simple Variables diff --git a/docs/for-template-authors/boolean-expressions.md b/docs/for-template-authors/boolean-expressions.md index 5943616..c02d64e 100644 --- a/docs/for-template-authors/boolean-expressions.md +++ b/docs/for-template-authors/boolean-expressions.md @@ -8,6 +8,8 @@ Boolean expressions allow you to evaluate complex logical conditions directly wi - [Expression Syntax](#expression-syntax) - [Logical Operators](#logical-operators) - [Comparison Operators](#comparison-operators) +- [Membership, String, and Existence Operators](#membership-string-and-existence-operators) +- [Reserved Words and Literal Quoting](#reserved-words-and-literal-quoting) - [Combining with Format Specifiers](#combining-with-format-specifiers) - [Advanced Usage](#advanced-usage) - [Best Practices](#best-practices) @@ -339,6 +341,96 @@ At or under limit: {{(Count <= MaxCount)}} At or under limit: True ``` +## Membership, String, and Existence Operators + +### Membership (in) + +Checks whether a value is a member of a collection. The right side can be a collection variable, a quoted list literal, or a comma-separated string. + +**Template:** +``` +Recognized role: {{(Role in Roles)}} +Can publish: {{(Role in ("Admin", "Editor")):yesno}} +``` + +**Data:** +```json +{ + "Role": "Editor", + "Roles": ["Admin", "Editor", "Viewer"] +} +``` + +**Output:** +``` +Recognized role: True +Can publish: Yes +``` + +Negate with `not`: `{{(not Role in Roles)}}`. + +### String Checks (contains, startswith, endswith) + +**Template:** +``` +Priority: {{(Description contains "urgent"):yesno}} +``` + +**Data:** +```json +{ + "Description": "This order is urgent" +} +``` + +**Output:** +``` +Priority: Yes +``` + +These comparisons are **case-sensitive**, like `=` and `==`. + +### Existence (exists, is empty, is not empty) + +`exists` checks that a variable is present regardless of its value; `is empty` / `is not empty` check whether a value is missing, null, blank, or an empty collection. + +**Template:** +``` +Notes provided: {{(Notes exists):yesno}} +Notes empty: {{(Notes is empty):yesno}} +``` + +**Data:** +```json +{ + "Notes": "" +} +``` + +**Output:** +``` +Notes provided: Yes +Notes empty: Yes +``` + +## Reserved Words and Literal Quoting + +The operator keywords are **reserved words**. The following names are always interpreted as operators, never as variable names or bareword text: + +``` +and, or, not, in, contains, startswith, endswith, exists, is, empty +``` + +Because of this, **string literals must be quoted**. Write `= "empty"`, not `= empty`: + +``` +Status is missing: {{(Category = "empty"):yesno}} ← compares against the text "empty" +``` + +If you leave a reserved word unquoted (for example `{{(Category = empty)}}`), Templify reads `empty` as the reserved keyword rather than as the literal text `empty`, and the expression will not evaluate as you intended. Always quote string literals that could collide with a reserved word. + +In inline `{{(...)}}` expressions, **both sides of a comparison are resolved as variables-or-literals**. In `{{(A = B)}}`, both `A` and `B` are looked up in your data; the comparison succeeds when the two resolved values are equal. Quote a side (`{{(A = "B")}}`) when you mean the literal text rather than a variable lookup. + ## Combining with Format Specifiers The real power comes from combining expressions with format specifiers for human-readable output. @@ -895,10 +987,14 @@ Expressions are evaluated with standard operator precedence: 1. **Parentheses** `()` - Highest priority 2. **NOT** `not` -3. **Comparison** `>`, `>=`, `<`, `<=`, `=`, `==`, `!=` +3. **Comparison** `>`, `>=`, `<`, `<=`, `=`, `==`, `!=`, `in`, `contains`, `startswith`, `endswith` 4. **AND** `and` 5. **OR** `or` - Lowest priority +`and` always binds tighter than `or`. Use parentheses to override the default grouping. + +`exists`, `is empty`, and `is not empty` are postfix operators — they attach directly to the variable that precedes them (e.g. `Notes is empty`), so they behave like a comparison result and combine with `and`/`or` the same way. + ### Examples **Expression:** `var1 or var2 and var3` @@ -916,6 +1012,7 @@ Boolean expressions enable powerful inline logic in your templates: - ✅ Logical operators: `and`, `or`, `not` - ✅ Comparison operators: `=`, `==`, `!=`, `>`, `>=`, `<`, `<=` +- ✅ Membership, string, and existence operators: `in`, `contains`, `startswith`, `endswith`, `exists`, `is empty`, `is not empty` - ✅ Nested expressions with parentheses - ✅ Combine with format specifiers for readable output - ✅ Works with nested properties and arrays diff --git a/docs/for-template-authors/conditionals.md b/docs/for-template-authors/conditionals.md index 6ac1e79..ee14a19 100644 --- a/docs/for-template-authors/conditionals.md +++ b/docs/for-template-authors/conditionals.md @@ -380,6 +380,111 @@ You are eligible to travel internationally. 4. `and` 5. `or` +## Membership, String, and Existence Checks + +### Membership (`in`) + +Check whether a value appears in a collection. The right side can be a collection variable, a quoted list literal, or a comma-separated string: + +**JSON:** +```json +{ + "Role": "Editor", + "Roles": ["Admin", "Editor", "Viewer"] +} +``` + +**Template:** +``` +{{#if Role in Roles}} +You have access to the content management system. +{{/if}} + +{{#if Role in ("Admin", "Editor")}} +You can publish articles. +{{/if}} + +{{#if Role in "Admin,Editor,Viewer"}} +You are a recognized role. +{{/if}} +``` + +Negate membership with `not`: + +``` +{{#if not Role in Roles}} +Your role is not recognized. +{{/if}} +``` + +### String Checks (`contains`, `startswith`, `endswith`) + +**JSON:** +```json +{ + "Description": "This order is urgent", + "OrderCode": "US-4821", + "FileName": "invoice.pdf" +} +``` + +**Template:** +``` +{{#if Description contains "urgent"}} +⚠️ Priority handling required. +{{/if}} + +{{#if OrderCode startswith "US-"}} +Domestic order. +{{/if}} + +{{#if FileName endswith ".pdf"}} +PDF attachment included. +{{/if}} +``` + +**Note:** Like `=`, these string comparisons are **case-sensitive**. + +### Existence (`exists`, `is empty`, `is not empty`) + +`exists` checks that a variable is present, regardless of its value. `is empty` / `is not empty` check whether a value is missing, null, blank, or an empty collection. + +**JSON:** +```json +{ + "Notes": "" +} +``` + +**Template:** +``` +{{#if Notes exists}} +The Notes field was provided. +{{/if}} + +{{#if Notes is empty}} +No notes were added. +{{/if}} + +{{#if Notes is not empty}} +Notes: {{Notes}} +{{/if}} +``` + +A missing variable is treated as empty. A variable that is present but explicitly `null` satisfies both `exists` and `is empty`. + +### Grouping with Parentheses + +Use parentheses to control evaluation order, the same as in [boolean expressions](boolean-expressions.md): + +``` +{{#if (Role in ("Admin", "Editor")) and not IsSuspended}} +You can manage content. +{{/if}} +``` + +**Reminder on precedence:** `and` binds tighter than `or` (`A or B and C` means `A or (B and C)`). Use parentheses whenever you want a different grouping. + ## Common Patterns ### Boolean Flags diff --git a/docs/for-template-authors/template-syntax.md b/docs/for-template-authors/template-syntax.md index ba68039..0128362 100644 --- a/docs/for-template-authors/template-syntax.md +++ b/docs/for-template-authors/template-syntax.md @@ -379,14 +379,30 @@ The row will be repeated for each item. | `or` | At least one true | `{{#if IsVIP or IsPremium}}` | | `not` | Negates condition | `{{#if not IsExpired}}` | +### Membership, String, and Existence Operators + +| Operator | Meaning | Example | +|----------|---------|---------| +| `in` | Value is in a collection, list literal, or comma-separated string | `{{#if Role in Roles}}`, `{{#if Role in ("Admin", "Editor")}}` | +| `contains` | Substring check (case-sensitive) | `{{#if Description contains "urgent"}}` | +| `startswith` | Prefix check (case-sensitive) | `{{#if Code startswith "US-"}}` | +| `endswith` | Suffix check (case-sensitive) | `{{#if FileName endswith ".pdf"}}` | +| `exists` | Variable is present, even if null | `{{#if Notes exists}}` | +| `is empty` | Missing, null, blank, or an empty collection | `{{#if Notes is empty}}` | +| `is not empty` | Has a value | `{{#if Notes is not empty}}` | + +Negate `in` with `not`: `{{#if not Role in Roles}}`. + ### Operator Precedence 1. Parentheses `()` 2. `not` -3. Comparison operators (`=`, `!=`, `>`, etc.) +3. Comparison operators (`=`, `!=`, `>`, etc.), `in`, `contains`, `startswith`, `endswith` 4. `and` 5. `or` +`and` binds tighter than `or`; use parentheses to override. + **Example:** ``` {{#if (Age > 18 or HasParent) and not IsBanned}} diff --git a/docs/superpowers/plans/2026-07-20-extensible-condition-operators.md b/docs/superpowers/plans/2026-07-20-extensible-condition-operators.md new file mode 100644 index 0000000..dcb2ca2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-extensible-condition-operators.md @@ -0,0 +1,2131 @@ +# Extensible Condition Engine + New Operators — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the two separate condition engines with one extensible AST-based engine (lexer → parser → operator registry → evaluator), add operators `in` (+ negation), `contains`/`startswith`/`endswith`, `exists`/`is empty`/`is not empty`, and parenthesized grouping — while every existing template, condition, and test keeps working unchanged. + +**Architecture:** A shared pipeline parses an expression string into a `ConditionNode` AST using an operator registry (uniform `and > or` precedence, grouping via parentheses). A `ConditionDialect` supplies the two externally-observable semantic policies that differ between callers: **Default** (used by `{{#if}}`, text templates, standalone `IConditionEvaluator` — rich truthiness, numeric `double.Parse` comparison) and **Inline** (used by `{{(...)}}` placeholders — bool-only truthiness, `IComparable` comparison). New operators are shared across both dialects. Adding a future operator = registering one `IConditionOperator` class; the parser is never touched. + +**Tech Stack:** C# / .NET 10, DocumentFormat.OpenXml 3.3.0, xUnit. Internal types are visible to tests via `InternalsVisibleTo("TriasDev.Templify.Tests")`. + +## Global Constraints + +- **Target framework:** .NET 10.0 (`net10.0`); nullable reference types enabled. +- **External compatibility is the hard requirement:** the entire existing test suite (`ConditionalEvaluatorTests` 81, `ConditionValidationTests` 28, `ConditionEvaluatorTests` 31, `ConditionContextTests`, `Expressions/BooleanExpressionParserTests` 12, all `Integration` tests) must pass **unchanged**. Never edit an existing test to make new code pass — if an existing test would fail, the new code is wrong. +- **Precedence is uniform:** `and` binds tighter than `or`; both left-associative. Verified: no existing test/template asserts a mixed `and`/`or` precedence. +- **Naming conventions:** private fields `_camelCase`; public/internal properties `PascalCase`; locals `camelCase`. XML docs on public APIs; internal classes documented where complexity warrants. +- **File header:** every new `.cs` file starts with the two-line copyright/license header used across the codebase: + ```csharp + // Copyright (c) 2026 TriasDev GmbH & Co. KG + // Licensed under the MIT License. See LICENSE file in the project root for full license information. + ``` +- **Formatting gate:** `dotnet format --verify-no-changes --no-restore` must pass before the final commit. +- **New engine namespace:** `TriasDev.Templify.Conditionals.Engine`. New files live in `TriasDev.Templify/Conditionals/Engine/`. +- **Test commands:** full suite `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj`; single test `... --filter "FullyQualifiedName~"`. + +--- + +## File Structure + +**New (`TriasDev.Templify/Conditionals/Engine/`):** +- `ConditionNode.cs` — AST node hierarchy (`ConditionNode`, `LiteralNode`, `VariableNode`, `ListNode`, `OperatorNode`). +- `ConditionToken.cs` — `ConditionToken` + `ConditionTokenType` enum. +- `ConditionLexer.cs` — string → tokens; quote normalization. +- `ConditionValueOps.cs` — shared, dialect-independent value helpers (equality, comparable-string) used by `in`/string operators and by `DefaultConditionDialect`. +- `ConditionDialect.cs` — abstract `ConditionDialect` + `DefaultConditionDialect` + `InlineConditionDialect`. +- `OperatorFixity.cs` — `enum { Prefix, Infix, Postfix }`. +- `IConditionOperator.cs` — operator contract. +- `ConditionOperatorRegistry.cs` — token → operator lookup + registration of all operators. +- `Operators/` — one file per operator group: + - `ComparisonOperators.cs` (`EqualOperator`, `NotEqualOperator`, `GreaterOperator`, `LessOperator`, `GreaterOrEqualOperator`, `LessOrEqualOperator`) + - `LogicalOperators.cs` (`AndOperator`, `OrOperator`, `NotOperator`) + - `MembershipOperator.cs` (`InOperator`) + - `StringOperators.cs` (`ContainsOperator`, `StartsWithOperator`, `EndsWithOperator`) + - `ExistenceOperators.cs` (`ExistsOperator`, `IsEmptyOperator`, `IsNotEmptyOperator`) +- `ConditionParser.cs` — Pratt parser + `ConditionParseException`. +- `ConditionEvaluatorCore.cs` — AST walker producing `bool`/`object?`. + +**Modified:** +- `TriasDev.Templify/Conditionals/ConditionalEvaluator.cs` — internals replaced; public method signatures preserved; `Validate` heuristic extended to recognize new operator tokens. +- `TriasDev.Templify/Visitors/PlaceholderVisitor.cs:72-111` — inline `{{(...)}}` switched from `BooleanExpressionParser` to the unified engine with `InlineConditionDialect`. + +**Removed (after Task 10):** +- `TriasDev.Templify/Expressions/BooleanExpressionParser.cs`, `BooleanExpression.cs`, `EvaluationContextAdapter.cs`. +- `TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs` (its behavior is re-covered by new Inline-dialect tests; see Task 10). + +**Docs (Task 11):** `docs/for-template-authors/conditionals.md`, `boolean-expressions.md`, `docs/for-developers/condition-evaluation.md`, plus operator-list touch-ups in `best-practices.md`, `template-syntax.md`, `FAQ.md`. + +--- + +## Task 1: AST nodes, fixity enum, value helpers + +**Files:** +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionNode.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/OperatorFixity.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionValueOps.cs` +- Test: `TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs` + +**Interfaces:** +- Produces: + - `abstract class ConditionNode` (marker base). + - `sealed class LiteralNode(object? value) { object? Value }` + - `sealed class VariableNode(string path) { string Path }` + - `sealed class ListNode(IReadOnlyList items) { IReadOnlyList Items }` + - `sealed class OperatorNode(IConditionOperator op, IReadOnlyList operands) { IConditionOperator Operator; IReadOnlyList Operands }` (references `IConditionOperator` from Task 3 — declare after Task 3 or use a forward `using`; see step 3 note). + - `enum OperatorFixity { Prefix, Infix, Postfix }` + - `static class ConditionValueOps` with `bool AreEqual(object? left, object? right)` and `string ToStr(object? value)`. + +- [ ] **Step 1: Write the failing test** + +Create `TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionValueOpsTests +{ + [Fact] + public void AreEqual_SameStrings_ReturnsTrue() + => Assert.True(ConditionValueOps.AreEqual("Active", "Active")); + + [Fact] + public void AreEqual_DifferentCaseStrings_ReturnsFalse() + => Assert.False(ConditionValueOps.AreEqual("active", "Active")); + + [Fact] + public void AreEqual_BoolAndLowercaseLiteral_ReturnsTrue() + => Assert.True(ConditionValueOps.AreEqual(true, "true")); + + [Fact] + public void AreEqual_BothNull_ReturnsTrue() + => Assert.True(ConditionValueOps.AreEqual(null, null)); + + [Fact] + public void AreEqual_OneNull_ReturnsFalse() + => Assert.False(ConditionValueOps.AreEqual(null, "x")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionValueOpsTests"` +Expected: FAIL — `ConditionValueOps` / namespace `TriasDev.Templify.Conditionals.Engine` does not exist (compile error). + +- [ ] **Step 3: Write minimal implementation** + +Create `TriasDev.Templify/Conditionals/Engine/OperatorFixity.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Where an operator sits relative to its operands. +internal enum OperatorFixity +{ + Prefix, + Infix, + Postfix +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/ConditionValueOps.cs`. `AreEqual` ports the existing `ConditionalEvaluator.AreEqual` semantics verbatim (bool-aware, case-insensitive for booleans, ordinal `ToString` otherwise) so `=` and `in` keep identical equality behavior: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// +/// Dialect-independent value helpers shared by operators (equality, string coercion). +/// These preserve the historical ConditionalEvaluator equality semantics so that +/// =, in, and the string operators behave identically regardless of dialect. +/// +internal static class ConditionValueOps +{ + /// Compares two values for equality (bool-aware, case-insensitive booleans, ordinal otherwise). + public static bool AreEqual(object? left, object? right) + { + if (left == null && right == null) + { + return true; + } + + if (left == null || right == null) + { + return false; + } + + if (left is bool || right is bool || IsBooleanLiteral(left) || IsBooleanLiteral(right)) + { + return string.Equals(left.ToString(), right.ToString(), StringComparison.OrdinalIgnoreCase); + } + + return left.ToString() == right.ToString(); + } + + /// Coerces a value to its ordinal string form (empty string for null). + public static string ToStr(object? value) => value?.ToString() ?? string.Empty; + + private static bool IsBooleanLiteral(object? value) + { + string? str = value as string; + return str != null && (str.Equals("true", StringComparison.OrdinalIgnoreCase) + || str.Equals("false", StringComparison.OrdinalIgnoreCase)); + } +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/ConditionNode.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Base type for parsed condition-expression AST nodes. +internal abstract class ConditionNode +{ +} + +/// A literal value (string, number, bool, or null). +internal sealed class LiteralNode : ConditionNode +{ + public LiteralNode(object? value) => Value = value; + + public object? Value { get; } +} + +/// A variable reference resolved through the evaluation context (supports dotted/indexed paths). +internal sealed class VariableNode : ConditionNode +{ + public VariableNode(string path) => Path = path ?? throw new ArgumentNullException(nameof(path)); + + public string Path { get; } +} + +/// A list literal, e.g. ("A", "B"). Valid as the right-hand side of in. +internal sealed class ListNode : ConditionNode +{ + public ListNode(IReadOnlyList items) => Items = items ?? throw new ArgumentNullException(nameof(items)); + + public IReadOnlyList Items { get; } +} + +/// An operator application with its operand nodes. +internal sealed class OperatorNode : ConditionNode +{ + public OperatorNode(IConditionOperator @operator, IReadOnlyList operands) + { + Operator = @operator ?? throw new ArgumentNullException(nameof(@operator)); + Operands = operands ?? throw new ArgumentNullException(nameof(operands)); + } + + public IConditionOperator Operator { get; } + + public IReadOnlyList Operands { get; } +} +``` + +> Note: `OperatorNode` references `IConditionOperator`, created in Task 3. To keep Task 1 self-contained and compiling, create a **minimal placeholder** `IConditionOperator.cs` now containing just the interface signature from Task 3's Step 3 (empty-bodied contract), then flesh out registry/operators in Task 3. Alternatively implement Task 3's `IConditionOperator.cs` first. Either way `OperatorNode` must compile. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionValueOpsTests"` +Expected: PASS (5 passed). + +- [ ] **Step 5: Commit** + +```bash +git add TriasDev.Templify/Conditionals/Engine/ TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs +git commit -m "feat(conditionals): add AST nodes, fixity enum, value helpers (#128)" +``` + +--- + +## Task 2: Lexer + +**Files:** +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionToken.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionLexer.cs` +- Test: `TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `enum ConditionTokenType { Identifier, String, Number, Boolean, Null, Operator, LParen, RParen, Comma, End }` + - `sealed class ConditionToken { ConditionTokenType Type; string Text; object? LiteralValue }` (ctor `ConditionToken(ConditionTokenType type, string text, object? literalValue = null)`). + - `sealed class ConditionLexer { IReadOnlyList Tokenize(string expression) }`. + +Lexing rules: +- Normalize curly quotes to ASCII first (port `NormalizeQuotes` from `ConditionalEvaluator.cs:643-654`). +- Whitespace separates tokens. +- `(`, `)`, `,` are single-character tokens (`LParen`, `RParen`, `Comma`). +- Quoted strings (`"..."`) → `String` token; quotes stripped; supports `\"` escape. +- Operator symbols: greedily match longest of `>=`, `<=`, `==`, `!=`, `=`, `>`, `<` → `Operator`. +- A run of letters/digits/`_`/`.`/`[`/`]` → a **word**. If the word (case-insensitive) is a known word-operator (`and`, `or`, `not`, `in`, `contains`, `startswith`, `endswith`, `exists`, `is`, `empty`) → `Operator` token (Text lowercased). If it is `true`/`false` → `Boolean` (LiteralValue bool). If it is `null` → `Null`. If it is all digits/`.`/leading `-` and parses as a number → `Number` (LiteralValue int or double). Otherwise → `Identifier`. +- Always end with an `End` token. + +> The multi-word operators `is empty` / `is not empty` are lexed as separate `Operator` word tokens (`is`, `not`, `empty`); the parser (Task 4) assembles them. `not` is shared with prefix negation — disambiguated by position in the parser. + +- [ ] **Step 1: Write the failing test** + +Create `TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionLexerTests +{ + private static List Lex(string s) => new ConditionLexer().Tokenize(s).ToList(); + + [Fact] + public void Tokenize_Comparison_ProducesIdentifierOperatorString() + { + List t = Lex("Status = \"Active\""); + Assert.Equal(ConditionTokenType.Identifier, t[0].Type); + Assert.Equal("Status", t[0].Text); + Assert.Equal(ConditionTokenType.Operator, t[1].Type); + Assert.Equal("=", t[1].Text); + Assert.Equal(ConditionTokenType.String, t[2].Type); + Assert.Equal("Active", t[2].Text); + Assert.Equal(ConditionTokenType.End, t[3].Type); + } + + [Fact] + public void Tokenize_GreaterOrEqual_MatchesLongestOperator() + { + List t = Lex("Count >= 3"); + Assert.Equal(ConditionTokenType.Operator, t[1].Type); + Assert.Equal(">=", t[1].Text); + Assert.Equal(ConditionTokenType.Number, t[2].Type); + Assert.Equal(3, t[2].LiteralValue); + } + + [Fact] + public void Tokenize_ListLiteral_ProducesParensAndCommas() + { + List t = Lex("Status in (\"A\", \"B\")"); + Assert.Equal("in", t[1].Text); + Assert.Equal(ConditionTokenType.LParen, t[2].Type); + Assert.Equal(ConditionTokenType.String, t[3].Type); + Assert.Equal(ConditionTokenType.Comma, t[4].Type); + Assert.Equal(ConditionTokenType.RParen, t[6].Type); + } + + [Fact] + public void Tokenize_WordOperators_AreLowercasedOperators() + { + List t = Lex("A AND B IS EMPTY"); + Assert.Equal(ConditionTokenType.Operator, t[1].Type); + Assert.Equal("and", t[1].Text); + Assert.Equal("is", t[3].Text); + Assert.Equal("empty", t[4].Text); + } + + [Fact] + public void Tokenize_CurlyQuotes_AreNormalized() + { + List t = Lex("Status = “Active”"); + Assert.Equal(ConditionTokenType.String, t[2].Type); + Assert.Equal("Active", t[2].Text); + } + + [Fact] + public void Tokenize_BooleanAndNull_ProduceLiteralTokens() + { + List t = Lex("Flag = true"); + Assert.Equal(ConditionTokenType.Boolean, t[2].Type); + Assert.Equal(true, t[2].LiteralValue); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionLexerTests"` +Expected: FAIL — `ConditionLexer`/`ConditionToken` not defined. + +- [ ] **Step 3: Write minimal implementation** + +Create `TriasDev.Templify/Conditionals/Engine/ConditionToken.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// The kind of a lexed condition token. +internal enum ConditionTokenType +{ + Identifier, + String, + Number, + Boolean, + Null, + Operator, + LParen, + RParen, + Comma, + End +} + +/// A single lexed token. +internal sealed class ConditionToken +{ + public ConditionToken(ConditionTokenType type, string text, object? literalValue = null) + { + Type = type; + Text = text; + LiteralValue = literalValue; + } + + public ConditionTokenType Type { get; } + + public string Text { get; } + + public object? LiteralValue { get; } +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/ConditionLexer.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.Text; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Converts a condition-expression string into a flat token list. +internal sealed class ConditionLexer +{ + private static readonly HashSet _wordOperators = new(StringComparer.OrdinalIgnoreCase) + { + "and", "or", "not", "in", "contains", "startswith", "endswith", "exists", "is", "empty" + }; + + public IReadOnlyList Tokenize(string expression) + { + string text = NormalizeQuotes(expression ?? string.Empty); + List tokens = new(); + int i = 0; + + while (i < text.Length) + { + char c = text[i]; + + if (char.IsWhiteSpace(c)) { i++; continue; } + + if (c == '(') { tokens.Add(new ConditionToken(ConditionTokenType.LParen, "(")); i++; continue; } + if (c == ')') { tokens.Add(new ConditionToken(ConditionTokenType.RParen, ")")); i++; continue; } + if (c == ',') { tokens.Add(new ConditionToken(ConditionTokenType.Comma, ",")); i++; continue; } + + if (c == '"') + { + i++; + StringBuilder sb = new(); + while (i < text.Length && text[i] != '"') + { + if (text[i] == '\\' && i + 1 < text.Length && text[i + 1] == '"') { sb.Append('"'); i += 2; continue; } + sb.Append(text[i]); + i++; + } + i++; // closing quote (if present) + tokens.Add(new ConditionToken(ConditionTokenType.String, sb.ToString())); + continue; + } + + string? symbol = MatchSymbolOperator(text, i); + if (symbol != null) + { + tokens.Add(new ConditionToken(ConditionTokenType.Operator, symbol)); + i += symbol.Length; + continue; + } + + if (IsWordChar(c) || (c == '-' && i + 1 < text.Length && char.IsDigit(text[i + 1]))) + { + int start = i; + if (c == '-') { i++; } + while (i < text.Length && IsWordChar(text[i])) { i++; } + string word = text.Substring(start, i - start); + tokens.Add(ClassifyWord(word)); + continue; + } + + // Unknown character: emit as an Operator token so validation/parse can reject it. + tokens.Add(new ConditionToken(ConditionTokenType.Operator, c.ToString())); + i++; + } + + tokens.Add(new ConditionToken(ConditionTokenType.End, string.Empty)); + return tokens; + } + + private static ConditionToken ClassifyWord(string word) + { + if (_wordOperators.Contains(word)) + { + return new ConditionToken(ConditionTokenType.Operator, word.ToLowerInvariant()); + } + + if (word.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + return new ConditionToken(ConditionTokenType.Boolean, word, true); + } + + if (word.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + return new ConditionToken(ConditionTokenType.Boolean, word, false); + } + + if (word.Equals("null", StringComparison.OrdinalIgnoreCase)) + { + return new ConditionToken(ConditionTokenType.Null, word, null); + } + + if (TryParseNumber(word, out object? number)) + { + return new ConditionToken(ConditionTokenType.Number, word, number); + } + + return new ConditionToken(ConditionTokenType.Identifier, word); + } + + private static bool TryParseNumber(string word, out object? value) + { + if (!word.Contains('.') && int.TryParse(word, NumberStyles.Integer, CultureInfo.InvariantCulture, out int iv)) + { + value = iv; + return true; + } + + if (double.TryParse(word, NumberStyles.Float, CultureInfo.InvariantCulture, out double dv)) + { + value = dv; + return true; + } + + value = null; + return false; + } + + private static string? MatchSymbolOperator(string text, int i) + { + string[] twoChar = { ">=", "<=", "==", "!=" }; + if (i + 1 < text.Length) + { + string pair = text.Substring(i, 2); + foreach (string op in twoChar) + { + if (pair == op) { return op; } + } + } + + char c = text[i]; + if (c == '=' || c == '>' || c == '<') { return c.ToString(); } + return null; + } + + private static bool IsWordChar(char c) + => char.IsLetterOrDigit(c) || c == '_' || c == '.' || c == '[' || c == ']'; + + private static string NormalizeQuotes(string expression) + { + return expression + .Replace('“', '"').Replace('”', '"').Replace('„', '"').Replace('‟', '"') + .Replace('‘', '\'').Replace('’', '\'').Replace('‚', '\'').Replace('‛', '\''); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionLexerTests"` +Expected: PASS (6 passed). + +- [ ] **Step 5: Commit** + +```bash +git add TriasDev.Templify/Conditionals/Engine/ConditionToken.cs TriasDev.Templify/Conditionals/Engine/ConditionLexer.cs TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs +git commit -m "feat(conditionals): add condition lexer (#128)" +``` + +--- + +## Task 3: Operator contract, dialects, evaluator core, registry with existing operators + +**Files:** +- Create: `TriasDev.Templify/Conditionals/Engine/IConditionOperator.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionDialect.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionEvaluatorCore.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/Operators/LogicalOperators.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/Operators/ComparisonOperators.cs` +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs` +- Test: `TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs` + +**Interfaces:** +- Consumes: `ConditionNode` family (Task 1), `OperatorFixity` (Task 1), `ConditionValueOps` (Task 1). +- Produces: + - `interface IConditionOperator { IReadOnlyList Tokens; int Precedence; OperatorFixity Fixity; bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands); }` + - `abstract class ConditionDialect { bool ToBool(object?); bool AreEqual(object?, object?); bool TryCompare(object?, object?, out int cmp); }` + `DefaultConditionDialect` + `InlineConditionDialect` (both stateless singletons via `static readonly Instance`). + - `sealed class ConditionEvaluatorCore` with ctor `(IEvaluationContext context, ConditionDialect dialect)`, `bool EvaluateBool(ConditionNode)`, `object? EvaluateValue(ConditionNode)`, `bool TryResolveVariable(string path, out object? value)`, `ConditionDialect Dialect { get; }`. + - `sealed class ConditionOperatorRegistry` with `static ConditionOperatorRegistry Shared { get; }`, `IConditionOperator? FindInfix(string token)`, `IConditionOperator? FindPrefix(string token)`, `IReadOnlyList PostfixOperators { get; }`, `bool IsKnownOperatorToken(string token)`. + - Precedence constants: `or`=1, `and`=2, `not`=3, comparisons=4, postfix=5. + +- [ ] **Step 1: Write the failing test** + +Create `TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionEvaluatorCoreTests +{ + private static ConditionEvaluatorCore Default(Dictionary data) + => new(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance); + + private static readonly ConditionOperatorRegistry Reg = ConditionOperatorRegistry.Shared; + + [Fact] + public void Equal_MatchingStrings_IsTrue() + { + var node = new OperatorNode(Reg.FindInfix("=")!, + new ConditionNode[] { new VariableNode("Status"), new LiteralNode("Active") }); + Assert.True(Default(new() { ["Status"] = "Active" }).EvaluateBool(node)); + } + + [Fact] + public void And_ShortCircuits_And_Combines() + { + var left = new OperatorNode(Reg.FindInfix(">")!, + new ConditionNode[] { new VariableNode("Count"), new LiteralNode(0) }); + var node = new OperatorNode(Reg.FindInfix("and")!, + new ConditionNode[] { left, new VariableNode("IsOn") }); + Assert.True(Default(new() { ["Count"] = 5, ["IsOn"] = true }).EvaluateBool(node)); + Assert.False(Default(new() { ["Count"] = 0, ["IsOn"] = true }).EvaluateBool(node)); + } + + [Fact] + public void Not_NegatesOperand() + { + var node = new OperatorNode(Reg.FindPrefix("not")!, + new ConditionNode[] { new VariableNode("IsOff") }); + Assert.True(Default(new() { ["IsOff"] = false }).EvaluateBool(node)); + } + + [Fact] + public void Greater_UsesNumericComparison() + { + var node = new OperatorNode(Reg.FindInfix(">")!, + new ConditionNode[] { new VariableNode("Count"), new LiteralNode(3) }); + Assert.True(Default(new() { ["Count"] = 5 }).EvaluateBool(node)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionEvaluatorCoreTests"` +Expected: FAIL — types not defined. + +- [ ] **Step 3: Write minimal implementation** + +Create `TriasDev.Templify/Conditionals/Engine/IConditionOperator.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// +/// A pluggable condition operator. Adding a new operator means implementing this and +/// registering it in ; the parser is not modified. +/// +internal interface IConditionOperator +{ + /// Token sequence that denotes this operator (e.g. ["="], ["is","empty"]). + IReadOnlyList Tokens { get; } + + /// Binding precedence (higher binds tighter). + int Precedence { get; } + + /// Operator position relative to its operands. + OperatorFixity Fixity { get; } + + /// Evaluates the operator against its operand nodes. + bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands); +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/ConditionDialect.cs`. `DefaultConditionDialect` ports `EvaluateValue`/`AreEqual`/`IsGreaterThan`/`IsLessThan` from `ConditionalEvaluator`. `InlineConditionDialect` ports `BooleanExpression` semantics (bool-only truthiness, `object.Equals`, `IComparable`): + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; +using System.Globalization; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Externally-observable semantic policy for an entry point (truthiness + base comparison). +internal abstract class ConditionDialect +{ + /// Coerces a value to a boolean (truthiness). + public abstract bool ToBool(object? value); + + /// Equality used by the =/!= operators. + public abstract bool AreEqual(object? left, object? right); + + /// Ordered comparison used by >/</>=/<=. + /// Returns false when the operands are not comparable in this dialect. + public abstract bool TryCompare(object? left, object? right, out int cmp); +} + +/// Semantics for {{#if}}, text templates, and the standalone API (rich truthiness, numeric compare). +internal sealed class DefaultConditionDialect : ConditionDialect +{ + public static readonly DefaultConditionDialect Instance = new(); + + public override bool ToBool(object? value) + { + if (value == null) { return false; } + if (value is bool b) { return b; } + + if (value is string s) + { + if (string.IsNullOrWhiteSpace(s)) { return false; } + string lower = s.ToLowerInvariant(); + if (lower == "false" || lower == "0") { return false; } + if (lower == "true" || lower == "1") { return true; } + return true; + } + + if (value is int i) { return i != 0; } + if (value is ICollection c) { return c.Count > 0; } + return true; + } + + public override bool AreEqual(object? left, object? right) => ConditionValueOps.AreEqual(left, right); + + public override bool TryCompare(object? left, object? right, out int cmp) + { + cmp = 0; + if (double.TryParse(left?.ToString() ?? "0", NumberStyles.Float, CultureInfo.InvariantCulture, out double l) + && double.TryParse(right?.ToString() ?? "0", NumberStyles.Float, CultureInfo.InvariantCulture, out double r)) + { + cmp = l.CompareTo(r); + return true; + } + return false; + } +} + +/// Semantics for inline {{(...)}} placeholders (bool-only truthiness, IComparable compare). +internal sealed class InlineConditionDialect : ConditionDialect +{ + public static readonly InlineConditionDialect Instance = new(); + + public override bool ToBool(object? value) => value is bool b && b; + + public override bool AreEqual(object? left, object? right) => Equals(left, right); + + public override bool TryCompare(object? left, object? right, out int cmp) + { + cmp = 0; + if (left == null || right == null) + { + cmp = left == null ? (right == null ? 0 : -1) : 1; + return true; + } + + if (left is IComparable lc && right is IComparable) + { + try { cmp = lc.CompareTo(right); return true; } + catch (ArgumentException) { return false; } + catch (InvalidCastException) { return false; } + } + return false; + } +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/ConditionEvaluatorCore.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Walks a AST, resolving variables and applying operators. +internal sealed class ConditionEvaluatorCore +{ + private readonly IEvaluationContext _context; + + public ConditionEvaluatorCore(IEvaluationContext context, ConditionDialect dialect) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + Dialect = dialect ?? throw new ArgumentNullException(nameof(dialect)); + } + + public ConditionDialect Dialect { get; } + + public bool TryResolveVariable(string path, out object? value) => _context.TryResolveVariable(path, out value); + + /// Evaluates a node to its boolean truth value. + public bool EvaluateBool(ConditionNode node) + { + return node switch + { + OperatorNode op => op.Operator.Evaluate(this, op.Operands), + LiteralNode lit => Dialect.ToBool(lit.Value), + VariableNode var => Dialect.ToBool(Resolve(var)), + _ => false + }; + } + + /// Evaluates a node to its underlying value (for operand comparison). + public object? EvaluateValue(ConditionNode node) + { + return node switch + { + LiteralNode lit => lit.Value, + VariableNode var => Resolve(var), + ListNode list => MaterializeList(list), + OperatorNode op => op.Operator.Evaluate(this, op.Operands), + _ => null + }; + } + + private object? Resolve(VariableNode var) + { + _context.TryResolveVariable(var.Path, out object? value); + return value; + } + + private List MaterializeList(ListNode list) + { + List items = new(list.Items.Count); + foreach (ConditionNode item in list.Items) + { + items.Add(EvaluateValue(item)); + } + return items; + } +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/Operators/LogicalOperators.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +internal sealed class OrOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "or" }; + public int Precedence => 1; + public OperatorFixity Fixity => OperatorFixity.Infix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => core.EvaluateBool(operands[0]) || core.EvaluateBool(operands[1]); +} + +internal sealed class AndOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "and" }; + public int Precedence => 2; + public OperatorFixity Fixity => OperatorFixity.Infix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => core.EvaluateBool(operands[0]) && core.EvaluateBool(operands[1]); +} + +internal sealed class NotOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "not" }; + public int Precedence => 3; + public OperatorFixity Fixity => OperatorFixity.Prefix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => !core.EvaluateBool(operands[0]); +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/Operators/ComparisonOperators.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +internal abstract class ComparisonOperatorBase : IConditionOperator +{ + public abstract IReadOnlyList Tokens { get; } + public int Precedence => 4; + public OperatorFixity Fixity => OperatorFixity.Infix; + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => Compare(core, core.EvaluateValue(operands[0]), core.EvaluateValue(operands[1])); + protected abstract bool Compare(ConditionEvaluatorCore core, object? left, object? right); +} + +internal sealed class EqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "=", "==" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) => core.Dialect.AreEqual(l, r); +} + +internal sealed class NotEqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "!=" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) => !core.Dialect.AreEqual(l, r); +} + +internal sealed class GreaterOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { ">" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c > 0; +} + +internal sealed class LessOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "<" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c < 0; +} + +internal sealed class GreaterOrEqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { ">=" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c >= 0; +} + +internal sealed class LessOrEqualOperator : ComparisonOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "<=" }; + protected override bool Compare(ConditionEvaluatorCore core, object? l, object? r) + => core.Dialect.TryCompare(l, r, out int c) && c <= 0; +} +``` + +Create `TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine.Operators; + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Central registry mapping tokens to operators. Register once; the parser reads it. +internal sealed class ConditionOperatorRegistry +{ + private readonly Dictionary _infix = new(StringComparer.Ordinal); + private readonly Dictionary _prefix = new(StringComparer.Ordinal); + private readonly List _postfix = new(); + private readonly HashSet _allTokens = new(StringComparer.Ordinal); + + public static ConditionOperatorRegistry Shared { get; } = CreateDefault(); + + public IReadOnlyList PostfixOperators => _postfix; + + public IConditionOperator? FindInfix(string token) => _infix.GetValueOrDefault(token); + + public IConditionOperator? FindPrefix(string token) => _prefix.GetValueOrDefault(token); + + public bool IsKnownOperatorToken(string token) => _allTokens.Contains(token); + + public void Register(IConditionOperator op) + { + foreach (string token in op.Tokens) + { + _allTokens.Add(token); + } + + switch (op.Fixity) + { + case OperatorFixity.Infix: + foreach (string token in op.Tokens) { _infix[token] = op; } + break; + case OperatorFixity.Prefix: + foreach (string token in op.Tokens) { _prefix[token] = op; } + break; + case OperatorFixity.Postfix: + _postfix.Add(op); + break; + } + } + + private static ConditionOperatorRegistry CreateDefault() + { + ConditionOperatorRegistry r = new(); + r.Register(new OrOperator()); + r.Register(new AndOperator()); + r.Register(new NotOperator()); + r.Register(new EqualOperator()); + r.Register(new NotEqualOperator()); + r.Register(new GreaterOperator()); + r.Register(new LessOperator()); + r.Register(new GreaterOrEqualOperator()); + r.Register(new LessOrEqualOperator()); + // Tasks 7-9 add: InOperator, ContainsOperator, StartsWithOperator, EndsWithOperator, + // ExistsOperator, IsEmptyOperator, IsNotEmptyOperator. + return r; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionEvaluatorCoreTests"` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add TriasDev.Templify/Conditionals/Engine/ TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs +git commit -m "feat(conditionals): add operator contract, dialects, evaluator core, registry (#128)" +``` + +--- + +## Task 4: Pratt parser with grouping + list literals + +**Files:** +- Create: `TriasDev.Templify/Conditionals/Engine/ConditionParser.cs` +- Test: `TriasDev.Templify.Tests/Engine/ConditionParserTests.cs` + +**Interfaces:** +- Consumes: `ConditionToken`/`ConditionTokenType` (Task 2), `ConditionNode` family (Task 1), `ConditionOperatorRegistry` (Task 3). +- Produces: + - `sealed class ConditionParseException : Exception` (ctor `(string message)`). + - `sealed class ConditionParser` with ctor `(ConditionOperatorRegistry registry)` and `ConditionNode Parse(IReadOnlyList tokens)` (throws `ConditionParseException` on malformed input). + +Parser grammar (Pratt / precedence-climbing): +- `ParseExpression(minPrec)`: parse a prefix/primary node; then loop applying **postfix** operators (`exists`, `is empty`, `is not empty`) and **infix** operators whose `Precedence >= minPrec` (right side parsed at `Precedence + 1` for left-associativity). +- Prefix: `not` → `OperatorNode(not, [ParseExpression(3)])`. +- Primary: `(` → parse inner `ParseExpression(0)`; if a `,` follows, it's a **list literal** (collect comma-separated `ParseExpression(0)` into `ListNode`); else it's a **grouping** (return the inner node). Then `String`/`Number`/`Boolean`/`Null` → `LiteralNode`; `Identifier` → `VariableNode`. +- Postfix matching: for each token position after an operand, try to match the token sequence of any registered postfix operator (`exists` = ["exists"]; `is empty` = ["is","empty"]; `is not empty` = ["is","not","empty"]). Match the **longest** sequence first. + +- [ ] **Step 1: Write the failing test** + +Create `TriasDev.Templify.Tests/Engine/ConditionParserTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class ConditionParserTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void AndBindsTighterThanOr() + { + // false or (true and true) => true ; if or bound tighter, (false or true) and false path differs. + Assert.True(Eval("A or B and C", new() { ["A"] = false, ["B"] = true, ["C"] = true })); + Assert.False(Eval("A or B and C", new() { ["A"] = false, ["B"] = true, ["C"] = false })); + } + + [Fact] + public void ParenthesesOverridePrecedence() + { + Assert.False(Eval("(A or B) and C", new() { ["A"] = false, ["B"] = true, ["C"] = false })); + Assert.True(Eval("(A or B) and C", new() { ["A"] = false, ["B"] = true, ["C"] = true })); + } + + [Fact] + public void ComparisonBindsTighterThanNot() + { + // not Status = "Active" => not (Status = "Active") + Assert.False(Eval("not Status = \"Active\"", new() { ["Status"] = "Active" })); + Assert.True(Eval("not Status = \"Active\"", new() { ["Status"] = "Inactive" })); + } + + [Fact] + public void MalformedExpression_Throws() + { + var tokens = new ConditionLexer().Tokenize("Status ="); + Assert.Throws(() => + new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionParserTests"` +Expected: FAIL — `ConditionParser`/`ConditionParseException` not defined. + +- [ ] **Step 3: Write minimal implementation** + +Create `TriasDev.Templify/Conditionals/Engine/ConditionParser.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine; + +/// Thrown when a condition expression cannot be parsed. +internal sealed class ConditionParseException : Exception +{ + public ConditionParseException(string message) : base(message) { } +} + +/// Precedence-climbing (Pratt) parser turning tokens into a AST. +internal sealed class ConditionParser +{ + private readonly ConditionOperatorRegistry _registry; + private IReadOnlyList _tokens = Array.Empty(); + private int _pos; + + public ConditionParser(ConditionOperatorRegistry registry) + => _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + + public ConditionNode Parse(IReadOnlyList tokens) + { + _tokens = tokens ?? throw new ArgumentNullException(nameof(tokens)); + _pos = 0; + ConditionNode node = ParseExpression(0); + if (Current.Type != ConditionTokenType.End) + { + throw new ConditionParseException($"Unexpected token '{Current.Text}'."); + } + return node; + } + + private ConditionToken Current => _tokens[_pos]; + + private ConditionToken Advance() => _tokens[_pos++]; + + private ConditionNode ParseExpression(int minPrecedence) + { + ConditionNode left = ParsePrefix(); + + while (true) + { + ConditionNode? postfixed = TryApplyPostfix(left); + if (postfixed != null) { left = postfixed; continue; } + + if (Current.Type != ConditionTokenType.Operator) { break; } + IConditionOperator? infix = _registry.FindInfix(Current.Text); + if (infix == null || infix.Precedence < minPrecedence) { break; } + + Advance(); + ConditionNode right = ParseExpression(infix.Precedence + 1); + left = new OperatorNode(infix, new[] { left, right }); + } + + return left; + } + + private ConditionNode ParsePrefix() + { + if (Current.Type == ConditionTokenType.Operator) + { + IConditionOperator? prefix = _registry.FindPrefix(Current.Text); + if (prefix != null) + { + Advance(); + ConditionNode operand = ParseExpression(prefix.Precedence); + return new OperatorNode(prefix, new[] { operand }); + } + } + + return ParsePrimary(); + } + + private ConditionNode ParsePrimary() + { + ConditionToken token = Current; + switch (token.Type) + { + case ConditionTokenType.LParen: + return ParseParenthesized(); + case ConditionTokenType.String: + Advance(); + return new LiteralNode(token.Text); + case ConditionTokenType.Number: + case ConditionTokenType.Boolean: + case ConditionTokenType.Null: + Advance(); + return new LiteralNode(token.LiteralValue); + case ConditionTokenType.Identifier: + Advance(); + return new VariableNode(token.Text); + default: + throw new ConditionParseException($"Expected an operand but found '{token.Text}'."); + } + } + + private ConditionNode ParseParenthesized() + { + Advance(); // consume '(' + ConditionNode first = ParseExpression(0); + + if (Current.Type == ConditionTokenType.Comma) + { + List items = new() { first }; + while (Current.Type == ConditionTokenType.Comma) + { + Advance(); + items.Add(ParseExpression(0)); + } + Expect(ConditionTokenType.RParen); + return new ListNode(items); + } + + Expect(ConditionTokenType.RParen); + return first; + } + + private ConditionNode? TryApplyPostfix(ConditionNode left) + { + foreach (IConditionOperator op in OrderedByTokenCountDescending()) + { + if (MatchesSequence(op.Tokens)) + { + _pos += op.Tokens.Count; + return new OperatorNode(op, new[] { left }); + } + } + return null; + } + + private IEnumerable OrderedByTokenCountDescending() + { + // Longest sequence first so "is not empty" wins over any "is ..." prefix. + List ordered = new(_registry.PostfixOperators); + ordered.Sort((a, b) => b.Tokens.Count.CompareTo(a.Tokens.Count)); + return ordered; + } + + private bool MatchesSequence(IReadOnlyList sequence) + { + for (int i = 0; i < sequence.Count; i++) + { + ConditionToken t = _tokens[Math.Min(_pos + i, _tokens.Count - 1)]; + if (t.Type != ConditionTokenType.Operator || !string.Equals(t.Text, sequence[i], StringComparison.Ordinal)) + { + return false; + } + } + return true; + } + + private void Expect(ConditionTokenType type) + { + if (Current.Type != type) + { + throw new ConditionParseException($"Expected {type} but found '{Current.Text}'."); + } + Advance(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ConditionParserTests"` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add TriasDev.Templify/Conditionals/Engine/ConditionParser.cs TriasDev.Templify.Tests/Engine/ConditionParserTests.cs +git commit -m "feat(conditionals): add Pratt parser with grouping and list literals (#128)" +``` + +--- + +## Task 5: Rewire `ConditionalEvaluator` facade to the new engine (compatibility gate) + +This is the critical backward-compatibility task: the internal `ConditionalEvaluator` keeps its public method signatures but delegates evaluation to the new engine (Default dialect). `Validate` keeps its existing heuristic, extended only to recognize the new operator tokens so they are not flagged as `UnknownOperator`. + +**Files:** +- Modify: `TriasDev.Templify/Conditionals/ConditionalEvaluator.cs` +- Test: (none new) — the gate is the **entire existing suite** passing unchanged. + +**Interfaces:** +- Consumes: `ConditionLexer`, `ConditionParser`, `ConditionOperatorRegistry`, `ConditionEvaluatorCore`, `DefaultConditionDialect`, `ConditionParseException`. +- Produces: unchanged public surface — `bool Evaluate(string, IEvaluationContext)`, `bool Evaluate(string, Dictionary)`, `ConditionValidationResult Validate(string)`. + +- [ ] **Step 1: Establish the green baseline** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj` +Expected: PASS (all existing tests + Tasks 1-4 tests). Record the total passing count — it must not drop. + +- [ ] **Step 2: Replace evaluation internals** + +In `TriasDev.Templify/Conditionals/ConditionalEvaluator.cs`, replace the body of `Evaluate(string expression, IEvaluationContext context)` (currently `ConditionalEvaluator.cs:202-225`) so it delegates to the engine, preserving the "empty/invalid → false" behavior: + +```csharp +public bool Evaluate(string expression, IEvaluationContext context) +{ + if (string.IsNullOrWhiteSpace(expression)) + { + return false; + } + + try + { + IReadOnlyList tokens = new Engine.ConditionLexer().Tokenize(expression); + Engine.ConditionNode node = new Engine.ConditionParser(Engine.ConditionOperatorRegistry.Shared).Parse(tokens); + return new Engine.ConditionEvaluatorCore(context, Engine.DefaultConditionDialect.Instance).EvaluateBool(node); + } + catch (Engine.ConditionParseException) + { + return false; + } +} +``` + +Keep the `Evaluate(string, Dictionary)` bridge as-is (it builds a `GlobalEvaluationContext` and calls the above). Delete the now-unused private evaluation helpers that the old flat evaluator used **only** for evaluation (`EvaluateTokens`, `ApplyLogicalOperator`, `ResolveValueOrLiteral`, `EvaluateVariable`, `EvaluateValue`, `AreEqual`, `IsBooleanLiteral`, `IsGreaterThan`, `IsLessThan`, `IsLogicalOperator`) **only if** they are not used by `Validate`. `Validate` currently uses `IsComparisonOperator`, `IsLogicalOperator`, `NormalizeQuotes`, `ParseExpression`, `IsSuspectedUnknownOperator`, and `_knownInvalidOperators` — keep those. + +> If removing a helper breaks compilation because `Validate` still references it, keep that helper. The safe rule: remove a private member only after confirming no remaining reference. + +- [ ] **Step 3: Extend `Validate` token recognition for new operators** + +In `IsComparisonOperator` (`ConditionalEvaluator.cs:631-637`), add the new infix word-operators so they are treated as operators (not unknown tokens or operands). Replace the method with: + +```csharp +private bool IsComparisonOperator(string token) +{ + string lower = token.ToLower(); + return lower == EqOperator || lower == EqOperatorDouble || lower == NeOperator || + lower == GtOperator || lower == LtOperator || + lower == GteOperator || lower == LteOperator || + lower == "in" || lower == "contains" || lower == "startswith" || lower == "endswith"; +} +``` + +> Postfix operators (`exists`, `is`, `empty`) and list-literal parentheses are new syntax with no prior validation tests. The existing heuristic `Validate` treats unknown **words** as operands (not errors), so `Foo exists` validates as two operands → it would report `ConsecutiveOperands`. That is acceptable for this task (no existing test covers it) and is refined in Task 9, Step 6. + +- [ ] **Step 4: Run the full suite (compatibility gate)** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj` +Expected: PASS — the same total as Step 1's baseline. In particular all 81 `ConditionalEvaluatorTests`, 28 `ConditionValidationTests`, 31 `ConditionEvaluatorTests`, `ConditionContextTests`, and all `Integration` tests are green **without edits**. + +> If any existing test fails, the Default dialect does not match historical semantics. Do not edit the test — fix the dialect/engine. Common culprits: numeric comparison culture, `EvaluateValue` truthiness for `"0"`/`"1"`, or bool equality case-insensitivity. Compare against the original methods in git history. + +- [ ] **Step 5: Commit** + +```bash +git add TriasDev.Templify/Conditionals/ConditionalEvaluator.cs +git commit -m "refactor(conditionals): route {{#if}} evaluation through unified engine (#128)" +``` + +--- + +## Task 6: `in` operator (+ negation, list literal, comma string, collection variable) + +**Files:** +- Create: `TriasDev.Templify/Conditionals/Engine/Operators/MembershipOperator.cs` +- Modify: `TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs` (register `InOperator`) +- Test: `TriasDev.Templify.Tests/Engine/MembershipOperatorTests.cs` + +**Interfaces:** +- Consumes: `ConditionEvaluatorCore`, `ConditionValueOps`, `ConditionNode` family. +- Produces: `sealed class InOperator : IConditionOperator` (Tokens `["in"]`, Precedence 4, Infix). + +`InOperator.Evaluate`: `left = core.EvaluateValue(operands[0])`; `right = core.EvaluateValue(operands[1])`. Build candidate values: +- `right` is `IEnumerable` but not `string` (includes the materialized `ListNode` `List` and any resolved `ICollection`) → iterate its items; +- `right` is `string` → split on `,`, trim each; +- otherwise → single candidate `right`. +Return true if any candidate satisfies `ConditionValueOps.AreEqual(left, candidate)`. Negation is handled by the existing `not` operator (`not Status in Roles`). + +- [ ] **Step 1: Write the failing test** + +Create `TriasDev.Templify.Tests/Engine/MembershipOperatorTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class MembershipOperatorTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void In_CollectionVariable_Matches() + => Assert.True(Eval("Status in Roles", new() { ["Status"] = "Admin", ["Roles"] = new List { "User", "Admin" } })); + + [Fact] + public void In_CollectionVariable_NoMatch() + => Assert.False(Eval("Status in Roles", new() { ["Status"] = "Guest", ["Roles"] = new List { "User", "Admin" } })); + + [Fact] + public void In_ListLiteral_Matches() + => Assert.True(Eval("Status in (\"Active\", \"Pending\")", new() { ["Status"] = "Pending" })); + + [Fact] + public void In_CommaString_Matches() + => Assert.True(Eval("Status in \"Active,Pending\"", new() { ["Status"] = "Active" })); + + [Fact] + public void NotIn_Negation_Works() + => Assert.True(Eval("not Status in Roles", new() { ["Status"] = "Guest", ["Roles"] = new List { "User", "Admin" } })); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~MembershipOperatorTests"` +Expected: FAIL — `in` is not registered, so the parser treats `in` as an unexpected token → `ConditionParseException`, and (for the negation test) evaluation differs. + +- [ ] **Step 3: Write minimal implementation** + +Create `TriasDev.Templify/Conditionals/Engine/Operators/MembershipOperator.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +/// Collection membership: scalar in source. Source may be a collection variable, +/// a list literal ("A","B"), or a comma-separated string "A,B". +internal sealed class InOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "in" }; + public int Precedence => 4; + public OperatorFixity Fixity => OperatorFixity.Infix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + { + object? left = core.EvaluateValue(operands[0]); + object? right = core.EvaluateValue(operands[1]); + + foreach (object? candidate in EnumerateCandidates(right)) + { + if (ConditionValueOps.AreEqual(left, candidate)) + { + return true; + } + } + return false; + } + + private static IEnumerable EnumerateCandidates(object? right) + { + if (right is string s) + { + foreach (string part in s.Split(',')) + { + yield return part.Trim(); + } + yield break; + } + + if (right is IEnumerable enumerable) + { + foreach (object? item in enumerable) + { + yield return item; + } + yield break; + } + + yield return right; + } +} +``` + +Register it in `ConditionOperatorRegistry.CreateDefault()` (add after `LessOrEqualOperator`): + +```csharp + r.Register(new InOperator()); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~MembershipOperatorTests"` +Expected: PASS (5 passed). + +- [ ] **Step 5: Commit** + +```bash +git add TriasDev.Templify/Conditionals/Engine/Operators/MembershipOperator.cs TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs TriasDev.Templify.Tests/Engine/MembershipOperatorTests.cs +git commit -m "feat(conditionals): add 'in' membership operator (#128)" +``` + +--- + +## Task 7: String operators `contains`, `startswith`, `endswith` + +**Files:** +- Create: `TriasDev.Templify/Conditionals/Engine/Operators/StringOperators.cs` +- Modify: `TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs` (register the three) +- Test: `TriasDev.Templify.Tests/Engine/StringOperatorsTests.cs` + +**Interfaces:** +- Produces: `ContainsOperator`, `StartsWithOperator`, `EndsWithOperator` (each Tokens single-word, Precedence 4, Infix). All use `ConditionValueOps.ToStr` and ordinal, case-sensitive comparison. + +- [ ] **Step 1: Write the failing test** + +Create `TriasDev.Templify.Tests/Engine/StringOperatorsTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class StringOperatorsTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void Contains_Substring_IsTrue() + => Assert.True(Eval("Description contains \"urgent\"", new() { ["Description"] = "this is urgent" })); + + [Fact] + public void Contains_IsCaseSensitive() + => Assert.False(Eval("Description contains \"URGENT\"", new() { ["Description"] = "this is urgent" })); + + [Fact] + public void StartsWith_Prefix_IsTrue() + => Assert.True(Eval("Phone startswith \"+49\"", new() { ["Phone"] = "+49 151" })); + + [Fact] + public void EndsWith_Suffix_IsTrue() + => Assert.True(Eval("File endswith \".pdf\"", new() { ["File"] = "report.pdf" })); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~StringOperatorsTests"` +Expected: FAIL — operators not registered → parse throws. + +- [ ] **Step 3: Write minimal implementation** + +Create `TriasDev.Templify/Conditionals/Engine/Operators/StringOperators.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +internal abstract class StringOperatorBase : IConditionOperator +{ + public abstract IReadOnlyList Tokens { get; } + public int Precedence => 4; + public OperatorFixity Fixity => OperatorFixity.Infix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + { + string left = ConditionValueOps.ToStr(core.EvaluateValue(operands[0])); + string right = ConditionValueOps.ToStr(core.EvaluateValue(operands[1])); + return Test(left, right); + } + + protected abstract bool Test(string left, string right); +} + +internal sealed class ContainsOperator : StringOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "contains" }; + protected override bool Test(string left, string right) => left.Contains(right, StringComparison.Ordinal); +} + +internal sealed class StartsWithOperator : StringOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "startswith" }; + protected override bool Test(string left, string right) => left.StartsWith(right, StringComparison.Ordinal); +} + +internal sealed class EndsWithOperator : StringOperatorBase +{ + public override IReadOnlyList Tokens { get; } = new[] { "endswith" }; + protected override bool Test(string left, string right) => left.EndsWith(right, StringComparison.Ordinal); +} +``` + +Register in `ConditionOperatorRegistry.CreateDefault()` (after `InOperator`): + +```csharp + r.Register(new ContainsOperator()); + r.Register(new StartsWithOperator()); + r.Register(new EndsWithOperator()); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~StringOperatorsTests"` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add TriasDev.Templify/Conditionals/Engine/Operators/StringOperators.cs TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs TriasDev.Templify.Tests/Engine/StringOperatorsTests.cs +git commit -m "feat(conditionals): add contains/startswith/endswith operators (#128)" +``` + +--- + +## Task 8: Existence operators `exists`, `is empty`, `is not empty` + +**Files:** +- Create: `TriasDev.Templify/Conditionals/Engine/Operators/ExistenceOperators.cs` +- Modify: `TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs` (register the three postfix ops) +- Modify: `TriasDev.Templify/Conditionals/ConditionalEvaluator.cs` (`Validate`: recognize `exists`/`is`/`empty` so `Foo exists` does not report `ConsecutiveOperands`) +- Test: `TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs` + +**Interfaces:** +- Produces: + - `ExistsOperator` (Tokens `["exists"]`, Precedence 5, Postfix). + - `IsEmptyOperator` (Tokens `["is","empty"]`, Precedence 5, Postfix). + - `IsNotEmptyOperator` (Tokens `["is","not","empty"]`, Precedence 5, Postfix). + +Semantics (operand is the node to the left, typically a `VariableNode`): +- `exists` → true if operand is a `VariableNode` whose path resolves (`core.TryResolveVariable(path, out _) == true`), regardless of value. For non-variable operands, "exists" = the evaluated value is non-null. +- `is empty` → true if the value is null, an empty/whitespace string, or an empty collection; a missing variable is also empty. +- `is not empty` → negation of `is empty`. + +- [ ] **Step 1: Write the failing test** + +Create `TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class ExistenceOperatorsTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), DefaultConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void Exists_PresentVariable_IsTrue() + => Assert.True(Eval("Notes exists", new() { ["Notes"] = "x" })); + + [Fact] + public void Exists_MissingVariable_IsFalse() + => Assert.False(Eval("Notes exists", new())); + + [Fact] + public void IsEmpty_EmptyString_IsTrue() + => Assert.True(Eval("Notes is empty", new() { ["Notes"] = " " })); + + [Fact] + public void IsEmpty_EmptyCollection_IsTrue() + => Assert.True(Eval("Items is empty", new() { ["Items"] = new List() })); + + [Fact] + public void IsEmpty_MissingVariable_IsTrue() + => Assert.True(Eval("Notes is empty", new())); + + [Fact] + public void IsNotEmpty_NonEmpty_IsTrue() + => Assert.True(Eval("Notes is not empty", new() { ["Notes"] = "x" })); + + [Fact] + public void PresentButNull_ExistsFalse_IsEmptyTrue() + { + var data = new Dictionary { ["Notes"] = null }; + var ctx = new GlobalEvaluationContext(data!); + ConditionNode existsNode = Build("Notes exists"); + ConditionNode emptyNode = Build("Notes is empty"); + // 'Notes' resolves (key present) → exists true; value null → is empty true. + Assert.True(new ConditionEvaluatorCore(ctx, DefaultConditionDialect.Instance).EvaluateBool(existsNode)); + Assert.True(new ConditionEvaluatorCore(ctx, DefaultConditionDialect.Instance).EvaluateBool(emptyNode)); + } + + private static ConditionNode Build(string expr) + => new ConditionParser(ConditionOperatorRegistry.Shared).Parse(new ConditionLexer().Tokenize(expr)); +} +``` + +> Note on the last test: confirm `GlobalEvaluationContext.TryResolveVariable` returns `true` for a present key whose value is `null`. If it returns `false` for null values, adjust the test's `exists` expectation to match actual context behavior and document it — the context's resolution contract wins over the spec's ideal. Check `GlobalEvaluationContext` before finalizing. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ExistenceOperatorsTests"` +Expected: FAIL — postfix operators not registered → parse throws. + +- [ ] **Step 3: Write minimal implementation** + +Create `TriasDev.Templify/Conditionals/Engine/Operators/ExistenceOperators.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Collections; + +namespace TriasDev.Templify.Conditionals.Engine.Operators; + +/// Postfix Foo exists: variable is present in the context, regardless of value. +internal sealed class ExistsOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "exists" }; + public int Precedence => 5; + public OperatorFixity Fixity => OperatorFixity.Postfix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + { + if (operands[0] is VariableNode variable) + { + return core.TryResolveVariable(variable.Path, out _); + } + return core.EvaluateValue(operands[0]) != null; + } +} + +/// Postfix Foo is empty: null / empty-or-whitespace string / empty collection (missing = empty). +internal sealed class IsEmptyOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "is", "empty" }; + public int Precedence => 5; + public OperatorFixity Fixity => OperatorFixity.Postfix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => IsEmptyValue(core.EvaluateValue(operands[0])); + + internal static bool IsEmptyValue(object? value) + { + if (value == null) { return true; } + if (value is string s) { return string.IsNullOrWhiteSpace(s); } + if (value is ICollection c) { return c.Count == 0; } + return false; + } +} + +/// Postfix Foo is not empty: negation of is empty. +internal sealed class IsNotEmptyOperator : IConditionOperator +{ + public IReadOnlyList Tokens { get; } = new[] { "is", "not", "empty" }; + public int Precedence => 5; + public OperatorFixity Fixity => OperatorFixity.Postfix; + + public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList operands) + => !IsEmptyOperator.IsEmptyValue(core.EvaluateValue(operands[0])); +} +``` + +Register in `ConditionOperatorRegistry.CreateDefault()` (after the string operators): + +```csharp + r.Register(new ExistsOperator()); + r.Register(new IsEmptyOperator()); + r.Register(new IsNotEmptyOperator()); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~ExistenceOperatorsTests"` +Expected: PASS (7 passed). + +- [ ] **Step 5: Extend `Validate` to accept the postfix keywords** + +In `TriasDev.Templify/Conditionals/ConditionalEvaluator.cs`, so that `Foo exists` / `Foo is empty` validate as valid, treat `exists`, `is`, `empty` as recognized operator words in `Validate`. In the classification loop (`ConditionalEvaluator.cs:93-156`), before the `IsComparisonOperator` check, add a branch that classifies these three words (case-insensitive) as `currentType = "comparison"` (so they act as operators between/after operands without triggering `ConsecutiveOperands`). Concretely, add near the top of the per-token classification: + +```csharp + else if (string.Equals(token, "exists", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "empty", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "is", StringComparison.OrdinalIgnoreCase)) + { + currentType = "comparison"; + } +``` + +> This is a heuristic relaxation, not full postfix validation. It prevents false-positive validation errors for the new syntax. No existing `ConditionValidationTests` case uses these words, so all 28 remain green. + +- [ ] **Step 6: Run the full suite** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj` +Expected: PASS — all existing + all new engine tests. + +- [ ] **Step 7: Commit** + +```bash +git add TriasDev.Templify/Conditionals/Engine/Operators/ExistenceOperators.cs TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs TriasDev.Templify/Conditionals/ConditionalEvaluator.cs TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs +git commit -m "feat(conditionals): add exists/is empty/is not empty operators (#128)" +``` + +--- + +## Task 9: Migrate inline `{{(...)}}` to the unified engine (Inline dialect); remove old `Expressions/` + +**Files:** +- Modify: `TriasDev.Templify/Visitors/PlaceholderVisitor.cs:72-111` +- Delete: `TriasDev.Templify/Expressions/BooleanExpressionParser.cs`, `TriasDev.Templify/Expressions/BooleanExpression.cs`, `TriasDev.Templify/Expressions/EvaluationContextAdapter.cs` +- Delete: `TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs` +- Create: `TriasDev.Templify.Tests/Engine/InlineDialectTests.cs` (re-covers the inline behavior the deleted tests asserted) + +**Interfaces:** +- Consumes: `ConditionLexer`, `ConditionParser`, `ConditionOperatorRegistry`, `ConditionEvaluatorCore`, `InlineConditionDialect`, `ConditionParseException`. + +> Inline expressions arrive **with** the outer parentheses (e.g. the placeholder text is `(var1 and var2)`), which the old parser required. The unified parser handles those parentheses as grouping — no stripping needed. The old parser returned `null` for a bare variable (no leading `(`); inline placeholders always start with `(` (that's how `PlaceholderMatch.IsExpression` is set), so this path only sees parenthesized text. + +- [ ] **Step 1: Write the failing test (new inline coverage)** + +Create `TriasDev.Templify.Tests/Engine/InlineDialectTests.cs`: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals.Engine; +using TriasDev.Templify.Core; + +namespace TriasDev.Templify.Tests.Engine; + +public class InlineDialectTests +{ + private static bool Eval(string expr, Dictionary data) + { + var tokens = new ConditionLexer().Tokenize(expr); + ConditionNode node = new ConditionParser(ConditionOperatorRegistry.Shared).Parse(tokens); + return new ConditionEvaluatorCore(new GlobalEvaluationContext(data), InlineConditionDialect.Instance).EvaluateBool(node); + } + + [Fact] + public void And_TwoBooleans() + => Assert.True(Eval("(var1 and var2)", new() { ["var1"] = true, ["var2"] = true })); + + [Fact] + public void Or_TwoBooleans() + => Assert.True(Eval("(var1 or var2)", new() { ["var1"] = false, ["var2"] = true })); + + [Fact] + public void Not_Boolean() + => Assert.True(Eval("(not IsActive)", new() { ["IsActive"] = false })); + + [Fact] + public void Comparison_Numeric() + => Assert.True(Eval("(Count > 0)", new() { ["Count"] = 5 })); + + [Fact] + public void Nested_Grouping() + => Assert.True(Eval("((var1 or var2) and var3)", new() { ["var1"] = false, ["var2"] = true, ["var3"] = true })); + + [Fact] + public void BareStringVariable_IsFalse_InInlineDialect() + => Assert.False(Eval("(Name)", new() { ["Name"] = "Alice" })); // Inline truthiness: only bool true is true + + [Fact] + public void NewOperators_WorkInInlineDialect() + => Assert.True(Eval("(Status in (\"A\", \"B\"))", new() { ["Status"] = "B" })); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~InlineDialectTests"` +Expected: PASS already (engine exists) — this test defines the target inline behavior and should pass immediately. If any case fails, fix the engine/dialect before proceeding. (This step verifies parity before we delete the old engine.) + +- [ ] **Step 3: Rewire `PlaceholderVisitor`** + +In `TriasDev.Templify/Visitors/PlaceholderVisitor.cs`, replace the expression branch (`:72-111`) so it uses the unified engine with the Inline dialect. Replace the body inside `if (placeholder.IsExpression)`: + +```csharp + if (placeholder.IsExpression) + { + try + { + IReadOnlyList tokens = + new Conditionals.Engine.ConditionLexer().Tokenize(placeholder.VariableName); + Conditionals.Engine.ConditionNode node = + new Conditionals.Engine.ConditionParser(Conditionals.Engine.ConditionOperatorRegistry.Shared).Parse(tokens); + bool result = new Conditionals.Engine.ConditionEvaluatorCore(context, Conditionals.Engine.InlineConditionDialect.Instance) + .EvaluateBool(node); + value = result; + resolved = true; + } + catch (Conditionals.Engine.ConditionParseException) + { + _warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, "Failed to parse expression")); + resolved = false; + value = null; + } + } +``` + +Remove the now-unused `using TriasDev.Templify.Expressions;` if present. + +- [ ] **Step 4: Delete the old engine and its test** + +```bash +git rm TriasDev.Templify/Expressions/BooleanExpressionParser.cs \ + TriasDev.Templify/Expressions/BooleanExpression.cs \ + TriasDev.Templify/Expressions/EvaluationContextAdapter.cs \ + TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs +``` + +- [ ] **Step 5: Run the full suite** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj` +Expected: PASS — all remaining existing tests (including inline-expression integration/replacement tests that exercise `{{(...)}}`) plus new engine tests. The deleted `BooleanExpressionParserTests` (12) are replaced by `InlineDialectTests`. + +> If an integration test asserting inline `{{(...)}}` output fails, the Inline dialect diverges from the old `BooleanExpression` semantics. Fix `InlineConditionDialect` (truthiness / `IComparable` compare), not the test. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(conditionals): migrate inline {{(...)}} to unified engine, remove legacy Expressions (#128)" +``` + +--- + +## Task 10: End-to-end integration tests through the public template + condition APIs + +**Files:** +- Test: `TriasDev.Templify.Tests/Integration/NewOperatorsIntegrationTests.cs` + +**Interfaces:** +- Consumes: public `ConditionEvaluator` / `IConditionEvaluator` (standalone), and (optionally) the document template processor via existing integration-test helpers in `TriasDev.Templify.Tests/Helpers/`. + +- [ ] **Step 1: Inspect the existing integration/helpers pattern** + +Run: `ls TriasDev.Templify.Tests/Helpers TriasDev.Templify.Tests/Integration` +Read one existing conditional integration test to copy the document-building helper usage (e.g. how a `{{#if}}` template is built and processed). Use the same helper(s) in the new test. + +- [ ] **Step 2: Write the failing test** + +Create `TriasDev.Templify.Tests/Integration/NewOperatorsIntegrationTests.cs`. The standalone-API portion needs no document helper: + +```csharp +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using TriasDev.Templify.Conditionals; + +namespace TriasDev.Templify.Tests.Integration; + +public class NewOperatorsIntegrationTests +{ + private readonly IConditionEvaluator _evaluator = new ConditionEvaluator(); + + [Fact] + public void StandaloneApi_In_ListLiteral() + => Assert.True(_evaluator.Evaluate("Status in (\"Active\", \"Pending\")", + new Dictionary { ["Status"] = "Active" })); + + [Fact] + public void StandaloneApi_Contains() + => Assert.True(_evaluator.Evaluate("Email contains \"@trias\"", + new Dictionary { ["Email"] = "a@trias.dev" })); + + [Fact] + public void StandaloneApi_Exists_And_IsEmpty() + { + var data = new Dictionary { ["Name"] = "Alice" }; + Assert.True(_evaluator.Evaluate("Name exists", data)); + Assert.True(_evaluator.Evaluate("Missing is empty", data)); + } + + [Fact] + public void StandaloneApi_Grouping_And_Precedence() + { + var data = new Dictionary { ["A"] = false, ["B"] = true, ["C"] = false }; + Assert.True(_evaluator.Evaluate("A or B and C = false", data)); // and binds tighter than or + Assert.False(_evaluator.Evaluate("(A or B) and C", data)); + } + + [Fact] + public void StandaloneApi_NotIn() + => Assert.True(_evaluator.Evaluate("not Role in Roles", + new Dictionary { ["Role"] = "Guest", ["Roles"] = new List { "Admin", "User" } })); +} +``` + +- [ ] **Step 3: Run test to verify it fails/passes** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj --filter "FullyQualifiedName~NewOperatorsIntegrationTests"` +Expected: PASS (the engine is complete). If `StandaloneApi_Grouping_And_Precedence`'s first assertion is ambiguous, simplify it to an unambiguous grouping case rather than changing engine behavior. + +- [ ] **Step 4: Add a document-level `{{#if}}` test** + +Using the helper discovered in Step 1, add one test that processes a Word template containing `{{#if Role in Roles}}VISIBLE{{/if}}` with data where `Role` is in `Roles`, and asserts the output contains `VISIBLE`. (Copy the exact helper call pattern from the existing conditional integration test; do not invent a new helper.) + +- [ ] **Step 5: Run the full suite** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add TriasDev.Templify.Tests/Integration/NewOperatorsIntegrationTests.cs +git commit -m "test(conditionals): end-to-end tests for new operators (#128)" +``` + +--- + +## Task 11: Documentation (GitHub Pages, `docs/`) + +**Files:** +- Modify: `docs/for-template-authors/conditionals.md` +- Modify: `docs/for-template-authors/boolean-expressions.md` +- Modify: `docs/for-developers/condition-evaluation.md` +- Modify: `docs/for-template-authors/best-practices.md`, `docs/for-template-authors/template-syntax.md`, `docs/FAQ.md` +- Modify: `TriasDev.Templify/Conditionals/IConditionEvaluator.cs` (XML doc operator list) + +- [ ] **Step 1: Update the standalone API docs + XML doc** + +In `docs/for-developers/condition-evaluation.md`, add an "Operators" reference section listing all operators with one example each: `=`/`==`, `!=`, `>`/`<`/`>=`/`<=`, `and`, `or`, `not`, `in` (with the three RHS forms), `contains`, `startswith`, `endswith`, `exists`, `is empty`, `is not empty`, and parentheses for grouping. State that string operators and `in` element equality are case-sensitive, and that `and` binds tighter than `or`. + +In `TriasDev.Templify/Conditionals/IConditionEvaluator.cs`, update the `Supported operators: ...` line (`:17`) to include the new operators: + +```csharp + /// Supported operators: =, ==, !=, >, <, >=, <=, and, or, not, + /// in, contains, startswith, endswith, exists, is empty, is not empty, and parentheses for grouping. +``` + +- [ ] **Step 2: Update template-author conditionals docs** + +In `docs/for-template-authors/conditionals.md`, add subsections with `{{#if}}` examples for: membership (`{{#if Role in Roles}}`, `{{#if Status in ("Active","Pending")}}`, negation `{{#if not Role in Roles}}`), string checks (`contains`/`startswith`/`endswith`), existence (`{{#if Notes exists}}`, `{{#if Notes is empty}}`, `{{#if Notes is not empty}}`), and grouping with parentheses. Note the precedence rule (`and` before `or`; use parentheses to override). + +- [ ] **Step 3: Update inline boolean-expressions docs** + +In `docs/for-template-authors/boolean-expressions.md`, add the new operators to the operator list (currently `:917-919`) and add inline `{{(...)}}` examples using `in`/`contains`/`exists`. Confirm the precedence section (`:896-908`) matches the unified rule (`and` tighter than `or`, parentheses highest). + +- [ ] **Step 4: Touch-ups** + +In `docs/for-template-authors/best-practices.md`, `docs/for-template-authors/template-syntax.md`, and `docs/FAQ.md`, update any place that enumerates the operator set or says operators are limited, to include the new operators. (Search each file for `and`, `or`, `not`, operator lists.) + +- [ ] **Step 5: Verify build is unaffected and commit** + +Run: `dotnet build templify.sln` +Expected: build succeeds (XML doc change compiles). + +```bash +git add docs/ TriasDev.Templify/Conditionals/IConditionEvaluator.cs +git commit -m "docs(conditionals): document new operators and grouping (#128)" +``` + +--- + +## Task 12: Final verification, formatting, PR + +**Files:** none (verification only). + +- [ ] **Step 1: Full test suite** + +Run: `dotnet test TriasDev.Templify.Tests/TriasDev.Templify.Tests.csproj` +Expected: PASS — all existing tests (unchanged) + all new tests. + +- [ ] **Step 2: Formatting gate** + +Run: `dotnet format --verify-no-changes --no-restore` +Expected: no changes. If it reports issues: `dotnet format --no-restore`, then re-run the test suite, then commit the formatting fixes: + +```bash +git add -A +git commit -m "style: apply dotnet format (#128)" +``` + +- [ ] **Step 3: Confirm the legacy engine is gone** + +Run: `grep -rn "BooleanExpressionParser\|EvaluationContextAdapter\|TriasDev.Templify.Expressions" TriasDev.Templify TriasDev.Templify.Tests --include='*.cs'` +Expected: no matches (all references removed). + +- [ ] **Step 4: Push and open PR** + +```bash +git push -u origin feat/128-extensible-condition-operators +gh pr create --repo TriasDev/templify --fill --base main +``` + +Link the PR to issue #128 (title/body reference `#128`). + +--- + +## Self-Review + +**Spec coverage:** +- Unified engine (lexer→parser→AST→registry→evaluator) → Tasks 1-4. ✓ +- Dialects (Default + Inline) preserving truthiness/comparison → Task 3 (`ConditionDialect`), used in Tasks 5 (Default) and 9 (Inline). ✓ +- Uniform `and > or` precedence + grouping → Task 4 (`ConditionParserTests` asserts both). ✓ +- `in` (+negation, 3 RHS forms) → Task 6. ✓ +- `contains`/`startswith`/`endswith` (case-sensitive) → Task 7. ✓ +- `exists`/`is empty`/`is not empty` (postfix, present-but-null semantics) → Task 8. ✓ +- External compatibility (existing tests unchanged) → Task 5 (Default gate) + Task 9 (Inline gate) + Task 12. ✓ +- Remove `Expressions/` engine → Task 9. ✓ +- Validation preserved (heuristic extended, all 28 tests green) → Tasks 5 & 8. ✓ (Deviation from spec §8: heuristic-extension instead of parser-based validation, chosen to eliminate compatibility risk — noted in plan intro.) +- Public API signatures unchanged → Task 5 (facade). ✓ +- Docs (GitHub Pages) → Task 11. ✓ + +**Placeholder scan:** No TBD/TODO/"add error handling"/"similar to Task N" — each code step contains full code. ✓ + +**Type consistency:** `ConditionNode`/`LiteralNode`/`VariableNode`/`ListNode`/`OperatorNode`, `IConditionOperator` (`Tokens`/`Precedence`/`Fixity`/`Evaluate`), `OperatorFixity`, `ConditionDialect`+`DefaultConditionDialect.Instance`/`InlineConditionDialect.Instance` (`ToBool`/`AreEqual`/`TryCompare`), `ConditionEvaluatorCore` (`EvaluateBool`/`EvaluateValue`/`TryResolveVariable`/`Dialect`), `ConditionOperatorRegistry.Shared` (`FindInfix`/`FindPrefix`/`PostfixOperators`/`IsKnownOperatorToken`/`Register`), `ConditionLexer.Tokenize`, `ConditionParser.Parse`+`ConditionParseException` — names used consistently across Tasks 1-10. ✓ + +**Known deviations flagged for the implementer:** +1. Validation stays heuristic (not parser-based) — spec §8 refinement, lower risk. +2. `exists` on a present-but-null variable depends on `GlobalEvaluationContext.TryResolveVariable` returning `true` for null values — Task 8 Step 1 instructs verifying this and adjusting the test to match actual context behavior if needed. diff --git a/docs/superpowers/specs/2026-07-20-extensible-condition-operators-design.md b/docs/superpowers/specs/2026-07-20-extensible-condition-operators-design.md new file mode 100644 index 0000000..430ca78 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-extensible-condition-operators-design.md @@ -0,0 +1,160 @@ +# Design: Unified Extensible Condition Engine + New Operators + +- **Issue:** [#128](https://github.com/TriasDev/templify/issues/128) +- **Date:** 2026-07-20 +- **Status:** Approved (design direction), pending final spec review + implementation plan + +## 1. Goal & Compatibility Principle + +Unify the two existing condition/boolean engines into **one** extensible engine (**lexer → parser → AST → operator registry → evaluator**) and add: + +- membership operator `in` (with negation via `not`), +- string operators `contains`, `startswith`, `endswith`, +- existence/emptiness operators `exists`, `is empty`, `is not empty`, +- **parentheses for grouping** (available uniformly). + +**Hard compatibility requirement (external):** every already-authored template and every already-stored condition keeps producing the same result. The **internal implementation may be replaced entirely.** The existing test suites (conditionals, boolean-expressions, integration) are the compatibility oracle — they must pass unchanged. + +The engine is consumed both by the template engine (`{{#if ...}}`, inline `{{(...)}}`, text templates) and by a standalone public API (`IConditionEvaluator`, e.g. in ViasPro). + +## 2. Current State (why unification) + +Two independent engines exist today with **different semantics**: + +| Aspect | `ConditionalEvaluator` (`Conditionals/`) | `BooleanExpressionParser` + `BooleanExpression` (`Expressions/`) | +|---|---|---| +| Consumers | `{{#if}}` (`ConditionalVisitor`), text templates (`TextTemplateProcessor`), **public `IConditionEvaluator`/`ConditionContext`** | inline expression placeholder `{{(...)}}` (`PlaceholderVisitor`), rendered via boolean formatters (checkbox/yes-no) | +| Structure | flat left-to-right token loop, **no parentheses** | recursive-descent, **AST, parentheses supported** | +| `and`/`or` precedence | **equal**, left-associative | **standard `and > or`** | +| Truthiness | rich (`EvaluateValue`: strings/ints/collections/"true"/"1"…) | weak (bare variable true only if `bool true`) | +| Comparison | `double.Parse` for `>`/`<`; `ToString` equality (bool case-insensitive) | `IComparable.CompareTo`; `object.Equals` | +| Context | `IEvaluationContext` | `IDataContext` via `EvaluationContextAdapter` | +| Validation | heuristic `Validate()` with `ConditionValidationIssueType` | none (parse failure → treated as plain variable) | + +These differences are **observable externally**, so they must be preserved. Unification therefore shares *structure* (parsing, AST, operator registry) while keeping *semantics* selectable per entry point (see Dialects). + +All `Expressions/` types (`BooleanExpressionParser`, `BooleanExpression` and subclasses, `ComparisonOperator`, `IDataContext`, `EvaluationContextAdapter`) are `internal` and can be removed once their call site is migrated. + +## 3. Unified Architecture + +New namespace `Conditionals/Engine/` (final name TBD in plan): + +- **`ConditionLexer`** — expression string → tokens: variable paths, string/number/bool/null literals, operator tokens, `(`, `)`, `,`. Quote normalization (curly → ASCII) lives here. +- **AST** (`ConditionNode`): `LiteralNode`, `VariableNode`, `ListNode`, `BinaryNode`, `UnaryNode`. +- **`ConditionParser`** — Pratt / precedence-climbing parser. Operator precedence, fixity and arity come from the **operator registry** (single, uniform precedence — see §5). Supports parenthesized grouping and list literals. +- **`IConditionOperator` + `ConditionOperatorRegistry`** — each operator = token(s), precedence, fixity (prefix/infix/postfix), `Evaluate`. **Adding an operator = registering one class; the parser is not touched.** +- **`ConditionEvaluatorCore`** — walks the AST, resolving variables via `IEvaluationContext`, applying the dialect's value policy. +- **`ConditionDialect`** — the per-entry-point value policy: truthiness rule and base comparison rule (precedence is uniform, not dialect-specific). Two instances (see §4). + +Entry points become thin facades over the core: +- `ConditionalEvaluator` (internal facade) → **Default** dialect. Keeps public `ConditionEvaluator`/`IConditionEvaluator`/`ConditionContext` signatures unchanged; used by `ConditionalVisitor` and `TextTemplateProcessor`. +- inline `{{(...)}}` in `PlaceholderVisitor` → **Inline** dialect, replacing `BooleanExpressionParser`. + +### Unit boundaries + +| Unit | Responsibility | Depends on | +|---|---|---| +| `ConditionLexer` | string → tokens | — | +| `ConditionParser` | tokens → AST | registry | +| `ConditionOperatorRegistry` | token → operator metadata + eval | `IConditionOperator` impls | +| `IConditionOperator` (per op) | one operator's parse metadata + evaluation | dialect value policy | +| `ConditionEvaluatorCore` | AST → bool | `IEvaluationContext`, dialect | +| `ConditionDialect` | truthiness + base comparison policy | — | +| facades | orchestrate lex→parse→eval per entry point | all of the above | + +## 4. Dialects (how both behaviors are preserved) + +One shared engine, two dialects differing only in semantic policy: + +**Precedence is now unified** to the standard `and > or` for all entry points (see §5). This was confirmed safe: no existing test in either suite (conditionals or boolean-expressions) asserts a mixed `and`/`or` precedence, and no known template relies on it. This removes precedence as a per-dialect concern. + +Dialects therefore differ only in **truthiness** and **comparison**, which are externally observable and relied upon: + +**Default dialect** — used by `{{#if}}`, text templates, standalone `IConditionEvaluator`. +- Truthiness: current `EvaluateValue` rules (rich: non-empty strings/collections, `"true"`/`"1"`, non-zero ints…). +- Comparison: current `double.Parse` / `ToString` rules. + +**Inline dialect** — used by `{{(...)}}` placeholders. +- Truthiness: bare variable true only if `bool true` (current behavior). +- Comparison: `IComparable.CompareTo` / `object.Equals` (current behavior). + +New operators (`in`, `contains`, `startswith`, `endswith`, `exists`, `is empty`, grouping) are registered **once** and available in **both** dialects. Their own comparison sub-semantics (e.g. `in` element equality, string ops case-sensitivity) are defined by the operator and are identical across dialects (see §6); dialects only govern truthiness and the base comparison rule above. + +> Note: dialects preserve today's truthiness/comparison divergences rather than converge them (that would change externally observable output). Converging truthiness is a separate, later decision. + +## 5. Precedence & Grouping + +Single precedence table for all entry points (binds looser → tighter): + +| Level | Operators | Fixity / associativity | +|---|---|---| +| 1 (loosest) | `or` | infix, left-assoc | +| 2 | `and` | infix, left-assoc | +| 3 | `not` | prefix | +| 4 | `=` `==` `!=` `>` `<` `>=` `<=` `in` `contains` `startswith` `endswith` | infix | +| 5 | `exists` `is empty` `is not empty` | postfix | +| 6 (tightest) | `( ... )` grouping, `( a, b, ... )` list literal | — | + +`and` binds tighter than `or` (standard). Because comparison/membership (level 4) bind tighter than `not` (level 3), `not Status in Roles` parses as `not (Status in Roles)`, reading naturally. Parentheses provide explicit grouping: `(A or B) and C`. + +## 6. Operator Catalog (shared across dialects) + +### Membership +- **`in`** — `scalar in `. RHS forms: (1) variable resolving to `ICollection`/array; (2) list literal `("Active", "Pending")`; (3) comma-separated string `"Active,Pending"`. Element comparison reuses the Default engine's `AreEqual` semantics (strings case-sensitive; booleans case-insensitive). `true` if the scalar equals any element. +- **Negation** — via `not`: `not Status in Roles`. `not in` sugar is out of scope. + +### String operators (case-sensitive, ordinal — consistent with `=`) +- **`contains`** — `string contains substring` (operand direction opposite of `in`). +- **`startswith`** — `string startswith prefix`. +- **`endswith`** — `string endswith suffix`. + +### Existence / emptiness (postfix) +- **`Foo exists`** — variable present in context (`TryResolveVariable == true`), regardless of value. Closes the current gap where a missing variable and a `false`/empty value are indistinguishable. +- **`Foo is empty`** — resolved value is `null`, empty/whitespace string, or empty collection; a **missing variable is also `is empty = true`**. +- **`Foo is not empty`** — negation of `is empty`. + +For a present-but-null variable: `exists == true` and `is empty == true` (coherent and explicit). + +### Grouping & list literals +- `( expr )` — grouping. `( a, b, c )` — list literal, valid as RHS of `in`. Parser distinguishes by commas: `(expr)` is grouping, `(expr, expr, …)` is a list. + +## 7. Call-site Migration + +1. `ConditionalVisitor` — no change (still calls `ConditionalEvaluator` facade / Default dialect). +2. `TextTemplateProcessor` — no change (Default dialect facade). +3. Public `ConditionEvaluator` / `ConditionContext` — signatures unchanged (Default dialect); new operators available automatically. +4. `PlaceholderVisitor` inline `{{(...)}}` — switch from `BooleanExpressionParser` to the unified engine with the **Inline** dialect. Expression detection (placeholder starts with `(`) stays. +5. Remove `Expressions/` engine types once (4) is migrated. + +## 8. Validation + +Parser-based validation replaces the heuristic, plus a **mapping layer** preserving existing `ConditionValidationIssueType` outcomes for existing cases. If an existing validation test yields a legitimately improved but different result, it is surfaced for a human decision rather than changed silently. + +## 9. Public API Impact + +- `IConditionEvaluator` / `ConditionEvaluator` / `IConditionContext` / `ConditionContext` — signatures unchanged; new operators available. +- `ConditionOperatorRegistry` / `ConditionDialect` — internal for now; the architecture allows exposing user-defined operators later (out of scope). +- Template author syntax: `{{#if}}` and `{{(...)}}` unchanged, gain the new operators + grouping (grouping already existed for `{{(...)}}`). + +## 10. Testing Strategy + +- **Characterization gate:** the entire existing test suite (conditionals + boolean-expressions + integration) passes without edits. This proves both dialects preserve external behavior. +- **Lexer:** tokenization, quote normalization, multi-word tokens (`is empty`, `is not empty`). +- **Parser:** precedence (`and > or`, uniform), parenthesized grouping, list literals, associativity. +- **Per operator:** `in` in all three RHS forms + negation; `contains`/`startswith`/`endswith`; `exists`/`is empty`/`is not empty` — tested in both dialects. +- **Edge cases:** `in` with non-collection RHS, empty collection, `null` LHS; string operators with non-string operand; `exists`/`is empty` on nested property paths. + +## 11. Documentation (GitHub Pages, `docs/`) + +Update in the same PR: +- `docs/for-template-authors/conditionals.md` — new operators, grouping, `exists`/`is empty`. +- `docs/for-template-authors/boolean-expressions.md` — new operators in inline `{{(...)}}`; note unified operator set. +- `docs/for-developers/condition-evaluation.md` — standalone API operator reference. +- `docs/for-template-authors/best-practices.md`, `docs/for-template-authors/template-syntax.md`, `docs/FAQ.md` — touch where operator lists / capabilities appear. + +## 12. Out of Scope + +- Converging the two dialects' truthiness into one behavior (would change external output). Precedence is already unified. +- Collection size comparison in conditions (`Items > 0`) — separate concern. +- Regex `matches`, range `between` — deferred (YAGNI). +- Public user-defined operator registration — architecture supports it, not implemented now.