diff --git a/TriasDev.Templify.Tests/Integration/ValidationInlineExpressionTests.cs b/TriasDev.Templify.Tests/Integration/ValidationInlineExpressionTests.cs new file mode 100644 index 0000000..3710da3 --- /dev/null +++ b/TriasDev.Templify.Tests/Integration/ValidationInlineExpressionTests.cs @@ -0,0 +1,341 @@ +// 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; +using TriasDev.Templify.Tests.Helpers; + +namespace TriasDev.Templify.Tests.Integration; + +/// +/// ValidateTemplate validates inline expression placeholders ({{(A and B)}}) through the parsed expression: +/// the referenced variables are listed and checked (not the expression text as one variable), and expressions that +/// cannot be parsed are errors. Every case is validated as a Word and as an OpenDocument template, directly and through +/// the facade, and the results must predict processing. +/// +public sealed class ValidationInlineExpressionTests +{ + private static Dictionary Data() => new Dictionary + { + ["A"] = true, + ["B"] = false, + ["IsActive"] = true, + ["Price"] = 150m, + ["Count"] = 3, + ["Status"] = "Active", + ["Role"] = "Admin", + ["Roles"] = new List { "Admin", "Owner" }, + ["Name"] = "Alice", + ["Customer"] = new Dictionary { ["Vip"] = true }, + ["Items"] = new List> + { + new() { ["Title"] = "i1", ["Active"] = true, ["Qty"] = 2 }, + new() { ["Title"] = "i2", ["Active"] = false, ["Qty"] = 0 }, + }, + ["Empty"] = new List(), + }; + + /// Validates the paragraphs in both formats and both entry points; returns the Word result. + private static ValidationResult Validate(Dictionary? data, params string[] paragraphs) + { + DocumentBuilder docx = new DocumentBuilder(); + OdtDocumentBuilder odt = new OdtDocumentBuilder(); + foreach (string paragraph in paragraphs) + { + docx.AddParagraph(paragraph); + odt.AddParagraph(paragraph); + } + + return ValidateAll(docx.ToStream().ToArray(), odt.ToBytes(), data); + } + + private static ValidationResult ValidateAll(byte[] docxBytes, byte[] odtBytes, Dictionary? data) + { + ValidationResult word = data == null + ? new DocumentTemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes)) + : new DocumentTemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes), data); + ValidationResult[] others = data == null + ? new[] + { + new OdtTemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes)), + new TemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes)), + new TemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes)), + } + : new[] + { + new OdtTemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes), data), + new TemplateProcessor().ValidateTemplate(new MemoryStream(docxBytes), data), + new TemplateProcessor().ValidateTemplate(new MemoryStream(odtBytes), data), + }; + + foreach (ValidationResult other in others) + { + Assert.Equal(Describe(word.Errors), Describe(other.Errors)); + Assert.Equal(word.Warnings.Select(w => $"{w.Type}: {w.Message}"), other.Warnings.Select(w => $"{w.Type}: {w.Message}")); + Assert.Equal(word.AllPlaceholders, other.AllPlaceholders); + Assert.Equal(word.MissingVariables, other.MissingVariables); + Assert.Equal(word.IsValid, other.IsValid); + } + + return word; + } + + private static List Describe(IEnumerable errors) => + errors.Select(e => $"{e.Type}: {e.Message} @ {e.Location}").Order(StringComparer.Ordinal).ToList(); + + private static string Errors(ValidationResult result) => string.Join("; ", result.Errors.Select(e => e.Message)); + + /// Processes the paragraphs as a Word document; returns the result and the paragraph texts. + private static (ProcessingResult Result, List Paragraphs) Process(Dictionary data, params string[] paragraphs) + { + DocumentBuilder docx = new DocumentBuilder(); + foreach (string paragraph in paragraphs) + { + docx.AddParagraph(paragraph); + } + + using MemoryStream template = docx.ToStream(); + using MemoryStream output = new MemoryStream(); + ProcessingResult result = new DocumentTemplateProcessor().ProcessTemplate(template, output, data); + using DocumentVerifier verifier = new DocumentVerifier(output); + return (result, verifier.GetAllParagraphTexts()); + } + + public static TheoryData ValidExpressions => new TheoryData + { + "{{(A and B)}}", + "{{(A or B)}}", + "{{(not B)}}", + "{{(IsActive):yesno}}", + "{{(IsActive and not B):checkbox}}", + "{{(Price > 100)}}", + "{{(Price >= 100 and Count < 5):yesno}}", + "{{((A or B) and not (Price < 5))}}", + "{{(((A) and (not B)) or (Count = 3))}}", + "{{(Status = 'Active')}}", + "{{(Status = \"Active\")}}", + "{{(Status = Active)}}", + "{{(Status != Pending)}}", + "{{(Missing = \"x\")}}", + "{{(Missing > 100)}}", + "{{(Customer.Vip and Name)}}", + "{{(Role in (\"Admin\", \"Owner\"))}}", + "{{(Role in Roles)}}", + "{{(Roles contains \"Admin\")}}", + "{{(Name startswith \"Al\")}}", + "{{(Missing exists)}}", + "{{(Missing is empty)}}", + "{{(not (Missing is not empty))}}", + "{{(true)}}", + "{{(1 = 1)}}", + }; + + [Theory] + [MemberData(nameof(ValidExpressions))] + public void ExpressionWithResolvableOperands_IsValid(string paragraph) + { + ValidationResult result = Validate(Data(), paragraph); + + Assert.True(result.IsValid, Errors(result)); + Assert.Empty(result.MissingVariables); + Assert.DoesNotContain(result.AllPlaceholders, p => p.StartsWith('(')); + + // Validation predicts processing: the expression is evaluated, nothing is missing. + (ProcessingResult processing, List texts) = Process(Data(), paragraph); + Assert.True(processing.IsSuccess, processing.ErrorMessage); + Assert.Empty(processing.MissingVariables); + Assert.Empty(processing.Warnings); + Assert.DoesNotContain("{{", texts.Single()); + } + + [Fact] + public void AllPlaceholders_ListsTheExpressionVariables() + { + ValidationResult result = Validate( + null, + "{{(A and B)}} {{(IsActive):yesno}} {{(Price > 100)}} {{((Customer.Vip or not Name) and Count >= 2)}}", + "{{(Status = 'Active')}} {{(Role in (\"Admin\", Other))}} {{(true)}} {{Plain}}"); + + Assert.True(result.IsValid, Errors(result)); + Assert.Equal( + new[] { "A", "B", "Count", "Customer.Vip", "IsActive", "Name", "Other", "Plain", "Price", "Role", "Status" }, + result.AllPlaceholders); + } + + public static TheoryData MissingOperandCases => new TheoryData + { + { "{{(A and Missing)}}", new[] { "Missing" } }, + { "{{(Missing):yesno}}", new[] { "Missing" } }, + { "{{(not Missing)}}", new[] { "Missing" } }, + { "{{((A or First) and not Second)}}", new[] { "First", "Second" } }, + { "{{((Missing) = true)}}", Array.Empty() }, + { "{{((A and Missing) = true)}}", new[] { "Missing" } }, + { "{{(Customer.Nope or A)}}", new[] { "Customer.Nope" } }, + { "{{(Price > 100 and Missing)}}", new[] { "Missing" } }, + { "{{(Missing in (\"a\", \"b\"))}}", new[] { "Missing" } }, + { "{{(Role in Allowed)}}", new[] { "Allowed" } }, + { "{{(Missing contains \"x\")}}", new[] { "Missing" } }, + { "{{(Name endswith Suffix)}}", new[] { "Suffix" } }, + }; + + [Theory] + [MemberData(nameof(MissingOperandCases))] + public void MissingOperand_IsAMissingVariableError(string paragraph, string[] missing) + { + ValidationResult result = Validate(Data(), paragraph); + + Assert.Equal(missing, result.MissingVariables); + Assert.Equal(missing.Length == 0, result.IsValid); + Assert.All(result.Errors, e => Assert.Equal(ValidationErrorType.MissingVariable, e.Type)); + Assert.Equal( + missing.Select(m => $"Variable '{m}' is referenced in expression '{ExpressionOf(paragraph)}' but not provided in the data."), + result.Errors.Select(e => e.Message).Order(StringComparer.Ordinal)); + + // Inline expressions do not get the {{#if}} MissingConditionVariable warning: the error covers them. + Assert.Empty(result.Warnings); + } + + /// The expression of a single placeholder, e.g. (A and B) of {{(A and B):yesno}}. + private static string ExpressionOf(string placeholder) + { + string inner = placeholder[2..^2]; + int format = inner.LastIndexOf("):", StringComparison.Ordinal); + return format >= 0 ? inner[..(format + 1)] : inner; + } + + [Fact] + public void WithoutData_MissingOperandsAreNotChecked() + { + ValidationResult result = Validate(null, "{{(A and Missing)}} {{(Nope):yesno}}"); + + Assert.True(result.IsValid, Errors(result)); + Assert.Empty(result.MissingVariables); + } + + public static TheoryData InvalidExpressions => new TheoryData + { + { "{{(A and)}}", "(A and)" }, + { "{{(Age > 18 && Name)}}", "(Age > 18 && Name)" }, + { "{{(Status === \"Active\")}}", "(Status === \"Active\")" }, + { "{{((A or B)}}", "((A or B)" }, + { "{{( )}}", "( )" }, + { "{{(A B):yesno}}", "(A B)" }, + }; + + [Theory] + [MemberData(nameof(InvalidExpressions))] + public void UnparsableExpression_IsAnInvalidExpressionError(string paragraph, string expression) + { + foreach (Dictionary? data in new[] { null, Data() }) + { + ValidationResult result = Validate(data, paragraph); + + ValidationError error = Assert.Single(result.Errors); + Assert.Equal(ValidationErrorType.InvalidConditionalExpression, error.Type); + Assert.StartsWith($"Invalid expression '{expression}': ", error.Message); + Assert.Equal(paragraph, error.Location); + Assert.False(result.IsValid); + Assert.Empty(result.MissingVariables); + Assert.DoesNotContain(result.AllPlaceholders, p => p.StartsWith('(')); + } + + // Validation predicts processing: the expression fails and is left in the document. + (ProcessingResult processing, List texts) = Process(Data(), paragraph); + Assert.Contains(processing.Warnings, w => w.Type == ProcessingWarningType.ExpressionFailed); + Assert.Equal(paragraph, texts.Single()); + } + + [Fact] + public void SameInvalidExpressionTwice_IsReportedOnce() + { + ValidationResult result = Validate(null, "{{(A and)}}", "x {{(A and)}}"); + + Assert.Single(result.Errors); + } + + [Fact] + public void InLoop_ItemPropertiesNamedVariablesMetadataAndGlobals_Resolve() + { + string[] paragraphs = + { + "{{#foreach item in Items}}", + "{{(item.Active and A)}} {{(Active or not B):yesno}} {{(item.Qty > 1)}} {{(Qty = 0 and Title)}}", + "{{(@first and not @last)}} {{(@index > 0)}} {{(item and IsActive)}}", + "{{/foreach}}", + }; + + ValidationResult result = Validate(Data(), paragraphs); + + Assert.True(result.IsValid, Errors(result)); + Assert.Empty(result.MissingVariables); + + (ProcessingResult processing, _) = Process(Data(), paragraphs); + Assert.Empty(processing.MissingVariables); + } + + [Fact] + public void InLoop_MissingItemPropertyOrVariable_IsAnError() + { + ValidationResult result = Validate( + Data(), + "{{#foreach item in Items}}", + "{{(item.Nope and A)}} {{(Nope):yesno}} {{(item.Active)}}", + "{{/foreach}}"); + + Assert.Equal(new[] { "item.Nope", "Nope" }, result.MissingVariables.Order(StringComparer.OrdinalIgnoreCase)); + Assert.False(result.IsValid); + } + + [Fact] + public void ItemPropertyOutsideTheLoop_IsAnError() + { + ValidationResult result = Validate( + Data(), + "{{#foreach Items}}", "{{(Active)}}", "{{/foreach}}", + "{{(Active)}}"); + + Assert.Equal(new[] { "Active" }, result.MissingVariables); + Assert.Single(result.Errors); + } + + [Fact] + public void InEmptyLoop_IsNotChecked() + { + ValidationResult result = Validate(Data(), "{{#foreach Empty}}", "{{(Nope and A)}}", "{{/foreach}}"); + + Assert.True(result.IsValid, Errors(result)); + Assert.Empty(result.MissingVariables); + } + + [Fact] + public void InsideConditionalBranches_IsChecked() + { + ValidationResult result = Validate( + Data(), + "{{#if A}}{{(IsActive):yesno}}{{#else}}{{(Nope)}}{{/if}}", + "{{#if B}}", + "{{(Price > 100 and Count = 3)}}", + "{{/if}}"); + + Assert.Equal(new[] { "Nope" }, result.MissingVariables); + } + + [Fact] + public void TableCellAndHeader_AreChecked() + { + DocumentBuilder docx = new DocumentBuilder(); + docx.AddTable(1, 1, (_, _) => "{{(A and CellMissing)}} {{(A and)}}"); + docx.AddHeader("{{(IsActive and HeaderMissing):yesno}}"); + + OdtDocumentBuilder odt = new OdtDocumentBuilder(); + odt.AddTable(new[] { "{{(A and CellMissing)}} {{(A and)}}" }); + odt.AddHeaderParagraph("{{(IsActive and HeaderMissing):yesno}}"); + + ValidationResult result = ValidateAll(docx.ToStream().ToArray(), odt.ToBytes(), Data()); + + Assert.Equal(new[] { "CellMissing", "HeaderMissing" }, result.MissingVariables); + Assert.Single(result.Errors, e => e.Type == ValidationErrorType.InvalidConditionalExpression); + Assert.Contains("CellMissing", result.AllPlaceholders); + Assert.Contains("HeaderMissing", result.AllPlaceholders); + Assert.Contains("IsActive", result.AllPlaceholders); + } +} diff --git a/TriasDev.Templify.Tests/Integration/ValidationMissingConditionVariableTests.cs b/TriasDev.Templify.Tests/Integration/ValidationMissingConditionVariableTests.cs index 62f4906..c98d4e9 100644 --- a/TriasDev.Templify.Tests/Integration/ValidationMissingConditionVariableTests.cs +++ b/TriasDev.Templify.Tests/Integration/ValidationMissingConditionVariableTests.cs @@ -224,11 +224,15 @@ public void InEmptyLoop_IsNotChecked() } [Fact] - public void InlineExpression_IsNotChecked() + public void InlineExpression_IsAMissingVariableErrorNotAWarning() { + // Inline expressions are placeholders: a missing operand is a MissingVariable error (see + // ValidationInlineExpressionTests), so no condition warning is added on top. ValidationResult result = Validate(Data(), "{{(Missing and A)}} {{(not Missing):yesno}}"); Assert.Empty(ConditionWarnings(result)); + Assert.Equal(new[] { "Missing" }, result.MissingVariables); + Assert.All(result.Errors, e => Assert.Equal(ValidationErrorType.MissingVariable, e.Type)); } [Fact] diff --git a/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs b/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs index 1773864..073ba38 100644 --- a/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs +++ b/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs @@ -53,6 +53,15 @@ public sealed class OdtValidationParityTests "{{#foreach item in Items}}", "{{#if item.Active and not item.Nope and Title and @first}}", "c", "{{/if}}", "{{/foreach}}", } }, + { + "inline expressions", + new[] + { + "{{(A and B)}} {{(Flag):yesno}} {{(Count > 2)}} {{((A or Nope) and not (Count < 1))}} {{(Status = Active)}}", + "{{(A and)}} {{(Name contains Missing)}} {{(Missing exists)}}", + "{{#foreach item in Items}}", "{{(item.Active and @first)}} {{(Title = 'i1' or item.Nope):yesno}}", "{{/foreach}}", + } + }, { "nested unmatched conditional in loop", new[] { "{{#foreach Items}}", "{{#if Active}}", "{{Title}}", "{{/foreach}}" } }, }; diff --git a/TriasDev.Templify/ARCHITECTURE.md b/TriasDev.Templify/ARCHITECTURE.md index 94c9dfb..f5d2965 100644 --- a/TriasDev.Templify/ARCHITECTURE.md +++ b/TriasDev.Templify/ARCHITECTURE.md @@ -215,6 +215,10 @@ expression string `Core/ScopedVariableValidator.cs`, which traverses containers like the walker (body loops, table row loops per table and per loop row list, cells, nested tables, text boxes, content controls) and resolves names inside a loop against the items of that loop and its enclosing loops (implicit properties, named iteration variables, metadata). + Inline `{{(...)}}` expressions are validated by `Core/InlineExpressionValidation.cs` with the processing parser + (`ConditionAstCache.InlineExpressions`): parse failures are `InvalidConditionalExpression` errors, `AllPlaceholders` + gets the referenced variables, and the variables processing reads from the data without a literal fallback + (truthiness operands, variable operands of `in` and the string operators) are checked as missing variables. ## OpenDocument Text (.odt / .ott) diff --git a/TriasDev.Templify/Core/InlineExpressionValidation.cs b/TriasDev.Templify/Core/InlineExpressionValidation.cs new file mode 100644 index 0000000..d7dcf96 --- /dev/null +++ b/TriasDev.Templify/Core/InlineExpressionValidation.cs @@ -0,0 +1,160 @@ +// 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.Conditionals.Engine.Operators; +using TriasDev.Templify.Placeholders; + +namespace TriasDev.Templify.Core; + +/// +/// Validates inline expression placeholders such as {{(A and B)}} or {{(Price > 100):yesno}} through +/// the parsed expression, with the parser that processing uses (). +/// Shared by the Word and the OpenDocument validators, which supply the loop-scoped resolution. +/// +/// +/// +/// +/// lists the variables an expression references (as for +/// {{#if}} conditions), not the expression text; an expression that cannot be parsed contributes nothing. +/// +/// +/// An expression that cannot be parsed is an error +/// (processing leaves it unreplaced and reports ). +/// +/// +/// With data, a error is reported for each variable that processing +/// resolves from the data and that is missing (see ). Comparison operands are not +/// reported: processing treats an unresolved bareword there as a string literal (Status = Active). +/// +/// +/// +internal static class InlineExpressionValidation +{ + /// + /// Gets the names a placeholder contributes to : the placeholder name + /// of a variable placeholder, the referenced variables of an expression placeholder. + /// + public static IEnumerable GetPlaceholderNames(string placeholderName) + { + if (!IsExpression(placeholderName)) + { + return new[] { placeholderName }; + } + + return TryParse(placeholderName, out ConditionNode? node, out _) + ? ConditionalEvaluator.CollectVariables(node!).Select(v => v.Path).Distinct(StringComparer.Ordinal) + : Array.Empty(); + } + + /// + /// Adds an error for each expression placeholder in + /// that cannot be parsed; an error that is already present is not added again. + /// + public static void CheckSyntax(string text, List errors) + { + if (text.IndexOf("{{(", StringComparison.Ordinal) < 0) + { + return; + } + + foreach (PlaceholderToken placeholder in PlaceholderScanner.FindPlaceholders(text)) + { + if (!placeholder.IsExpression || TryParse(placeholder.VariableName, out _, out string? error)) + { + continue; + } + + string message = $"Invalid expression '{placeholder.VariableName}': {error}"; + if (!errors.Any(e => e.Type == ValidationErrorType.InvalidConditionalExpression && e.Message == message)) + { + errors.Add(ValidationError.Create(ValidationErrorType.InvalidConditionalExpression, message, placeholder.FullMatch)); + } + } + } + + /// + /// Gets the variables of an expression placeholder that are missing: resolved by processing from the data (see + /// ), not loop metadata or the current item, and not resolvable in the scope. + /// + /// The expression, including its parentheses, e.g. (A and B). + /// Resolves a variable path in the current (loop) scope. + /// The missing variables; empty for an expression that cannot be parsed (a syntax error). + public static IEnumerable GetMissingVariables(string expression, Func canResolve) + { + if (!TryParse(expression, out ConditionNode? node, out _)) + { + return Array.Empty(); + } + + return CollectDataVariables(node!) + .Select(v => v.Path) + .Distinct(StringComparer.Ordinal) + .Where(path => !(path.StartsWith('@') || path.StartsWith('.') || path == "this" || canResolve(path))) + .ToList(); + } + + /// + /// Collects the variables that processing resolves from the data without a fallback: operands evaluated for + /// truthiness (the whole expression and the operands of and, or, not; a missing one is + /// false) and the variable operands of in and the string operators (a missing one is null). + /// + /// + /// Not collected: comparison operands and list items (a missing bareword is a string literal, e.g. the + /// Active in Status = Active) and the operands of exists / is empty / + /// is not empty (designed for missing values). Nested operator nodes are always descended into. + /// + internal static IEnumerable CollectDataVariables(ConditionNode node) + { + List result = new List(); + Collect(node, isRequired: true, result); + return result; + + static void Collect(ConditionNode node, bool isRequired, List result) + { + switch (node) + { + case VariableNode variable when isRequired: + result.Add(variable); + break; + + case OperatorNode op: + bool isLogical = op.Operator is AndOperator or OrOperator or NotOperator; + bool isStrict = op.Operator is InOperator or StringOperatorBase; + foreach (ConditionNode operand in op.Operands) + { + Collect(operand, isLogical || (isStrict && operand is VariableNode), result); + } + + break; + + case ListNode list: + foreach (ConditionNode item in list.Items) + { + Collect(item, isRequired: false, result); + } + + break; + } + } + } + + private static bool IsExpression(string placeholderName) => placeholderName.StartsWith('('); + + private static bool TryParse(string expression, out ConditionNode? node, out string? error) + { + try + { + node = ConditionAstCache.InlineExpressions.GetOrParse(expression); + error = null; + return true; + } + catch (ConditionParseException ex) + { + node = null; + error = ex.Message; + return false; + } + } +} diff --git a/TriasDev.Templify/Core/ScopedVariableValidator.cs b/TriasDev.Templify/Core/ScopedVariableValidator.cs index 453cc98..3cd2dd3 100644 --- a/TriasDev.Templify/Core/ScopedVariableValidator.cs +++ b/TriasDev.Templify/Core/ScopedVariableValidator.cs @@ -204,7 +204,20 @@ private void ValidateText(string text) { foreach (string placeholder in PlaceholderScanner.GetUniqueVariableNames(text)) { - _allPlaceholders.Add(placeholder); + _allPlaceholders.UnionWith(InlineExpressionValidation.GetPlaceholderNames(placeholder)); + + // Inline expressions ({{(A and B)}}) are checked per referenced variable + if (placeholder.StartsWith('(')) + { + foreach (string variable in InlineExpressionValidation.GetMissingVariables(placeholder, CanResolveInScope)) + { + ReportMissing( + variable, + $"Variable '{variable}' is referenced in expression '{placeholder}' but not provided in the data."); + } + + continue; + } // Skip special placeholders (loop metadata, current item) if (placeholder.StartsWith('@') || placeholder.StartsWith('.') || placeholder == "this") diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs index 3677ac2..557911b 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -10,6 +10,7 @@ using TriasDev.Templify.Conditionals; using TriasDev.Templify.Loops; using TriasDev.Templify.Placeholders; +using TriasDev.Templify.Utilities; namespace TriasDev.Templify.Core; @@ -75,8 +76,9 @@ public ValidationResult Validate(Stream templateStream, IReadOnlyDictionary elements, IEnumerable foundPlaceholders = PlaceholderScanner.GetUniqueVariableNames(text); foreach (string placeholder in foundPlaceholders) { - allPlaceholders.Add(placeholder); + allPlaceholders.UnionWith(InlineExpressionValidation.GetPlaceholderNames(placeholder)); } } } + + /// + /// Reports the inline expression placeholders ({{(...)}}) that cannot be parsed. + /// + private static void ValidateInlineExpressions(List elements, List errors) + { + foreach (Paragraph paragraph in EnumerateParagraphs(elements)) + { + InlineExpressionValidation.CheckSyntax(ParagraphTextModel.GetText(paragraph), errors); + } + } } diff --git a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs index cf5ec4c..b9cb181 100644 --- a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs +++ b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs @@ -65,6 +65,7 @@ public ValidationResult Validate(Stream templateStream, IReadOnlyDictionary allP { foreach (XElement paragraph in GetParagraphs(container)) { - allPlaceholders.UnionWith(PlaceholderScanner.GetUniqueVariableNames(OdtParagraphTextModel.GetText(paragraph))); + allPlaceholders.UnionWith(PlaceholderScanner.GetUniqueVariableNames(OdtParagraphTextModel.GetText(paragraph)) + .SelectMany(InlineExpressionValidation.GetPlaceholderNames)); + } + } + + /// Reports the inline expression placeholders ({{(...)}}) that cannot be parsed. + private static void ValidateInlineExpressions(XElement container, List errors) + { + foreach (XElement paragraph in GetParagraphs(container)) + { + InlineExpressionValidation.CheckSyntax(OdtParagraphTextModel.GetText(paragraph), errors); } } @@ -472,7 +483,19 @@ private void ValidateText(string text) { foreach (string placeholder in PlaceholderScanner.GetUniqueVariableNames(text)) { - _allPlaceholders.Add(placeholder); + _allPlaceholders.UnionWith(InlineExpressionValidation.GetPlaceholderNames(placeholder)); + + if (placeholder.StartsWith('(')) + { + foreach (string variable in InlineExpressionValidation.GetMissingVariables(placeholder, CanResolveInScope)) + { + ReportMissing( + variable, + $"Variable '{variable}' is referenced in expression '{placeholder}' but not provided in the data."); + } + + continue; + } if (placeholder.StartsWith('@') || placeholder.StartsWith('.') || placeholder == "this") { diff --git a/TriasDev.Templify/README.md b/TriasDev.Templify/README.md index ca27585..1110d9e 100644 --- a/TriasDev.Templify/README.md +++ b/TriasDev.Templify/README.md @@ -1355,7 +1355,7 @@ Result of template processing operation. ### ValidationResult - `IsValid` (no errors), `Errors` (`ValidationError` with `Type`, `Message`, `Location`); when data is passed, each missing variable is an error of type `MissingVariable`; a template that cannot be read (unsupported format, corrupted package) passed to `TemplateProcessor` or `OdtTemplateProcessor` is an error of type `InvalidDocument` -- `AllPlaceholders`, `MissingVariables` +- `AllPlaceholders`, `MissingVariables` (an inline expression such as `{{(A and B)}}` contributes the variables it references; an expression that cannot be parsed is an `InvalidConditionalExpression` error) - `Warnings` (`ValidationWarning` of type `EmptyLoopCollection`, `ReservedWordAsVariable` or `MissingConditionVariable`: a condition tests a variable missing from the data on its own, e.g. `{{#if Missing}}`; not added to `MissingVariables`, `IsValid` is unchanged) ### TextTemplateProcessor diff --git a/docs/for-developers/quick-start.md b/docs/for-developers/quick-start.md index 56f2ff4..5af2138 100644 --- a/docs/for-developers/quick-start.md +++ b/docs/for-developers/quick-start.md @@ -246,6 +246,16 @@ text `Active` when there is no such variable) or for the other operators, and no The variable is not added to `MissingVariables` and is not a `MissingVariable` error, so existing validation code keeps its result. The warning is the same for Word and OpenDocument templates. +Inline expressions (`{{(A and B)}}`, `{{(Price > 100):yesno}}`) are validated through the parsed expression, like +processing evaluates them. `AllPlaceholders` lists the variables they reference (`A`, `B`, `Price`), not the +expression text. An expression that cannot be parsed (`{{(A and)}}`, `{{(A && B)}}`) is an +`InvalidConditionalExpression` error, also without data. With data, each referenced variable that processing reads +from the data and that is missing is a `MissingVariable` error and is listed in `MissingVariables`: the operands +tested for truthiness (`{{(A and Missing)}}`, `{{(not Missing)}}`) and the variable operands of `in`, `contains`, +`startswith` and `endswith`. As in processing, comparison operands are not reported (`{{(Status = Active)}}` compares +with the text `Active` when there is no such variable), nor the operands of `exists`, `is empty` and `is not empty`. +Inline expressions get no `MissingConditionVariable` warning, because their missing operands are already errors. + `ValidationError.Type` is one of these `ValidationErrorType` values: | Type | Meaning | @@ -254,7 +264,7 @@ keeps its result. The warning is the same for Word and OpenDocument templates. | `UnmatchedLoopStart` / `UnmatchedLoopEnd` | A `{{#foreach}}` without `{{/foreach}}`, or the other way round | | `InvalidPlaceholderSyntax` | Invalid template syntax, for example an invalid loop variable name | | `MissingVariable` | A variable or loop collection is not in the data (only when data is passed) | -| `InvalidConditionalExpression` | A condition cannot be parsed | +| `InvalidConditionalExpression` | A condition or an inline `{{(...)}}` expression cannot be parsed | | `InvalidDocument` | The input cannot be read as a supported template: unsupported format, unreadable stream, or corrupted or rejected package | `InvalidDocument` is reported by `TemplateProcessor` and `OdtTemplateProcessor`. `DocumentTemplateProcessor` still