diff --git a/TriasDev.Templify.Tests/Conditionals/CollectBareOperandVariablesTests.cs b/TriasDev.Templify.Tests/Conditionals/CollectBareOperandVariablesTests.cs new file mode 100644 index 0000000..e3c79d5 --- /dev/null +++ b/TriasDev.Templify.Tests/Conditionals/CollectBareOperandVariablesTests.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.Conditionals; + +/// +/// finds the variables evaluated for truthiness on +/// their own. +/// +public sealed class CollectBareOperandVariablesTests +{ + [Theory] + [InlineData("A", "A")] + [InlineData("not A", "A")] + [InlineData("A and B", "A,B")] + [InlineData("A or not B", "A,B")] + [InlineData("(A or B) and not (C and D)", "A,B,C,D")] + [InlineData("A and Status = \"x\"", "A")] + [InlineData("Customer.Name", "Customer.Name")] + [InlineData("Items[0].Flag or [Exists]", "Items[0].Flag,Exists")] + [InlineData("(A and B) = true", "A,B")] + [InlineData("not (A = B)", "")] + [InlineData("A exists", "")] + [InlineData("A is empty or B is not empty", "")] + [InlineData("Status = Active", "")] + [InlineData("A != B", "")] + [InlineData("A > 1", "")] + [InlineData("A in (B, C)", "")] + [InlineData("A contains B", "")] + [InlineData("A startswith B or C endswith D", "")] + [InlineData("true and not null", "")] + [InlineData("\"text\"", "")] + public void CollectsBareOperands(string expression, string expected) + { + IEnumerable paths = ConditionalEvaluator + .CollectBareOperandVariables(ConditionalEvaluator.Parse(expression)) + .Select(v => v.Path); + + Assert.Equal(expected, string.Join(",", paths)); + } +} diff --git a/TriasDev.Templify.Tests/Integration/ValidationMissingConditionVariableTests.cs b/TriasDev.Templify.Tests/Integration/ValidationMissingConditionVariableTests.cs new file mode 100644 index 0000000..62f4906 --- /dev/null +++ b/TriasDev.Templify.Tests/Integration/ValidationMissingConditionVariableTests.cs @@ -0,0 +1,256 @@ +// 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(template, data) warns () when a +/// condition evaluates a variable that is missing from the data for truthiness on its own. Every case is validated as +/// a Word and as an OpenDocument template, directly and through the facade. +/// +public sealed class ValidationMissingConditionVariableTests +{ + private static Dictionary Data() => new Dictionary + { + ["A"] = true, + ["Status"] = "Active", + ["Role"] = "Admin", + ["Global"] = "g", + ["Items"] = new List> + { + new() { ["Title"] = "i1", ["Active"] = true }, + new() { ["Title"] = "i2" }, + }, + ["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.Warnings), Describe(other.Warnings)); + Assert.Equal(word.Errors.Select(e => e.Message).Order(), other.Errors.Select(e => e.Message).Order()); + Assert.Equal(word.MissingVariables, other.MissingVariables); + } + + return word; + } + + private static List Describe(IEnumerable warnings) => + warnings.Select(w => $"{w.Type}: {w.Message} @ {w.Location}").ToList(); + + private static List ConditionWarnings(ValidationResult result) => + result.Warnings.Where(w => w.Type == ValidationWarningType.MissingConditionVariable).ToList(); + + public static TheoryData WarningCases => new TheoryData + { + { "Missing", new[] { "{{#if Missing}}", "x", "{{/if}}" }, "{{#if Missing}}" }, + { "Missing", new[] { "{{#if not Missing}}", "x", "{{/if}}" }, "{{#if not Missing}}" }, + { "Missing", new[] { "{{#if A and Missing}}", "x", "{{/if}}" }, "{{#if A and Missing}}" }, + { "Missing", new[] { "{{#if Missing or Status = \"Active\"}}", "x", "{{/if}}" }, "{{#if Missing or Status = \"Active\"}}" }, + { "Missing", new[] { "{{#if (A or Missing) and not A}}", "x", "{{/if}}" }, "{{#if (A or Missing) and not A}}" }, + { "Missing", new[] { "{{#if A}}", "a", "{{#elseif Missing}}", "b", "{{/if}}" }, "{{#elseif Missing}}" }, + { "Missing", new[] { "Text {{#if Missing}}x{{/if}} end" }, "{{#if Missing}}" }, + { "Customer.Name", new[] { "{{#if Customer.Name}}", "x", "{{/if}}" }, "{{#if Customer.Name}}" }, + }; + + [Theory] + [MemberData(nameof(WarningCases))] + public void BareMissingOperand_Warns(string variable, string[] paragraphs, string location) + { + ValidationResult result = Validate(Data(), paragraphs); + + ValidationWarning warning = Assert.Single(ConditionWarnings(result)); + Assert.Contains($"'{variable}'", warning.Message); + Assert.Contains($"Condition '{location[(location.IndexOf(' ') + 1)..^2]}'", warning.Message); + Assert.Equal(location, warning.Location); + + // Warning only: no error, no missing variable, still valid. + Assert.True(result.IsValid, string.Join("; ", result.Errors.Select(e => e.Message))); + Assert.Empty(result.MissingVariables); + } + + public static TheoryData NoWarningConditions => new TheoryData + { + "A", + "not A", + "A and Status = \"Active\"", + "Missing exists", + "not (Missing exists)", + "Missing is empty", + "Missing is not empty", + "A and Missing is empty", + "Status = Active", + "Missing = \"x\"", + "Missing != Other", + "Role in (\"Admin\", \"Owner\")", + "Missing in (\"Admin\", Other)", + "Missing contains \"x\"", + "Status startswith Missing", + "true", + "not null", + "\"text\"", + "1", + }; + + [Theory] + [MemberData(nameof(NoWarningConditions))] + public void NonBareOrResolvedOperand_DoesNotWarn(string condition) + { + ValidationResult result = Validate(Data(), $"{{{{#if {condition}}}}}", "x", "{{/if}}"); + + Assert.Empty(ConditionWarnings(result)); + } + + [Fact] + public void WithoutData_DoesNotWarn() + { + ValidationResult result = Validate(null, "{{#if Missing}}", "x", "{{/if}}"); + + Assert.Empty(result.Warnings); + Assert.True(result.IsValid); + } + + [Fact] + public void InvalidCondition_IsAnErrorOnly() + { + ValidationResult result = Validate(Data(), "{{#if Missing and}}", "x", "{{/if}}"); + + Assert.Empty(ConditionWarnings(result)); + Assert.Contains(result.Errors, e => e.Type == ValidationErrorType.InvalidConditionalExpression); + } + + [Fact] + public void SameConditionTwice_WarnsOnce() + { + ValidationResult result = Validate( + Data(), + "{{#if Missing}}", "x", "{{/if}}", + "{{#if Missing}}", "y", "{{/if}}"); + + Assert.Single(ConditionWarnings(result)); + } + + [Fact] + public void SeveralMissingOperands_WarnEach() + { + ValidationResult result = Validate(Data(), "{{#if First or not Second}}", "x", "{{/if}}"); + + Assert.Equal( + new[] { "First", "Second" }, + ConditionWarnings(result).Select(w => w.Message.Contains("'First'") ? "First" : "Second").Order()); + } + + [Fact] + public void InLoop_ItemPropertiesNamedVariablesMetadataAndGlobals_Resolve() + { + ValidationResult result = Validate( + Data(), + "{{#foreach item in Items}}", + "{{#if item.Active}}a{{/if}} {{#if Active}}b{{/if}} {{#if item}}c{{/if}} {{#if Title and Global}}d{{/if}}", + "{{#if @first}}e{{/if}} {{#if not @last and @index}}f{{/if}} {{#if this}}g{{/if}}", + "{{/foreach}}"); + + Assert.Empty(ConditionWarnings(result)); + Assert.True(result.IsValid, string.Join("; ", result.Errors.Select(e => e.Message))); + } + + [Fact] + public void InLoop_MissingItemPropertyOrVariable_Warns() + { + ValidationResult result = Validate( + Data(), + "{{#foreach item in Items}}", + "{{#if item.Nope}}", "a", "{{/if}}", + "{{#if Nope}}", "b", "{{/if}}", + "{{/foreach}}"); + + Assert.Equal( + new[] { "Condition 'Nope' uses variable 'Nope'", "Condition 'item.Nope' uses variable 'item.Nope'" }, + ConditionWarnings(result).Select(w => w.Message[..w.Message.IndexOf(", which", StringComparison.Ordinal)]).Order(StringComparer.Ordinal)); + } + + [Fact] + public void ItemPropertyOutsideTheLoop_Warns() + { + ValidationResult result = Validate( + Data(), + "{{#foreach Items}}", "{{#if Active}}a{{/if}}", "{{/foreach}}", + "{{#if Active}}", "b", "{{/if}}"); + + Assert.Single(ConditionWarnings(result)); + } + + [Fact] + public void InEmptyLoop_IsNotChecked() + { + ValidationResult result = Validate(Data(), "{{#foreach Empty}}", "{{#if Nope}}x{{/if}}", "{{/foreach}}"); + + Assert.Empty(ConditionWarnings(result)); + } + + [Fact] + public void InlineExpression_IsNotChecked() + { + ValidationResult result = Validate(Data(), "{{(Missing and A)}} {{(not Missing):yesno}}"); + + Assert.Empty(ConditionWarnings(result)); + } + + [Fact] + public void TableRowConditionalAndHeader_Warn() + { + DocumentBuilder docx = new DocumentBuilder(); + docx.AddTable(3, 1, (row, _) => row switch + { + 0 => "{{#if RowFlag}}", + 1 => "x", + _ => "{{/if}}" + }); + docx.AddHeader("{{#if HeaderFlag}}h{{/if}}"); + + OdtDocumentBuilder odt = new OdtDocumentBuilder(); + odt.AddTable(new[] { "{{#if RowFlag}}" }, new[] { "x" }, new[] { "{{/if}}" }); + odt.AddHeaderParagraph("{{#if HeaderFlag}}h{{/if}}"); + + ValidationResult result = ValidateAll(docx.ToStream().ToArray(), odt.ToBytes(), Data()); + + Assert.Equal(2, ConditionWarnings(result).Count); + Assert.Contains(result.Warnings, w => w.Message.Contains("'RowFlag'")); + Assert.Contains(result.Warnings, w => w.Message.Contains("'HeaderFlag'")); + } +} diff --git a/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs b/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs index 540a61d..1773864 100644 --- a/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs +++ b/TriasDev.Templify.Tests/Odt/OdtValidationParityTests.cs @@ -44,6 +44,15 @@ public sealed class OdtValidationParityTests { "inline conditional", new[] { "{{Name}}{{#if Flag}} (flag {{Title}}){{/if}}" } }, { "nested invalid iteration variable", new[] { "{{#foreach Items}}", "{{#foreach in in Tags}}", "x", "{{/foreach}}", "{{/foreach}}" } }, { "unmatched loop with body", new[] { "{{Name}} {{Missing}}", "{{#foreach Items}}", "{{Title}} {{Nope}}" } }, + { + "missing bare condition operands", + new[] + { + "{{#if Nope}}", "a", "{{#elseif not Other and A}}", "b", "{{/if}}", + "x {{#if Absent or Missing exists}}y{{/if}} {{#if Nope is empty or Status = Nope}}z{{/if}}", + "{{#foreach item in Items}}", "{{#if item.Active and not item.Nope and Title and @first}}", "c", "{{/if}}", "{{/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 7f70847..94c9dfb 100644 --- a/TriasDev.Templify/ARCHITECTURE.md +++ b/TriasDev.Templify/ARCHITECTURE.md @@ -209,7 +209,9 @@ expression string - **`Core/TemplateValidator.cs`** (`ValidateTemplate`) walks body, headers/footers and notes: it reports unmatched markers and invalid conditions (`ValidationError`), collects `AllPlaceholders` (condition variables are taken from the AST), and with data reports `MissingVariables` plus warnings (`EmptyLoopCollection` when - `WarnOnEmptyLoopCollections` is on, `ReservedWordAsVariable`). Missing variables are checked by + `WarnOnEmptyLoopCollections` is on, `ReservedWordAsVariable`, and `MissingConditionVariable` for bare condition + operands missing from the data, found in the AST by `ConditionalEvaluator.CollectBareOperandVariables` and checked + in loop scope by `Core/MissingConditionVariableCheck.cs`). Missing variables are checked by `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). diff --git a/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs b/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs index d17fa7e..bfb20a7 100644 --- a/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs +++ b/TriasDev.Templify/Conditionals/ConditionalEvaluator.cs @@ -312,6 +312,44 @@ internal static Engine.ConditionNode Parse(string expression) } } + /// + /// Collects the variables of a parsed expression that are evaluated for truthiness on their own: the whole + /// expression (Flag) and the operands of and, or and not. + /// + /// + /// Operands of comparisons (Status = Active, where a missing bareword falls back to a string literal), + /// of exists / is empty / is not empty (designed for missing values), of in and of + /// the string operators are not bare operands. Nested operator nodes are always descended into, so the + /// operands of a logical operator nested in a comparison ((A and B) = true) are still found. + /// + internal static IEnumerable CollectBareOperandVariables(Engine.ConditionNode node) + { + return Collect(node, isBooleanContext: true); + + static IEnumerable Collect(Engine.ConditionNode node, bool isBooleanContext) + { + switch (node) + { + case Engine.VariableNode variable when isBooleanContext: + yield return variable; + break; + case Engine.OperatorNode op: + bool isLogical = op.Operator is Engine.Operators.AndOperator + or Engine.Operators.OrOperator + or Engine.Operators.NotOperator; + foreach (Engine.ConditionNode operand in op.Operands) + { + foreach (Engine.VariableNode v in Collect(operand, isLogical)) + { + yield return v; + } + } + + break; + } + } + } + /// /// Evaluates a conditional expression (backward compatibility bridge). /// diff --git a/TriasDev.Templify/Core/MissingConditionVariableCheck.cs b/TriasDev.Templify/Core/MissingConditionVariableCheck.cs new file mode 100644 index 0000000..d5c20c8 --- /dev/null +++ b/TriasDev.Templify/Core/MissingConditionVariableCheck.cs @@ -0,0 +1,66 @@ +// 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.Text.RegularExpressions; +using TriasDev.Templify.Conditionals; + +namespace TriasDev.Templify.Core; + +/// +/// Reports the variables that a condition evaluates for truthiness on their own (bare operands, e.g. +/// {{#if Missing}} or {{#if A and not Missing}}) and that cannot be resolved in the data. +/// Shared by the Word and the OpenDocument validators, which supply the loop-scoped resolution. +/// +/// +/// A missing bare operand silently evaluates to , which usually hides a typo or a data +/// problem, so validation with data reports a +/// warning. The warning is not a error and does not affect +/// or . +/// +internal static class MissingConditionVariableCheck +{ + /// + /// Checks the {{#if}}/{{#elseif}} conditions in (block, inline and + /// table-row markers) and adds one warning per condition and missing variable. + /// + /// The text of a paragraph. + /// Resolves a variable path in the current (loop) scope. + /// The warnings; a warning that is already present is not added again. + public static void Check(string text, Func canResolve, List warnings) + { + if (text.IndexOf("{{#", StringComparison.Ordinal) < 0) + { + return; + } + + ConditionalEvaluator evaluator = new ConditionalEvaluator(); + foreach (Match match in ConditionalPatterns.IfStart.Matches(text).Concat(ConditionalPatterns.ElseIf.Matches(text))) + { + string expression = match.Groups[1].Value.Trim(); + + // Invalid conditions are reported as errors by the syntax validation. + if (!evaluator.Validate(expression).IsValid) + { + continue; + } + + foreach (Conditionals.Engine.VariableNode variable in ConditionalEvaluator.CollectBareOperandVariables(ConditionalEvaluator.Parse(expression))) + { + string path = variable.Path; + + // Loop metadata and the current item are resolved by the loop, not by the data. + if (path.StartsWith('@') || path.StartsWith('.') || path == "this" || canResolve(path)) + { + continue; + } + + string message = $"Condition '{expression}' uses variable '{path}', which is not provided in the data. " + + "It is treated as false."; + if (!warnings.Any(w => w.Type == ValidationWarningType.MissingConditionVariable && w.Message == message)) + { + warnings.Add(ValidationWarning.Create(ValidationWarningType.MissingConditionVariable, message, match.Value)); + } + } + } + } +} diff --git a/TriasDev.Templify/Core/ScopedVariableValidator.cs b/TriasDev.Templify/Core/ScopedVariableValidator.cs index abe899d..453cc98 100644 --- a/TriasDev.Templify/Core/ScopedVariableValidator.cs +++ b/TriasDev.Templify/Core/ScopedVariableValidator.cs @@ -219,6 +219,8 @@ private void ValidateText(string text) $"Variable '{placeholder}' is referenced in the template but not provided in the data."); } } + + MissingConditionVariableCheck.Check(text, CanResolveInScope, _warnings); } private void ReportMissing(string variable, string message) diff --git a/TriasDev.Templify/Core/ValidationResult.cs b/TriasDev.Templify/Core/ValidationResult.cs index d63554c..4ca6b4b 100644 --- a/TriasDev.Templify/Core/ValidationResult.cs +++ b/TriasDev.Templify/Core/ValidationResult.cs @@ -231,5 +231,18 @@ public enum ValidationWarningType /// variable with that name. It is evaluated as the variable, but the bracketed form ([Exists]) is /// recommended because the word is also an operator. /// - ReservedWordAsVariable + ReservedWordAsVariable, + + /// + /// A condition evaluates a variable for truthiness on its own (a bare operand, e.g. {{#if Missing}}, + /// {{#if not Missing}} or {{#if A and Missing}}) and the variable is not provided in the data, + /// so it is treated as . Reported only when the template is validated with data. + /// + /// + /// Operands of exists, is empty and is not empty (designed for missing values) and + /// comparison operands (Status = Active, where an unknown bareword is a string literal) are not + /// reported. The variable is not added to and the warning does + /// not affect . + /// + MissingConditionVariable = 2 } diff --git a/TriasDev.Templify/Examples.md b/TriasDev.Templify/Examples.md index 2f5543b..f07649d 100644 --- a/TriasDev.Templify/Examples.md +++ b/TriasDev.Templify/Examples.md @@ -1592,7 +1592,7 @@ Console.WriteLine($"Missing: {string.Join(", ", validation.MissingVariables)}"); foreach (ValidationWarning warning in validation.Warnings) { - // e.g. EmptyLoopCollection (see WarnOnEmptyLoopCollections), ReservedWordAsVariable + // e.g. EmptyLoopCollection (see WarnOnEmptyLoopCollections), ReservedWordAsVariable, MissingConditionVariable Console.WriteLine($"{warning.Type}: {warning.Message}"); } ``` diff --git a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs index 4e2fb80..cf5ec4c 100644 --- a/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs +++ b/TriasDev.Templify/OpenDocument/OdtTemplateValidator.cs @@ -486,6 +486,8 @@ private void ValidateText(string text) $"Variable '{placeholder}' is referenced in the template but not provided in the data."); } } + + MissingConditionVariableCheck.Check(text, CanResolveInScope, _warnings); } private void ReportMissing(string variable, string message) diff --git a/TriasDev.Templify/PublicAPI.Unshipped.txt b/TriasDev.Templify/PublicAPI.Unshipped.txt index 266dc17..3f667fc 100644 --- a/TriasDev.Templify/PublicAPI.Unshipped.txt +++ b/TriasDev.Templify/PublicAPI.Unshipped.txt @@ -33,3 +33,4 @@ TriasDev.Templify.Core.TemplateProcessor.ValidateTemplate(System.IO.Stream! temp TriasDev.Templify.Core.TemplateProcessor.ValidateTemplate(System.IO.Stream! templateStream, System.Collections.Generic.IReadOnlyDictionary! data) -> TriasDev.Templify.Core.ValidationResult! static TriasDev.Templify.Core.TemplateProcessor.DetectFormat(System.IO.Stream! templateStream) -> TriasDev.Templify.Core.TemplateFormat TriasDev.Templify.Core.ValidationErrorType.InvalidDocument = 7 -> TriasDev.Templify.Core.ValidationErrorType +TriasDev.Templify.Core.ValidationWarningType.MissingConditionVariable = 2 -> TriasDev.Templify.Core.ValidationWarningType diff --git a/TriasDev.Templify/README.md b/TriasDev.Templify/README.md index c17509b..ca27585 100644 --- a/TriasDev.Templify/README.md +++ b/TriasDev.Templify/README.md @@ -1356,7 +1356,7 @@ Result of template processing operation. - `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` -- `Warnings` (`ValidationWarning` of type `EmptyLoopCollection` or `ReservedWordAsVariable`) +- `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/condition-evaluation.md b/docs/for-developers/condition-evaluation.md index 0323a36..3466a71 100644 --- a/docs/for-developers/condition-evaluation.md +++ b/docs/for-developers/condition-evaluation.md @@ -223,7 +223,9 @@ evaluator.Evaluate("[Empty] = \"yes\" and not [Not]", data); evaluator.Evaluate("[Exists].Count > 0", data); ``` -`ValidateTemplate(template, data)` reports a `ReservedWordAsVariable` warning when a condition uses a bare keyword as a variable that exists in the data, recommending the bracketed form. +`ValidateTemplate(template, data)` reports a `ReservedWordAsVariable` warning when a condition uses a bare keyword as a variable that exists in the data, recommending the bracketed form. It reports a `MissingConditionVariable` warning when a condition tests a variable +that is not in the data for truthiness on its own (`{{#if Missing}}`, `{{#if A and not Missing}}`); operands of +comparisons, `exists` and `is empty` are not reported. **Quote string literals** that could collide with a keyword (`= "empty"` rather than `= empty`). diff --git a/docs/for-developers/quick-start.md b/docs/for-developers/quick-start.md index b211a2b..9ac09d4 100644 --- a/docs/for-developers/quick-start.md +++ b/docs/for-developers/quick-start.md @@ -222,7 +222,26 @@ Console.WriteLine(string.Join(", ", validation.MissingVariables)); Each missing variable or loop collection is reported as an error of type `MissingVariable` (so `IsValid` is `false` for that data) and listed in `MissingVariables`; call `ValidateTemplate(template)` without data to check the syntax only. `validation.Warnings` reports loops over empty collections (`EmptyLoopCollection`, controlled by -`WarnOnEmptyLoopCollections`) and condition keywords used as variable names (`ReservedWordAsVariable`). +`WarnOnEmptyLoopCollections`), condition keywords used as variable names (`ReservedWordAsVariable`) and conditions +that test a variable missing from the data on its own (`MissingConditionVariable`, see below). Warnings never make +`IsValid` false. + +`ValidationWarning.Type` is one of these `ValidationWarningType` values: + +| Type | Meaning | +|------|---------| +| `EmptyLoopCollection` | A loop collection is empty, so the loop content could not be checked (only with `WarnOnEmptyLoopCollections`) | +| `ReservedWordAsVariable` | A condition uses a bare keyword (`Exists`, `Empty`, ...) as a variable that is in the data; write `[Exists]` | +| `MissingConditionVariable` | A condition tests a variable that is not in the data for truthiness on its own, so it is treated as false | + +`MissingConditionVariable` covers variables used as bare operands: the whole condition (`{{#if Missing}}`, +`{{#elseif Missing}}`, inline `{{#if Missing}}...{{/if}}`) and the operands of `and`, `or` and `not` +(`{{#if not Missing}}`, `{{#if A and Missing}}`). Inside loops, item properties, named iteration variables and loop +metadata (`@first`, ...) are resolved as in processing. It is not reported for the operands of `exists`, `is empty` +and `is not empty` (they are meant for missing values), for comparison operands (`Status = Active` compares with the +text `Active` when there is no such variable) or for the other operators, and not for inline `{{(...)}}` expressions. +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. `ValidationError.Type` is one of these `ValidationErrorType` values: diff --git a/docs/for-template-authors/conditionals.md b/docs/for-template-authors/conditionals.md index 2b90619..e7c36b9 100644 --- a/docs/for-template-authors/conditionals.md +++ b/docs/for-template-authors/conditionals.md @@ -18,6 +18,8 @@ Conditionals let you show or hide content in your document based on data values. **Falsy values** (content is hidden): missing/`null`, `false`, empty or whitespace text, the text `"false"` or `"0"` (any casing, e.g. `"False"`), **any numeric zero** (`0`, `0.0`, `0.00`, JSON `0.0`, and zero of every numeric type such as `decimal` or `long`), `NaN`, and empty lists. Everything else is truthy, including any other text (`"no"` is truthy!). +> **Misspelled or missing variables** are silently false: `{{#if IsActiv}}` never shows its content. When a developer validates the template with data (`ValidateTemplate`), a condition that tests a missing variable on its own (`{{#if Missing}}`, `{{#if not Missing}}`, `{{#if A and Missing}}`, also in `{{#elseif}}`) produces a `MissingConditionVariable` warning. Use `{{#if Missing exists}}` or `{{#if Missing is empty}}` when a variable is meant to be optional: those checks are not reported. + > **Markers in their own paragraphs:** in the block form, each marker (`{{#if}}`, `{{#elseif}}`, `{{#else}}`, `{{/if}}`) should be alone in its paragraph. A marker paragraph is removed completely, so any other text in it is lost. To change only part of a paragraph, put all markers in that paragraph ([inline conditionals](#inline-conditionals)). > > Marker keywords are case-insensitive (`{{#IF}}` works), but variable names are not.