diff --git a/TriasDev.Templify.Tests/ConditionValidationTests.cs b/TriasDev.Templify.Tests/ConditionValidationTests.cs new file mode 100644 index 0000000..15e112f --- /dev/null +++ b/TriasDev.Templify.Tests/ConditionValidationTests.cs @@ -0,0 +1,313 @@ +// 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; + +public class ConditionValidationTests +{ + private readonly ConditionEvaluator _evaluator = new(); + + #region Valid Expressions + + [Fact] + public void Validate_SimpleVariable_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("IsActive"); + + Assert.True(result.IsValid); + Assert.Empty(result.Issues); + } + + [Fact] + public void Validate_ComparisonWithSingleEquals_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("Count = 5"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ComparisonWithDoubleEquals_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("Count == 5"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_EqualityWithQuotedString_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("Status = \"Active\""); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_LogicalAnd_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("A and B"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_LogicalOr_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("A or B"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_NotOperator_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("not IsDisabled"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ComplexExpression_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("Count > 0 and IsEnabled"); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("A = B")] + [InlineData("A == B")] + [InlineData("A != B")] + [InlineData("A > B")] + [InlineData("A < B")] + [InlineData("A >= B")] + [InlineData("A <= B")] + public void Validate_AllComparisonOperators_ReturnsValid(string expression) + { + ConditionValidationResult result = _evaluator.Validate(expression); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_NestedPath_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("Customer.Address.City"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ComplexWithMultipleOperators_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("Status = \"Active\" and Count > 0 or IsEnabled"); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_NotWithComparison_ReturnsValid() + { + ConditionValidationResult result = _evaluator.Validate("not Status = \"Inactive\""); + + Assert.True(result.IsValid); + } + + #endregion + + #region Empty Expression + + [Fact] + public void Validate_EmptyString_ReturnsEmptyExpression() + { + ConditionValidationResult result = _evaluator.Validate(""); + + Assert.False(result.IsValid); + Assert.Single(result.Issues); + Assert.Equal(ConditionValidationIssueType.EmptyExpression, result.Issues[0].Type); + } + + [Fact] + public void Validate_WhitespaceOnly_ReturnsEmptyExpression() + { + ConditionValidationResult result = _evaluator.Validate(" "); + + Assert.False(result.IsValid); + Assert.Single(result.Issues); + Assert.Equal(ConditionValidationIssueType.EmptyExpression, result.Issues[0].Type); + } + + [Fact] + public void Validate_NullExpression_ThrowsArgumentNullException() + { + Assert.Throws(() => _evaluator.Validate(null!)); + } + + #endregion + + #region Unbalanced Quotes + + [Fact] + public void Validate_UnclosedQuote_ReturnsUnbalancedQuotes() + { + ConditionValidationResult result = _evaluator.Validate("Status = \"Active"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => i.Type == ConditionValidationIssueType.UnbalancedQuotes); + } + + #endregion + + #region Unknown Operators + + [Fact] + public void Validate_DollarSign_ReturnsUnknownOperator() + { + ConditionValidationResult result = _evaluator.Validate("A $ B"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.UnknownOperator && i.Token == "$"); + } + + [Fact] + public void Validate_DoubleAmpersand_ReturnsUnknownOperator() + { + ConditionValidationResult result = _evaluator.Validate("A && B"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.UnknownOperator && i.Token == "&&"); + } + + [Fact] + public void Validate_DoublePipe_ReturnsUnknownOperator() + { + ConditionValidationResult result = _evaluator.Validate("A || B"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.UnknownOperator && i.Token == "||"); + } + + [Fact] + public void Validate_DiamondOperator_ReturnsUnknownOperator() + { + ConditionValidationResult result = _evaluator.Validate("A <> B"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.UnknownOperator && i.Token == "<>"); + } + + [Fact] + public void Validate_TripleEquals_ReturnsUnknownOperator() + { + ConditionValidationResult result = _evaluator.Validate("A === B"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.UnknownOperator && i.Token == "==="); + } + + #endregion + + #region Missing Operand + + [Fact] + public void Validate_TrailingOperator_ReturnsMissingOperand() + { + ConditionValidationResult result = _evaluator.Validate("Status ="); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.MissingOperand && i.Token == "="); + } + + [Fact] + public void Validate_LeadingOperator_ReturnsMissingOperand() + { + ConditionValidationResult result = _evaluator.Validate("= Active"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.MissingOperand && i.Token == "="); + } + + #endregion + + #region Consecutive Operators + + [Fact] + public void Validate_TwoComparisonOperators_ReturnsConsecutiveOperators() + { + ConditionValidationResult result = _evaluator.Validate("A = = B"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.ConsecutiveOperators); + } + + #endregion + + #region Consecutive Operands + + [Fact] + public void Validate_TwoOperandsWithoutOperator_ReturnsConsecutiveOperands() + { + ConditionValidationResult result = _evaluator.Validate("A B"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, i => + i.Type == ConditionValidationIssueType.ConsecutiveOperands); + } + + #endregion + + #region Multiple Issues + + [Fact] + public void Validate_MultipleIssues_ReturnsAll() + { + // "$ B C" has unknown operator $ at start (also MissingOperand) and consecutive operands B C + ConditionValidationResult result = _evaluator.Validate("$ B C"); + + Assert.False(result.IsValid); + Assert.True(result.Issues.Count >= 2); + } + + #endregion + + #region Via IConditionContext + + [Fact] + public void Validate_ViaConditionContext_DelegatesToEvaluator() + { + Dictionary data = new() { ["X"] = true }; + IConditionContext context = _evaluator.CreateConditionContext(data); + + ConditionValidationResult validResult = context.Validate("X = true"); + Assert.True(validResult.IsValid); + + ConditionValidationResult invalidResult = context.Validate("A $ B"); + Assert.False(invalidResult.IsValid); + } + + #endregion + + #region Via IConditionEvaluator Interface + + [Fact] + public void Validate_ViaInterface_Works() + { + IConditionEvaluator evaluator = new ConditionEvaluator(); + + ConditionValidationResult result = evaluator.Validate("Count > 0"); + + Assert.True(result.IsValid); + } + + #endregion +} diff --git a/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs b/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs index 05c223a..c187b93 100644 --- a/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs +++ b/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs @@ -371,6 +371,70 @@ public void Evaluate_EqOperator_WithMatchingNumbers_ReturnsTrue() #endregion + #region Double Equality Operator (==) + + [Fact] + public void Evaluate_DoubleEqOperator_WithMatchingStrings_ReturnsTrue() + { + Dictionary data = new() { ["Status"] = "Active" }; + + bool result = Evaluate("Status == Active", data); + + Assert.True(result); + } + + [Fact] + public void Evaluate_DoubleEqOperator_WithMatchingQuotedStrings_ReturnsTrue() + { + Dictionary data = new() { ["Status"] = "In Progress" }; + + bool result = Evaluate("Status == \"In Progress\"", data); + + Assert.True(result); + } + + [Fact] + public void Evaluate_DoubleEqOperator_WithNonMatchingStrings_ReturnsFalse() + { + Dictionary data = new() { ["Status"] = "Active" }; + + bool result = Evaluate("Status == Inactive", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_DoubleEqOperator_WithMatchingNumbers_ReturnsTrue() + { + Dictionary data = new() { ["Count"] = 5 }; + + bool result = Evaluate("Count == 5", data); + + Assert.True(result); + } + + [Fact] + public void Evaluate_DoubleEqOperator_WithBooleanFalse_ReturnsFalse() + { + Dictionary data = new() { ["IsActive"] = true }; + + bool result = Evaluate("IsActive == false", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_DoubleEqOperator_WithBooleanTrue_ReturnsTrue() + { + Dictionary data = new() { ["IsActive"] = true }; + + bool result = Evaluate("IsActive == true", data); + + Assert.True(result); + } + + #endregion + #region Not Equal Operator (!=) [Fact] @@ -995,4 +1059,92 @@ public void Evaluate_DeepNestedPath_InLoopContext_WhenItemHasSamePath_ReturnsTru } #endregion + + #region Missing Nested Variable Comparison + + [Fact] + public void Evaluate_MissingNestedVariable_EqualsFalse_ReturnsFalse() + { + // var1 exists but var2 does not → null != "false" → false + Dictionary data = new() + { + ["var1"] = new Dictionary { ["other"] = "value" } + }; + + bool result = Evaluate("var1.var2 = false", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_MissingNestedVariable_EqualsTrue_ReturnsFalse() + { + // var1 exists but var2 does not → null != "true" → false + Dictionary data = new() + { + ["var1"] = new Dictionary { ["other"] = "value" } + }; + + bool result = Evaluate("var1.var2 = true", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_MissingNestedVariable_DoubleEqualsTrue_ReturnsFalse() + { + // Same behavior with == operator + Dictionary data = new() + { + ["var1"] = new Dictionary { ["other"] = "value" } + }; + + bool result = Evaluate("var1.var2 == true", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_MissingNestedVariable_Standalone_ReturnsFalse() + { + // var1.var2 as standalone boolean check → missing = falsy → false + Dictionary data = new() + { + ["var1"] = new Dictionary { ["other"] = "value" } + }; + + bool result = Evaluate("var1.var2", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_ExistingNestedVariable_EqualsFalse_WhenValueIsTrue_ReturnsFalse() + { + // var1.var2 exists and is true → "True" != "false" → false + Dictionary data = new() + { + ["var1"] = new Dictionary { ["var2"] = true } + }; + + bool result = Evaluate("var1.var2 = false", data); + + Assert.False(result); + } + + [Fact] + public void Evaluate_ExistingNestedVariable_EqualsFalse_WhenValueIsFalse_ReturnsTrue() + { + // var1.var2 exists and is false → "False" == "false" → true + Dictionary data = new() + { + ["var1"] = new Dictionary { ["var2"] = false } + }; + + bool result = Evaluate("var1.var2 = false", data); + + Assert.True(result); + } + + #endregion } diff --git a/TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs b/TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs index 4adad7f..5eb717c 100644 --- a/TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs +++ b/TriasDev.Templify.Tests/Expressions/BooleanExpressionParserTests.cs @@ -109,6 +109,20 @@ public void Parse_WithComparisonEquals_ReturnsExpression() 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() { diff --git a/TriasDev.Templify.Tests/Integration/ProcessingWarningsIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/ProcessingWarningsIntegrationTests.cs index 8f7a9f7..288fabb 100644 --- a/TriasDev.Templify.Tests/Integration/ProcessingWarningsIntegrationTests.cs +++ b/TriasDev.Templify.Tests/Integration/ProcessingWarningsIntegrationTests.cs @@ -257,10 +257,10 @@ public void ProcessTemplate_NestedMissingVariable_CollectsWarning() [Fact] public void ProcessTemplate_InvalidExpressionSyntax_CollectsWarning() { - // Arrange - expression with invalid syntax (= instead of ==) + // Arrange - expression with invalid operator (===) // This causes parse failure which generates ExpressionFailed warning DocumentBuilder builder = new DocumentBuilder(); - builder.AddParagraph("{{(Status = \"Active\")}}"); + builder.AddParagraph("{{(Status === \"Active\")}}"); Dictionary data = new Dictionary(); @@ -279,7 +279,7 @@ public void ProcessTemplate_InvalidExpressionSyntax_CollectsWarning() // Note: Also generates MissingVariable warning because resolved=false Assert.Contains(result.Warnings, w => w.Type == ProcessingWarningType.ExpressionFailed); ProcessingWarning exprWarning = result.Warnings.First(w => w.Type == ProcessingWarningType.ExpressionFailed); - Assert.Equal("(Status = \"Active\")", exprWarning.VariableName); + Assert.Equal("(Status === \"Active\")", exprWarning.VariableName); Assert.Contains("expression", exprWarning.Context); } diff --git a/TriasDev.Templify/Conditionals/ConditionContext.cs b/TriasDev.Templify/Conditionals/ConditionContext.cs index ebd85d6..922fa9b 100644 --- a/TriasDev.Templify/Conditionals/ConditionContext.cs +++ b/TriasDev.Templify/Conditionals/ConditionContext.cs @@ -47,6 +47,13 @@ internal ConditionContext(ConditionalEvaluator evaluator, IEvaluationContext con _context = context; } + /// + public ConditionValidationResult Validate(string expression) + { + ArgumentNullException.ThrowIfNull(expression); + return _evaluator.Validate(expression); + } + /// public bool Evaluate(string expression) { diff --git a/TriasDev.Templify/Conditionals/ConditionEvaluator.cs b/TriasDev.Templify/Conditionals/ConditionEvaluator.cs index 17175ed..473d78b 100644 --- a/TriasDev.Templify/Conditionals/ConditionEvaluator.cs +++ b/TriasDev.Templify/Conditionals/ConditionEvaluator.cs @@ -61,6 +61,13 @@ public sealed class ConditionEvaluator : IConditionEvaluator { private readonly ConditionalEvaluator _evaluator = new(); + /// + public ConditionValidationResult Validate(string expression) + { + ArgumentNullException.ThrowIfNull(expression); + return _evaluator.Validate(expression); + } + /// public bool Evaluate(string expression, Dictionary data) { diff --git a/TriasDev.Templify/Conditionals/ConditionValidationIssue.cs b/TriasDev.Templify/Conditionals/ConditionValidationIssue.cs new file mode 100644 index 0000000..2f0170a --- /dev/null +++ b/TriasDev.Templify/Conditionals/ConditionValidationIssue.cs @@ -0,0 +1,81 @@ +// 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; + +/// +/// Represents an issue found during condition expression validation. +/// +public sealed class ConditionValidationIssue +{ + /// + /// Gets the type of validation issue. + /// + public ConditionValidationIssueType Type { get; } + + /// + /// Gets a human-readable message describing the issue. + /// + public string Message { get; } + + /// + /// Gets the offending token, if applicable. + /// + public string? Token { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The type of validation issue. + /// A human-readable message describing the issue. + /// The offending token, if applicable. + public ConditionValidationIssue(ConditionValidationIssueType type, string message, string? token = null) + { + Type = type; + Message = message ?? throw new ArgumentNullException(nameof(message)); + Token = token; + } + + /// + public override string ToString() + { + string tokenPart = Token != null ? $" (token: '{Token}')" : ""; + return $"{Type}{tokenPart}: {Message}"; + } +} + +/// +/// Defines the types of issues that can be found during condition expression validation. +/// +public enum ConditionValidationIssueType +{ + /// + /// The expression is empty or whitespace-only. + /// + EmptyExpression, + + /// + /// An unrecognized operator-like token was found (e.g., "===" , "$", "&&", "||"). + /// + UnknownOperator, + + /// + /// The expression contains unbalanced quotes. + /// + UnbalancedQuotes, + + /// + /// An operator is missing a required operand (e.g., "Status =" or "= Active"). + /// + MissingOperand, + + /// + /// Two operators appear consecutively without an operand between them. + /// + ConsecutiveOperators, + + /// + /// Two operands appear consecutively without an operator between them. + /// + ConsecutiveOperands +} diff --git a/TriasDev.Templify/Conditionals/ConditionValidationResult.cs b/TriasDev.Templify/Conditionals/ConditionValidationResult.cs new file mode 100644 index 0000000..9d00c9c --- /dev/null +++ b/TriasDev.Templify/Conditionals/ConditionValidationResult.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. + +namespace TriasDev.Templify.Conditionals; + +/// +/// Represents the result of validating a condition expression. +/// +public sealed class ConditionValidationResult +{ + /// + /// Gets whether the expression is syntactically valid. + /// + public bool IsValid { get; init; } + + /// + /// Gets a read-only list of validation issues found in the expression. + /// + public IReadOnlyList Issues { get; init; } = Array.Empty(); + + /// + /// Creates a successful validation result with no issues. + /// + public static ConditionValidationResult Success() + { + return new ConditionValidationResult { IsValid = true }; + } + + /// + /// Creates a failed validation result with the specified issues. + /// + /// The validation issues found. + public static ConditionValidationResult Failure(IReadOnlyList issues) + { + return new ConditionValidationResult + { + IsValid = false, + Issues = issues ?? throw new ArgumentNullException(nameof(issues)) + }; + } +} diff --git a/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs b/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs index 0c290c0..959f858 100644 --- a/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs +++ b/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs @@ -10,7 +10,7 @@ namespace TriasDev.Templify.Conditionals; /// /// Evaluates conditional expressions for conditional blocks. -/// Supports operators: =, !=, >, <, >=, <=, and, or, not +/// Supports operators: =, ==, !=, >, <, >=, <=, and, or, not /// internal sealed class ConditionalEvaluator { @@ -18,12 +18,181 @@ internal sealed class ConditionalEvaluator private const string AndOperator = "and"; private const string NotOperator = "not"; private const string EqOperator = "="; + private const string EqOperatorDouble = "=="; private const string NeOperator = "!="; private const string GtOperator = ">"; private const string LtOperator = "<"; private const string GteOperator = ">="; private const string LteOperator = "<="; + /// + /// Known operator-like tokens that are common mistakes but not valid operators. + /// + private static readonly HashSet _knownInvalidOperators = new(StringComparer.OrdinalIgnoreCase) + { + "===", "<>", "&&", "||" + }; + + /// + /// Validates a conditional expression for syntactic correctness. + /// This is a pure syntax check that does not require a data context. + /// + /// 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 + if (string.IsNullOrWhiteSpace(expression)) + { + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.EmptyExpression, + "Expression is empty.")); + return ConditionValidationResult.Failure(issues); + } + + // Rule 2: Unbalanced quotes + string normalized = NormalizeQuotes(expression); + int quoteCount = 0; + foreach (char c in normalized) + { + if (c == '"') + { + quoteCount++; + } + } + + if (quoteCount % 2 != 0) + { + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.UnbalancedQuotes, + "Expression contains unbalanced quotes.")); + } + + // Tokenize + 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); + } + + // Walk tokens and check structure + // Classify: "operator" (comparison/logical), "not", or "operand" + string? previousType = null; // "operand", "comparison", "logical", "not" + string? previousToken = null; + + for (int i = 0; i < tokens.Count; i++) + { + string token = tokens[i]; + string currentType; + + if (string.Equals(token, NotOperator, StringComparison.OrdinalIgnoreCase)) + { + currentType = "not"; + } + else if (IsComparisonOperator(token)) + { + currentType = "comparison"; + } + else if (IsLogicalOperator(token)) + { + currentType = "logical"; + } + else if (IsSuspectedUnknownOperator(token)) + { + string hint = token == "<>" ? " Did you mean '!='?" : ""; + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.UnknownOperator, + $"Unknown operator '{token}'.{hint}", + token)); + currentType = "comparison"; // Treat as operator for structural analysis + } + else + { + currentType = "operand"; + } + + // Structural checks + if (i == 0 && (currentType == "comparison" || currentType == "logical")) + { + // Operator at start (not is OK) + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.MissingOperand, + $"Operator '{token}' is missing a left-hand operand.", + token)); + } + else if (previousType != null) + { + bool prevIsOp = previousType == "comparison" || previousType == "logical"; + bool currIsOp = currentType == "comparison" || currentType == "logical"; + + if (prevIsOp && currIsOp) + { + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.ConsecutiveOperators, + $"Consecutive operators '{previousToken}' and '{token}'.", + token)); + } + else if (previousType == "operand" && currentType == "operand") + { + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.ConsecutiveOperands, + $"Missing operator between '{previousToken}' and '{token}'.", + token)); + } + } + + previousType = currentType; + previousToken = token; + } + + // Check for trailing operator + if (previousType == "comparison" || previousType == "logical") + { + issues.Add(new ConditionValidationIssue( + ConditionValidationIssueType.MissingOperand, + $"Operator '{previousToken}' is missing a right-hand operand.", + previousToken)); + } + + return issues.Count == 0 + ? ConditionValidationResult.Success() + : ConditionValidationResult.Failure(issues); + } + + /// + /// Checks if a token looks like an operator but is not recognized. + /// + private static bool IsSuspectedUnknownOperator(string token) + { + // Check known invalid operators first + if (_knownInvalidOperators.Contains(token)) + { + return true; + } + + // Check if token consists entirely of operator-like punctuation + foreach (char c in token) + { + if (!"=!<>$~^&|%#".Contains(c)) + { + return false; + } + } + + // Non-empty punctuation-only token that isn't a valid operator + return token.Length > 0; + } + /// /// Evaluates a conditional expression. /// @@ -186,6 +355,7 @@ private bool EvaluateTokens(List tokens, IEvaluationContext context) break; case EqOperator: + case EqOperatorDouble: { bool comparisonResult = AreEqual(currentValue, nextValue); if (negateNext) @@ -461,7 +631,7 @@ private bool IsLogicalOperator(string token) private bool IsComparisonOperator(string token) { string lower = token.ToLower(); - return lower == EqOperator || lower == NeOperator || + return lower == EqOperator || lower == EqOperatorDouble || lower == NeOperator || lower == GtOperator || lower == LtOperator || lower == GteOperator || lower == LteOperator; } diff --git a/TriasDev.Templify/Conditionals/IConditionContext.cs b/TriasDev.Templify/Conditionals/IConditionContext.cs index bc4ad01..2306b53 100644 --- a/TriasDev.Templify/Conditionals/IConditionContext.cs +++ b/TriasDev.Templify/Conditionals/IConditionContext.cs @@ -30,6 +30,15 @@ namespace TriasDev.Templify.Conditionals; /// public interface IConditionContext { + /// + /// Validates a conditional expression for syntactic correctness without evaluating it. + /// This is a pure syntax check that does not use the pre-loaded data. + /// + /// The expression to validate. + /// A indicating whether the expression is valid and any issues found. + /// Thrown when is null. + ConditionValidationResult Validate(string expression); + /// /// Evaluates a conditional expression against the pre-loaded data. /// diff --git a/TriasDev.Templify/Conditionals/IConditionEvaluator.cs b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs index 5655568..988900f 100644 --- a/TriasDev.Templify/Conditionals/IConditionEvaluator.cs +++ b/TriasDev.Templify/Conditionals/IConditionEvaluator.cs @@ -14,7 +14,7 @@ namespace TriasDev.Templify.Conditionals; /// for use in standalone scenarios without Word document processing. /// /// -/// Supported operators: =, !=, >, <, >=, <=, and, or, not +/// Supported operators: =, ==, !=, >, <, >=, <=, and, or, not /// /// /// Examples: @@ -34,6 +34,34 @@ namespace TriasDev.Templify.Conditionals; /// public interface IConditionEvaluator { + #region Validate + + /// + /// Validates a conditional expression for syntactic correctness without evaluating it. + /// This is a pure syntax check that does not require a data context. + /// + /// The expression to validate. + /// A indicating whether the expression is valid and any issues found. + /// Thrown when is null. + /// + /// + /// var evaluator = new ConditionEvaluator(); + /// + /// var result = evaluator.Validate("Status = \"Active\""); + /// // result.IsValid == true + /// + /// var result2 = evaluator.Validate("Status == \"Active\""); + /// // result2.IsValid == true (both = and == are supported) + /// + /// var result3 = evaluator.Validate("A $ B"); + /// // result3.IsValid == false + /// // result3.Issues[0].Type == ConditionValidationIssueType.UnknownOperator + /// + /// + ConditionValidationResult Validate(string expression); + + #endregion + #region Evaluate (Synchronous) /// diff --git a/TriasDev.Templify/Expressions/BooleanExpressionParser.cs b/TriasDev.Templify/Expressions/BooleanExpressionParser.cs index 94e6141..065fd3c 100644 --- a/TriasDev.Templify/Expressions/BooleanExpressionParser.cs +++ b/TriasDev.Templify/Expressions/BooleanExpressionParser.cs @@ -7,7 +7,7 @@ namespace TriasDev.Templify.Expressions; /// /// Parses boolean expressions from text. -/// Supports: and, or, not, ==, !=, >, >=, <, <=, parentheses +/// Supports: and, or, not, =, ==, !=, >, >=, <, <=, parentheses /// Examples: /// - (var1 and var2) /// - (var1 or var2) @@ -167,6 +167,12 @@ private bool TryParseComparisonOperator(out ComparisonOperator op) 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; diff --git a/docs/FAQ.md b/docs/FAQ.md index 4193aea..815f094 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -399,7 +399,7 @@ Can proceed: ✓ **Supported operators**: - Logical: `and`, `or`, `not` -- Comparison: `==`, `!=`, `>`, `>=`, `<`, `<=` +- Comparison: `=`, `==`, `!=`, `>`, `>=`, `<`, `<=` - Nested: `((var1 or var2) and var3)` See the [Boolean Expressions Guide](for-template-authors/boolean-expressions.md) for complete documentation. @@ -565,7 +565,7 @@ Parallel.ForEach(dataList, data => 1. **Condition syntax**: `{{#if IsActive}}` not `{{if IsActive}}` 2. **Variable exists**: Provide the variable in data 3. **Type mismatch**: `"true"` (string) is not `true` (boolean) -4. **Comparison operators**: Use `==` for equality, not `=` +4. **Comparison operators**: Use `=` or `==` for equality (both are supported) 5. **Closing tag**: Must have `{{/if}}` ### Q: Loop isn't repeating. Why? diff --git a/docs/for-developers/processing-warnings.md b/docs/for-developers/processing-warnings.md index f42a1f9..848509b 100644 --- a/docs/for-developers/processing-warnings.md +++ b/docs/for-developers/processing-warnings.md @@ -31,7 +31,7 @@ if (result.IsSuccess) | `MissingVariable` | Variable not found in data | Placeholder like `{{CustomerName}}` when `CustomerName` is not in the data dictionary | | `MissingLoopCollection` | Loop collection not found | `{{#foreach Items}}` when `Items` is not in the data dictionary | | `NullLoopCollection` | Loop collection is null | `{{#foreach Items}}` when `Items` exists but is `null` | -| `ExpressionFailed` | Expression parsing or evaluation failed | `{{(Status = "Active")}}` with invalid syntax (should be `==`) | +| `ExpressionFailed` | Expression parsing or evaluation failed | `{{(Status === "Active")}}` with invalid syntax (use `=` or `==`) | ## Warning Properties @@ -106,5 +106,5 @@ if (result.HasWarnings) - **Empty collections** do not generate warnings (they're valid, just produce no output) - **Valid expressions with missing variables** evaluate to `false` without warnings (e.g., `{{(Price > 100)}}` where `Price` is missing returns `false`) -- **Invalid expression syntax** generates `ExpressionFailed` (e.g., using `=` instead of `==`) +- **Invalid expression syntax** generates `ExpressionFailed` (e.g., using `===` instead of `=` or `==`) - Warnings are collected even when `MissingVariableBehavior` is set to `LeaveUnchanged` or `ReplaceWithEmpty` diff --git a/docs/for-template-authors/boolean-expressions.md b/docs/for-template-authors/boolean-expressions.md index 1a2f480..5943616 100644 --- a/docs/for-template-authors/boolean-expressions.md +++ b/docs/for-template-authors/boolean-expressions.md @@ -194,9 +194,9 @@ Account locked: True ## Comparison Operators -### Equality (==) +### Equality (== or =) -Checks if two values are equal. +Checks if two values are equal. Both `==` and `=` are supported. **Template:** ``` @@ -895,7 +895,7 @@ Expressions are evaluated with standard operator precedence: 1. **Parentheses** `()` - Highest priority 2. **NOT** `not` -3. **Comparison** `>`, `>=`, `<`, `<=`, `==`, `!=` +3. **Comparison** `>`, `>=`, `<`, `<=`, `=`, `==`, `!=` 4. **AND** `and` 5. **OR** `or` - Lowest priority @@ -915,7 +915,7 @@ Expressions are evaluated with standard operator precedence: Boolean expressions enable powerful inline logic in your templates: - ✅ Logical operators: `and`, `or`, `not` -- ✅ Comparison operators: `==`, `!=`, `>`, `>=`, `<`, `<=` +- ✅ Comparison operators: `=`, `==`, `!=`, `>`, `>=`, `<`, `<=` - ✅ 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 1dc1ad4..6ac1e79 100644 --- a/docs/for-template-authors/conditionals.md +++ b/docs/for-template-authors/conditionals.md @@ -859,9 +859,9 @@ Geschäftszeiten: 9:00 - 17:00 Uhr MEZ - ✅ `{{#if Status = "Active"}}` with JSON: `"Status": "Active"` - ❌ `{{#if Status = "active"}}` with JSON: `"Status": "Active"}` -4. **Wrong operator:** - - ✅ `{{#if Age = 18}}` (checking equality) - - ❌ `{{#if Age == 18}}` (wrong operator, use single `=`) +4. **Equality operator:** + - ✅ `{{#if Age = 18}}` (single `=` for equality) + - ✅ `{{#if Age == 18}}` (double `==` also accepted) 5. **Quotes around text:** - ✅ `{{#if Name = "Alice"}}`