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