Skip to content

feat(conditionals): extensible operator engine + in/string/existence operators + grouping (#128)#129

Merged
vaceslav merged 20 commits into
mainfrom
feat/128-extensible-condition-operators
Jul 21, 2026
Merged

feat(conditionals): extensible operator engine + in/string/existence operators + grouping (#128)#129
vaceslav merged 20 commits into
mainfrom
feat/128-extensible-condition-operators

Conversation

@vaceslav

Copy link
Copy Markdown
Contributor

Closes #128.

What & why

Replaces the two separate condition engines (Conditionals/ConditionalEvaluator for {{#if}}/text/standalone, and Expressions/BooleanExpressionParser for inline {{(...)}}) with one extensible engine — lexer → Pratt parser → AST → operator registry → evaluator — under TriasDev.Templify/Conditionals/Engine/. Adding a future operator now means registering a single IConditionOperator; the parser is never touched.

New operators & syntax

  • Membership: in (Role in Roles, Status in ("Active","Pending"), Status in "A,B"); negation via not (not Role in Roles).
  • String: contains, startswith, endswith (case-sensitive / ordinal, consistent with =).
  • Existence: exists, is empty, is not empty (postfix). exists = variable present regardless of value; is empty = null / empty-or-whitespace string / empty collection (missing var is also empty).
  • Grouping: parentheses (A or B) and C.

All new operators work in both {{#if}} conditionals and inline {{(...)}} expressions.

Architecture

  • Uniform precedence: and binds tighter than or; parentheses override.
  • A ConditionDialect supplies the two externally-observable semantic profiles: Default ({{#if}}, text templates, standalone IConditionEvaluator — rich truthiness, numeric comparison) and Inline ({{(...)}} — bool-only truthiness, IComparable comparison). New-operator element equality is dialect-independent (ordinal, via ConditionValueOps).
  • Public API signatures unchanged (IConditionEvaluator / ConditionEvaluator / IConditionContext / ConditionContext).
  • Validate() is now parser-based (spec §8) with pre-checks for empty/unbalanced-quotes and a mapping layer that preserves the existing typed validation issues.
  • Legacy Expressions/ engine removed.

Compatibility

External compatibility was the hard requirement. The entire pre-existing test suite passes unchanged (the only removed test file, BooleanExpressionParserTests, is re-covered by new InlineDialectTests). All 28 ConditionValidationTests pass unedited.

A few narrow, previously-untested edge behaviors changed and were explicitly accepted and locked with characterization tests + a docs note ("Reserved words & literal quoting"):

  • Inline comparison now resolves both operands as variable-or-literal ({{(A = B)}} compares two variables).
  • Inline >=/<= on incomparable operands → false (was true).
  • The new operator keywords are reserved; string literals must be quoted (= "empty", not = empty).

Testing & docs

  • Full suite: 1175 passing, 0 failing; dotnet format clean.
  • Added lexer/parser/registry/dialect/operator unit tests, inline-dialect parity tests, end-to-end tests through the public API and the document pipeline, and characterization tests for the accepted edges.
  • Docs updated (GitHub Pages): for-template-authors/conditionals.md, boolean-expressions.md, template-syntax.md, FAQ.md, for-developers/condition-evaluation.md, and the IConditionEvaluator XML docs.

Design spec: docs/superpowers/specs/2026-07-20-extensible-condition-operators-design.md · Plan: docs/superpowers/plans/2026-07-20-extensible-condition-operators.md

🤖 Built with subagent-driven development (fresh implementer + spec/quality review per task, plus a final whole-branch review).

vaceslav added 18 commits July 21, 2026 09:19
…ne (#128)

Rewire the internal ConditionalEvaluator facade to delegate Evaluate(string,
IEvaluationContext) to the new condition-expression engine (lexer, Pratt
parser, ConditionEvaluatorCore) using the Default dialect, instead of its own
flat-token evaluator. Public signatures and behavior are unchanged.

Also fixes two engine gaps surfaced by the full compatibility-gate test run:
- ConditionEvaluatorCore now falls back to a variable's literal path text when
  it is used as a comparison operand and does not resolve (e.g. the bareword
  `Active` in `Status = Active`), matching the legacy evaluator's
  ResolveValueOrLiteral behavior, while leaving standalone/logical-operator
  truthiness checks strict (missing variable still evaluates to false).
- ConditionLexer now accepts `@` as an identifier character so loop-metadata
  tokens (@FIRST, @last, @index, @count) tokenize correctly instead of being
  rejected as an unknown operator.

Validate's IsComparisonOperator now also recognizes the new infix
word-operators (in, contains, startswith, endswith) so they aren't flagged as
UnknownOperator.
…tor check (#128)

Validate() classified exists/is/empty as "comparison", so an expression ending
in one of these keywords tripped the trailing-operator MissingOperand check.
Give them their own "postfix" classification instead, which the existing
structural checks already treat as neither comparison nor logical.
… edges (#128)

- F1: Validate now parses via the engine (accepts in/contains/exists/... and
  the previously-broken 'in (...)' with spaces); keeps empty/unbalanced-quote
  pre-checks and falls back to the legacy heuristic for typed failure classes.
- F2/F3/F4: lock current post-migration behavior with characterization tests
  and add a 'Reserved words & literal quoting' note to the author/developer docs.
- F5: remove dead IsKnownOperatorToken / _allTokens from ConditionOperatorRegistry.

All 28 ConditionValidationTests pass unedited; full suite 1175 green; format clean.
@vaceslav
vaceslav force-pushed the feat/128-extensible-condition-operators branch from 2df6c2d to 2de83c9 Compare July 21, 2026 07:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors Templify’s conditional/boolean evaluation by replacing the legacy Expressions/ boolean-expression parser and the existing conditional evaluator internals with a unified lexer→parser→AST→operator-registry→evaluator engine under TriasDev.Templify/Conditionals/Engine/. It also extends the supported condition syntax with membership, string, existence/emptiness operators, and parenthesized grouping, and updates tests/docs accordingly.

Changes:

  • Introduces a new extensible condition engine (lexer, Pratt parser, AST, operator registry, dialect-aware evaluator) and migrates both {{#if ...}} and inline {{(...)}} evaluation to it.
  • Adds new operators: in, contains, startswith, endswith, exists, is empty, is not empty, plus grouping and list literals.
  • Updates public API docs + project documentation and adds broad unit/integration test coverage for the new engine and operators.

Reviewed changes

Copilot reviewed 41 out of 41 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
TriasDev.Templify/Visitors/PlaceholderVisitor.cs Migrates inline {{(...)}} evaluation to the new condition engine.
TriasDev.Templify/Expressions/EvaluationContextAdapter.cs Removes legacy inline-expression context adapter (engine removed).
TriasDev.Templify/Expressions/BooleanExpressionParser.cs Removes legacy inline-expression parser.
TriasDev.Templify/Expressions/BooleanExpression.cs Removes legacy expression AST/evaluator types.
TriasDev.Templify/Conditionals/IConditionEvaluator.cs Updates XML docs to include new operators.
TriasDev.Templify/Conditionals/Engine/Operators/StringOperators.cs Adds contains / startswith / endswith operators.
TriasDev.Templify/Conditionals/Engine/Operators/MembershipOperator.cs Adds in membership operator.
TriasDev.Templify/Conditionals/Engine/Operators/LogicalOperators.cs Adds logical operators with precedence for Pratt parsing.
TriasDev.Templify/Conditionals/Engine/Operators/ExistenceOperators.cs Adds postfix exists / is empty / is not empty operators.
TriasDev.Templify/Conditionals/Engine/Operators/ComparisonOperators.cs Implements =, !=, <, >, <=, >= operators using dialect comparison rules.
TriasDev.Templify/Conditionals/Engine/OperatorFixity.cs Defines prefix/infix/postfix operator fixity.
TriasDev.Templify/Conditionals/Engine/IConditionOperator.cs Defines operator contract for extensibility.
TriasDev.Templify/Conditionals/Engine/ConditionValueOps.cs Adds shared equality/string coercion helpers used by operators.
TriasDev.Templify/Conditionals/Engine/ConditionToken.cs Introduces token model for lexing.
TriasDev.Templify/Conditionals/Engine/ConditionParser.cs Adds Pratt parser and parse exception type.
TriasDev.Templify/Conditionals/Engine/ConditionOperatorRegistry.cs Central operator registration/lookup for parsing/evaluation.
TriasDev.Templify/Conditionals/Engine/ConditionNode.cs Defines AST node types (literal/variable/list/operator).
TriasDev.Templify/Conditionals/Engine/ConditionLexer.cs Adds lexer/tokenizer for condition expressions.
TriasDev.Templify/Conditionals/Engine/ConditionEvaluatorCore.cs Adds AST evaluator core + dialect integration.
TriasDev.Templify/Conditionals/Engine/ConditionDialect.cs Introduces Default vs Inline dialect semantic profiles.
TriasDev.Templify/Conditionals/ConditionalEvaluator.cs Rewires {{#if}}/standalone evaluation and validation to new engine.
TriasDev.Templify.Tests/Integration/NewOperatorsIntegrationTests.cs Adds end-to-end coverage for new operators via public API and document pipeline.
TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs Removes tests for the legacy inline expression parser (engine removed).
TriasDev.Templify.Tests/Engine/StringOperatorsTests.cs Adds unit tests for string operators.
TriasDev.Templify.Tests/Engine/NewSyntaxValidationTests.cs Adds tests ensuring Validate accepts new syntax.
TriasDev.Templify.Tests/Engine/MembershipOperatorTests.cs Adds unit tests for in operator forms and negation.
TriasDev.Templify.Tests/Engine/InlineDialectTests.cs Adds tests covering inline dialect behavior and parity.
TriasDev.Templify.Tests/Engine/ExistenceValidationTests.cs Adds validation tests for postfix existence/emptiness syntax.
TriasDev.Templify.Tests/Engine/ExistenceOperatorsTests.cs Adds unit tests for exists / is empty / is not empty semantics.
TriasDev.Templify.Tests/Engine/ConditionValueOpsTests.cs Adds tests for shared equality semantics.
TriasDev.Templify.Tests/Engine/ConditionParserTests.cs Adds tests for precedence/grouping + malformed parsing.
TriasDev.Templify.Tests/Engine/ConditionLexerTests.cs Adds tests for lexing behavior.
TriasDev.Templify.Tests/Engine/ConditionEvaluatorCoreTests.cs Adds tests for evaluator core and basic operators.
TriasDev.Templify.Tests/Engine/CompatEdgeCharacterizationTests.cs Adds characterization tests locking accepted edge-case behavior changes.
docs/superpowers/specs/2026-07-20-extensible-condition-operators-design.md Adds design spec for the unified engine and new operators.
docs/superpowers/plans/2026-07-20-extensible-condition-operators.md Adds implementation plan for the refactor and operator additions.
docs/for-template-authors/template-syntax.md Documents new operators + precedence for template authors.
docs/for-template-authors/conditionals.md Documents membership/string/existence operators and grouping for {{#if}}.
docs/for-template-authors/boolean-expressions.md Documents new operators, reserved words, and precedence for {{(...)}}.
docs/for-developers/condition-evaluation.md Documents new operator reference and reserved-word/literal rules for developers.
docs/FAQ.md Updates feature/operator list in FAQ.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 84 to 86
catch (Conditionals.Engine.ConditionParseException)
{
_warningCollector.AddWarning(ProcessingWarning.ExpressionFailed(placeholder.VariableName, "Failed to parse expression"));
Comment on lines +16 to +29
public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList<ConditionNode> 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;
}
Comment on lines +12 to +17
public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList<ConditionNode> operands)
{
string left = ConditionValueOps.ToStr(core.EvaluateValue(operands[0]));
string right = ConditionValueOps.ToStr(core.EvaluateValue(operands[1]));
return Test(left, right);
}
Comment on lines +6 to +10
/// <summary>
/// Dialect-independent value helpers shared by operators (equality, string coercion).
/// These preserve the historical <c>ConditionalEvaluator</c> equality semantics so that
/// <c>=</c>, <c>in</c>, and the string operators behave identically regardless of dialect.
/// </summary>
Comment on lines +6 to +22
internal sealed class OrOperator : IConditionOperator
{
public IReadOnlyList<string> Tokens { get; } = new[] { "or" };
public int Precedence => 1;
public OperatorFixity Fixity => OperatorFixity.Infix;
public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList<ConditionNode> operands)
=> core.EvaluateBool(operands[0]) || core.EvaluateBool(operands[1]);
}

internal sealed class AndOperator : IConditionOperator
{
public IReadOnlyList<string> Tokens { get; } = new[] { "and" };
public int Precedence => 2;
public OperatorFixity Fixity => OperatorFixity.Infix;
public bool Evaluate(ConditionEvaluatorCore core, IReadOnlyList<ConditionNode> operands)
=> core.EvaluateBool(operands[0]) && core.EvaluateBool(operands[1]);
}
…ine error handling (#128)

Addresses Copilot review findings on the condition engine (#129):
- Correct ConditionValueOps XML summary: it is dialect-independent and
  used by `in`/string operators and DefaultConditionDialect.AreEqual;
  it does NOT govern `=`/`!=` in the Inline dialect (object.Equals).
- Add ConditionEvaluatorCore.ResolveValueStrict: resolves a VariableNode
  via TryResolveVariable (null when missing) instead of the path-text
  literal fallback used by comparison operators.
- InOperator and the string operators (contains/startswith/endswith) now
  use ResolveValueStrict so a missing variable never becomes its own
  path name for matching; a missing `in` collection yields no candidates,
  and a missing string operand short-circuits to false.
- Dedup: IsEmptyOperator/IsNotEmptyOperator now use the shared
  ResolveValueStrict instead of a private ResolveOperandValue helper.
  ExistsOperator is unchanged (still needs presence, not value).
- PlaceholderVisitor: inline expression failures now include the actual
  exception message and also catch ArgumentException/
  InvalidOperationException/InvalidCastException as non-fatal warnings,
  restoring pre-migration resilience.

Adds TriasDev.Templify.Tests/Engine/StrictResolutionTests.cs (6 tests).
Full suite: 1181 passed (was 1175).
@vaceslav

Copy link
Copy Markdown
Contributor Author

Thanks for the review 🤖 — went through all five. Summary of what changed in b169de3:

Addressed in code:

  • PlaceholderVisitor error handling — the inline {{(...)}} branch now surfaces the actual exception message in the warning (instead of a canned string) and again catches runtime evaluation errors (ArgumentException/InvalidOperationException/InvalidCastException) as non-fatal warnings, restoring the pre-migration resilience. (Not a bare catch (Exception).)
  • in operator — operands are now resolved strictly (via a new ConditionEvaluatorCore.ResolveValueStrict): an unresolved variable yields null rather than its path text, and a null right-hand side produces an empty candidate set. So Status in Roles with a missing Roles (or missing Status) no longer matches spuriously.
  • String operators — same strict resolution; if either operand is a missing variable, the operator short-circuits to false (no more "…".Contains("")-style surprises).
  • ConditionValueOps XML summary — corrected to state it provides dialect-independent equality/coercion for the in/string operators and the Default dialect's AreEqual; it does not govern = in the Inline dialect (which uses object.Equals).

Added StrictResolutionTests covering the missing-variable cases. Full suite: 1181/1181, dotnet format clean.

On the and/or precedence comment — this one is intentional, not an oversight. The engine adopts standard and-binds-tighter-than-or uniformly. The original issue text mentioned keeping equal precedence, but that requirement was explicitly revisited and dropped by the maintainer after verifying that no existing test or template relies on mixed and/or precedence (confirmed by grep over the test suite and templates). The decision and rationale are recorded in the design spec (docs/superpowers/specs/2026-07-20-extensible-condition-operators-design.md, §5) and on issue #128. Parentheses are available to make grouping explicit where desired.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 42 out of 42 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

TriasDev.Templify/Conditionals/Engine/Operators/LogicalOperators.cs:19

  • and is given higher precedence than or (OrOperator.Precedence=1, AndOperator.Precedence=2), which makes A or B and C parse as A or (B and C). Issue #128 explicitly requires keeping and/or at equal precedence (left-to-right) to avoid silently changing semantics of already-stored conditions/templates; this precedence change therefore violates the linked acceptance criteria.
internal sealed class AndOperator : IConditionOperator
{
    public IReadOnlyList<string> Tokens { get; } = new[] { "and" };
    public int Precedence => 2;
    public OperatorFixity Fixity => OperatorFixity.Infix;

Comment on lines +37 to +51
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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6f7c816. The legacy inline parser did accept both quote styles, so single-quoted string literals are now supported again on the inline {{(...)}} path: ConditionLexer gained an opt-in allowSingleQuotedStrings flag that PlaceholderVisitor enables (with \' escape support). The {{#if}}/text/standalone path keeps the flag off, so its lexing is byte-for-byte unchanged (it never supported single quotes). Added SingleQuoteLexingTests; full suite 1187/1187, format clean.

…xpressions (#128)

ConditionLexer only recognized double-quoted strings, unlike the deleted
legacy inline {{(...)}} parser (BooleanExpressionParser) which accepted
both quote styles. Templates like {{(Status = 'Active')}} silently broke.

Add an opt-in `allowSingleQuotedStrings` flag to ConditionLexer (default
false, preserving current behavior). PlaceholderVisitor's inline {{(...)}}
path now constructs the lexer with the flag on; ConditionalEvaluator's
{{#if}}/text/standalone paths keep constructing it with the flag off, so
that behavior is unchanged byte-for-byte.

Refs #128, #129
@vaceslav
vaceslav merged commit 035669c into main Jul 21, 2026
14 checks passed
@vaceslav
vaceslav deleted the feat/128-extensible-condition-operators branch July 21, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(conditionals): extensible operator engine + in/string/existence operators + grouping

2 participants