From 96a3e983c332239c6032325ea9964e6246aa1e3a Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Tue, 23 Dec 2025 15:08:59 +0100 Subject: [PATCH 1/4] feat: add named iteration variable syntax for nested loops Add support for explicit iteration variable names in foreach loops: {{#foreach item in Items}}...{{/foreach}} This enables access to parent loop variables in nested loops: {{#foreach category in Categories}} {{#foreach product in category.Products}} {{category.Name}}: {{product.Name}} {{/foreach}} {{/foreach}} - Update LoopDetector regex to parse "item in Collection" syntax - Add IterationVariableName property to LoopBlock and LoopContext - Update LoopContext.TryResolveVariable to handle named variables - Update TemplateValidator to validate named iteration variables - Add 8 integration tests for named iteration variables - Update documentation (loops.md, CLAUDE.md, README.md) Both implicit ({{Name}}) and explicit ({{item.Name}}) syntax work when using named iteration variables, maintaining backward compatibility. Closes #58 --- CLAUDE.md | 13 + README.md | 2 +- .../ConditionalEvaluatorTests.cs | 2 +- .../NamedIterationVariableIntegrationTests.cs | 378 ++++++++++++++++++ .../LoopContextPrimitiveTests.cs | 6 +- TriasDev.Templify.Tests/LoopContextTests.cs | 12 +- .../LoopEvaluationContextTests.cs | 1 + .../Visitors/CompositeVisitorTests.cs | 1 + .../Visitors/ConditionalVisitorTests.cs | 1 + .../Visitors/LoopVisitorTests.cs | 8 + .../Visitors/PlaceholderVisitorTests.cs | 1 + .../Visitors/TemplateElementTests.cs | 1 + TriasDev.Templify/Core/TemplateValidator.cs | 43 +- TriasDev.Templify/Loops/LoopBlock.cs | 12 +- TriasDev.Templify/Loops/LoopContext.cs | 37 +- TriasDev.Templify/Loops/LoopDetector.cs | 21 +- TriasDev.Templify/Loops/LoopProcessor.cs | 1 + TriasDev.Templify/Visitors/LoopVisitor.cs | 1 + docs/for-template-authors/loops.md | 79 +++- 19 files changed, 588 insertions(+), 32 deletions(-) create mode 100644 TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 13565a9..c8da73c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,6 +312,19 @@ The library uses a **visitor pattern** for processing Word documents, enabling: {{/foreach}} ``` +**Named iteration variable syntax** (for accessing parent scope in nested loops): +``` +{{#foreach item in CollectionName}} + {{item.PropertyName}} +{{/foreach}} + +{{#foreach category in Categories}} + {{#foreach product in category.Products}} + {{category.Name}}: {{product.Name}} ← Access parent loop variable + {{/foreach}} +{{/foreach}} +``` + **Loop metadata:** `{{@index}}`, `{{@first}}`, `{{@last}}`, `{{@count}}` ### Markdown Syntax diff --git a/README.md b/README.md index baa148a..284da7f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Templify is a focused .NET library built on the OpenXML SDK that enables dynamic - ✨ Markdown formatting in variable values: `**bold**`, `*italic*`, `~~strikethrough~~` - â†Šī¸ Line breaks in variable values: `"Line 1\nLine 2"` renders as separate lines - 🔀 Conditional blocks: `{{#if}}...{{#elseif}}...{{#else}}...{{/if}}` -- 🔁 Loops and iterations: `{{#foreach collection}}...{{/foreach}}` +- 🔁 Loops and iterations: `{{#foreach collection}}...{{/foreach}}` or `{{#foreach item in collection}}...{{/foreach}}` - đŸŒŗ Nested data structures with dot notation and array indexing - 🎨 Automatic formatting preservation (bold, italic, fonts, colors) - 📊 Full table support including row loops diff --git a/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs b/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs index 15e879a..a78d82d 100644 --- a/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs +++ b/TriasDev.Templify.Tests/ConditionalEvaluatorTests.cs @@ -866,7 +866,7 @@ public void Evaluate_DeepNestedPath_InLoopContext_WhenItemHasSamePath_ReturnsTru object loopContext = Activator.CreateInstance( loopContextType, - new object[] { firstItem, 0, 1, "data.assets.items", null! })!; + new object[] { firstItem, 0, 1, "data.assets.items", null!, null! })!; IEvaluationContext loopEvalContext = (IEvaluationContext)Activator.CreateInstance( loopEvalContextType, diff --git a/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs new file mode 100644 index 0000000..fda6553 --- /dev/null +++ b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs @@ -0,0 +1,378 @@ +// Copyright (c) 2025 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; + +/// +/// Integration tests for named iteration variable syntax ({{#foreach item in Items}}). +/// Tests the ability to explicitly reference loop variables and access parent scope. +/// +public sealed class NamedIterationVariableIntegrationTests +{ + // Test data classes + private class Category + { + public string Name { get; set; } = ""; + public List Products { get; set; } = new List(); + } + + private class Product + { + public string Name { get; set; } = ""; + public decimal Price { get; set; } + } + + private class Order + { + public string OrderId { get; set; } = ""; + public string CustomerName { get; set; } = ""; + public List Items { get; set; } = new List(); + } + + private class LineItem + { + public string Product { get; set; } = ""; + public int Quantity { get; set; } + } + + [Fact] + public void ProcessTemplate_NamedVariable_SimpleLoop() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("Products:"); + builder.AddParagraph("{{#foreach product in Products}}"); + builder.AddParagraph("- {{product.Name}}: {{product.Price}} EUR"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Products"] = new List + { + new Product { Name = "Widget", Price = 19.99m }, + new Product { Name = "Gadget", Price = 29.99m } + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + Assert.Equal(3, paragraphs.Count); + Assert.Equal("Products:", paragraphs[0]); + Assert.Contains("Widget", paragraphs[1]); + Assert.True(paragraphs[1].Contains("19.99") || paragraphs[1].Contains("19,99")); // Handle locale differences + Assert.Contains("Gadget", paragraphs[2]); + Assert.True(paragraphs[2].Contains("29.99") || paragraphs[2].Contains("29,99")); // Handle locale differences + } + + [Fact] + public void ProcessTemplate_NamedVariable_NestedLoops_AccessParent() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach category in Categories}}"); + builder.AddParagraph("Category: {{category.Name}}"); + builder.AddParagraph("{{#foreach product in category.Products}}"); + builder.AddParagraph(" - {{category.Name}}: {{product.Name}} ({{product.Price}} EUR)"); + builder.AddParagraph("{{/foreach}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Categories"] = new List + { + new Category + { + Name = "Electronics", + Products = new List + { + new Product { Name = "Phone", Price = 599.00m }, + new Product { Name = "Tablet", Price = 399.00m } + } + }, + new Category + { + Name = "Books", + Products = new List + { + new Product { Name = "C# Guide", Price = 49.99m } + } + } + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + // Expected: Category header + products for Electronics, Category header + products for Books + Assert.Contains(paragraphs, p => p.Contains("Category: Electronics")); + Assert.Contains(paragraphs, p => p.Contains("Electronics: Phone")); + Assert.Contains(paragraphs, p => p.Contains("Electronics: Tablet")); + Assert.Contains(paragraphs, p => p.Contains("Category: Books")); + Assert.Contains(paragraphs, p => p.Contains("Books: C# Guide")); + } + + [Fact] + public void ProcessTemplate_NamedVariable_ImplicitSyntaxStillWorks() + { + // Arrange: Use named variable but also implicit syntax + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach product in Products}}"); + builder.AddParagraph("Named: {{product.Name}}, Implicit: {{Name}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Products"] = new List + { + new Product { Name = "Widget", Price = 19.99m } + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + Assert.Single(paragraphs); + Assert.Contains("Named: Widget", paragraphs[0]); + Assert.Contains("Implicit: Widget", paragraphs[0]); + } + + [Fact] + public void ProcessTemplate_BackwardCompatibility_ImplicitSyntax() + { + // Arrange: Old syntax without named variable + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach Products}}"); + builder.AddParagraph("{{Name}}: {{Price}} EUR"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Products"] = new List + { + new Product { Name = "Widget", Price = 19.99m }, + new Product { Name = "Gadget", Price = 29.99m } + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + Assert.Equal(2, paragraphs.Count); + Assert.Contains("Widget", paragraphs[0]); + Assert.Contains("Gadget", paragraphs[1]); + } + + [Fact] + public void ProcessTemplate_NamedVariable_DirectReference() + { + // Arrange: Reference iteration variable directly (for primitive values) + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach name in Names}}"); + builder.AddParagraph("- {{name}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Names"] = new List { "Alice", "Bob", "Charlie" } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + Assert.Equal(3, paragraphs.Count); + Assert.Equal("- Alice", paragraphs[0]); + Assert.Equal("- Bob", paragraphs[1]); + Assert.Equal("- Charlie", paragraphs[2]); + } + + [Fact] + public void ProcessTemplate_NamedVariable_AccessGlobalFromNestedLoop() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("Company: {{CompanyName}}"); + builder.AddParagraph("{{#foreach order in Orders}}"); + builder.AddParagraph("Order {{order.OrderId}} for {{CompanyName}}:"); + builder.AddParagraph("{{#foreach item in order.Items}}"); + builder.AddParagraph(" - {{item.Product}} x{{item.Quantity}}"); + builder.AddParagraph("{{/foreach}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["CompanyName"] = "Acme Corp", + ["Orders"] = new List + { + new Order + { + OrderId = "ORD-001", + Items = new List + { + new LineItem { Product = "Widget", Quantity = 5 } + } + } + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + Assert.Contains(paragraphs, p => p == "Company: Acme Corp"); + Assert.Contains(paragraphs, p => p.Contains("Order ORD-001 for Acme Corp")); + Assert.Contains(paragraphs, p => p.Contains("Widget x5")); + } + + [Fact] + public void ProcessTemplate_NamedVariable_MixedSyntax_NestedLoops() + { + // Arrange: Mix of named and implicit syntax + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach category in Categories}}"); + builder.AddParagraph("{{category.Name}}:"); + builder.AddParagraph("{{#foreach Products}}"); // Implicit syntax for inner loop + builder.AddParagraph(" - {{Name}} from {{category.Name}}"); + builder.AddParagraph("{{/foreach}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Categories"] = new List + { + new Category + { + Name = "Electronics", + Products = new List + { + new Product { Name = "Phone", Price = 599.00m } + } + } + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + Assert.Contains(paragraphs, p => p.Contains("Electronics:")); + Assert.Contains(paragraphs, p => p.Contains("Phone from Electronics")); + } + + [Fact] + public void ProcessTemplate_NamedVariable_WithLoopMetadata() + { + // Arrange + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach item in Items}}"); + builder.AddParagraph("{{@index}}: {{item.Name}}{{#if @last}} (last){{/if}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Items"] = new List + { + new Product { Name = "First", Price = 10m }, + new Product { Name = "Second", Price = 20m }, + new Product { Name = "Third", Price = 30m } + } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + Assert.Equal(3, paragraphs.Count); + Assert.Equal("0: First", paragraphs[0]); + Assert.Equal("1: Second", paragraphs[1]); + Assert.Equal("2: Third (last)", paragraphs[2]); + } +} diff --git a/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs b/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs index 68e0ef3..c5893b5 100644 --- a/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs +++ b/TriasDev.Templify.Tests/LoopContextPrimitiveTests.cs @@ -28,7 +28,7 @@ public void TryResolveVariable_WithDot_ReturnsPrimitiveValue() { // Arrange List items = new List { "Item One", "Item Two", "Item Three" }; - object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!; IList contexts = (IList)result; object firstContext = contexts[0]!; @@ -53,7 +53,7 @@ public void TryResolveVariable_WithThis_ReturnsPrimitiveValue() { // Arrange List items = new List { 10, 20, 30 }; - object result = _createContextsMethod.Invoke(null, new object[] { items, "Numbers", null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, "Numbers", null!, null! })!; IList contexts = (IList)result; object firstContext = contexts[0]!; @@ -71,7 +71,7 @@ public void TryResolveVariable_WithDot_WorksWithDecimals() { // Arrange List items = new List { 99.99m, 149.99m, 249.99m }; - object result = _createContextsMethod.Invoke(null, new object[] { items, "Prices", null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, "Prices", null!, null! })!; IList contexts = (IList)result; object lastContext = contexts[2]!; diff --git a/TriasDev.Templify.Tests/LoopContextTests.cs b/TriasDev.Templify.Tests/LoopContextTests.cs index c118256..de976f2 100644 --- a/TriasDev.Templify.Tests/LoopContextTests.cs +++ b/TriasDev.Templify.Tests/LoopContextTests.cs @@ -38,7 +38,7 @@ public void CreateContexts_WithSimpleList_CreatesCorrectContexts() string collectionName = "Items"; // Act - object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null!, null! })!; IList contexts = (IList)result; // Assert @@ -69,7 +69,7 @@ public void CreateContexts_WithEmptyCollection_ReturnsEmptyList() string collectionName = "Items"; // Act - object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, collectionName, null!, null! })!; IList contexts = (IList)result; // Assert @@ -81,7 +81,7 @@ public void TryResolveVariable_WithMetadata_ReturnsCorrectValues() { // Arrange List items = new List { "First", "Second" }; - object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!; IList contexts = (IList)result; object context = contexts[0]!; @@ -118,7 +118,7 @@ public void TryResolveVariable_WithSimpleProperty_ReturnsValue() { new TestItem { Name = "Item1", Value = 100 } }; - object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!; IList contexts = (IList)result; object context = contexts[0]!; @@ -143,7 +143,7 @@ public void TryResolveVariable_WithNestedProperty_ReturnsValue() Address = new Address { City = "Munich" } } }; - object result = _createContextsMethod.Invoke(null, new object[] { items, "Customers", null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, "Customers", null!, null! })!; IList contexts = (IList)result; object context = contexts[0]!; @@ -164,7 +164,7 @@ public void TryResolveVariable_WithInvalidProperty_ReturnsFalse() { new TestItem { Name = "Item1", Value = 100 } }; - object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null! })!; + object result = _createContextsMethod.Invoke(null, new object[] { items, "Items", null!, null! })!; IList contexts = (IList)result; object context = contexts[0]!; diff --git a/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs b/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs index a606e2d..e00e76d 100644 --- a/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs +++ b/TriasDev.Templify.Tests/LoopEvaluationContextTests.cs @@ -307,6 +307,7 @@ private static object CreateLoopContext( index, count, collectionName, + null, // iterationVariableName parent)!; } diff --git a/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs index 82e1208..586c0d3 100644 --- a/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs +++ b/TriasDev.Templify.Tests/Visitors/CompositeVisitorTests.cs @@ -286,6 +286,7 @@ private static LoopBlock CreateTestLoopBlock() return new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: content, startMarker: startMarker, endMarker: endMarker, diff --git a/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs index 6dc4cf0..d5bc0e4 100644 --- a/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs +++ b/TriasDev.Templify.Tests/Visitors/ConditionalVisitorTests.cs @@ -342,6 +342,7 @@ private static LoopBlock CreateTestLoopBlock() return new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: content, startMarker: startMarker, endMarker: endMarker, diff --git a/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs index 5a19fca..5c99072 100644 --- a/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs +++ b/TriasDev.Templify.Tests/Visitors/LoopVisitorTests.cs @@ -31,6 +31,7 @@ public void VisitLoop_EmptyCollection_RemovesLoopBlock() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content }, startMarker: startMarker, endMarker: endMarker, @@ -67,6 +68,7 @@ public void VisitLoop_MissingCollection_RemovesLoopBlock() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content }, startMarker: startMarker, endMarker: endMarker, @@ -100,6 +102,7 @@ public void VisitLoop_NonCollectionVariable_ThrowsInvalidOperationException() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content }, startMarker: startMarker, endMarker: endMarker, @@ -137,6 +140,7 @@ public void VisitLoop_SimpleCollection_ExpandsLoopContent() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content }, startMarker: startMarker, endMarker: endMarker, @@ -176,6 +180,7 @@ public void VisitLoop_MultipleContentElements_ClonesAll() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content1, content2 }, startMarker: startMarker, endMarker: endMarker, @@ -221,6 +226,7 @@ public void VisitLoop_CreatesLoopEvaluationContext() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content }, startMarker: startMarker, endMarker: endMarker, @@ -258,6 +264,7 @@ public void VisitLoop_ProcessesNestedConstructsViaDocumentWalker() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content }, startMarker: startMarker, endMarker: endMarker, @@ -297,6 +304,7 @@ public void VisitLoop_SingleItem_CreatesOneIteration() LoopBlock loop = new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: new List { content }, startMarker: startMarker, endMarker: endMarker, diff --git a/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs b/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs index 779997f..a839b6c 100644 --- a/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs +++ b/TriasDev.Templify.Tests/Visitors/PlaceholderVisitorTests.cs @@ -354,6 +354,7 @@ private static LoopBlock CreateTestLoopBlock() return new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: content, startMarker: startMarker, endMarker: endMarker, diff --git a/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs b/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs index 6264512..5e84353 100644 --- a/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs +++ b/TriasDev.Templify.Tests/Visitors/TemplateElementTests.cs @@ -204,6 +204,7 @@ private static LoopBlock CreateTestLoopBlock() return new LoopBlock( collectionName: "Items", + iterationVariableName: null, contentElements: content, startMarker: startMarker, endMarker: endMarker, diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs index 5a42519..bf2642e 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -220,8 +220,8 @@ private static void ValidateMissingVariables( bool warnOnEmptyLoopCollections) { ValueResolver resolver = new ValueResolver(); - Stack<(string CollectionName, HashSet Properties)> loopStack = - new Stack<(string CollectionName, HashSet Properties)>(); + Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack = + new Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)>(); // Note: We reuse allPlaceholders from steps 1-4 (conditionals, loops, table loops, regular placeholders). // ValidatePlaceholdersInScope will add any additional placeholders found during recursive processing. @@ -288,7 +288,7 @@ private static string NormalizeQuotes(string text) /// private static void ValidatePlaceholdersInScope( IReadOnlyList elements, - Stack<(string CollectionName, HashSet Properties)> loopStack, + Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack, Dictionary data, HashSet allPlaceholders, HashSet missingVariables, @@ -370,7 +370,7 @@ private static void ValidatePlaceholdersInScope( /// private static void ProcessLoopForValidation( LoopBlock loop, - Stack<(string CollectionName, HashSet Properties)> loopStack, + Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack, Dictionary data, HashSet allPlaceholders, HashSet missingVariables, @@ -420,7 +420,7 @@ private static void ProcessLoopForValidation( } // Recurse into loop content with aggregated properties as scope - loopStack.Push((loop.CollectionName, aggregatedProperties)); + loopStack.Push((loop.CollectionName, loop.IterationVariableName, aggregatedProperties)); ValidatePlaceholdersInScope(loop.ContentElements, loopStack, data, allPlaceholders, missingVariables, warnings, errors, resolver, warnOnEmptyLoopCollections); loopStack.Pop(); } @@ -430,9 +430,9 @@ private static void ProcessLoopForValidation( /// private static bool IsLoopScopedProperty( string name, - Stack<(string CollectionName, HashSet Properties)> loopStack) + Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack) { - foreach ((string _, HashSet properties) in loopStack) + foreach ((string _, string? _, HashSet properties) in loopStack) { if (properties.Contains(name)) { @@ -521,14 +521,37 @@ private static HashSet AggregatePropertiesFromCollection(object collecti /// private static bool CanResolveInScope( string placeholder, - Stack<(string CollectionName, HashSet Properties)> loopStack, + Stack<(string CollectionName, string? IterationVariableName, HashSet Properties)> loopStack, Dictionary data, ValueResolver resolver) { // Try loop scopes (innermost first - stack iteration goes from top to bottom) - foreach ((string _, HashSet properties) in loopStack) + foreach ((string _, string? iterationVariableName, HashSet properties) in loopStack) { - // Direct property match + // Check if accessing via named iteration variable (e.g., "item" or "item.Name") + if (iterationVariableName != null) + { + // Direct reference to iteration variable (e.g., {{item}}) + if (placeholder == iterationVariableName) + { + return true; + } + + // Property access via iteration variable (e.g., {{item.Name}}) + if (placeholder.StartsWith(iterationVariableName + ".", StringComparison.Ordinal)) + { + string propertyPath = placeholder.Substring(iterationVariableName.Length + 1); + // Extract root property from the path + int nextDotIndex = propertyPath.IndexOf('.'); + string rootProperty = nextDotIndex > 0 ? propertyPath.Substring(0, nextDotIndex) : propertyPath; + if (properties.Contains(rootProperty)) + { + return true; + } + } + } + + // Direct property match (implicit syntax) if (properties.Contains(placeholder)) { return true; diff --git a/TriasDev.Templify/Loops/LoopBlock.cs b/TriasDev.Templify/Loops/LoopBlock.cs index c049572..e2348d7 100644 --- a/TriasDev.Templify/Loops/LoopBlock.cs +++ b/TriasDev.Templify/Loops/LoopBlock.cs @@ -7,7 +7,8 @@ namespace TriasDev.Templify.Loops; /// /// Represents a parsed loop block in the document template. -/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax. +/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax +/// and {{#foreach item in CollectionName}}...{{/foreach}} for named iteration variables. /// internal sealed class LoopBlock { @@ -16,6 +17,13 @@ internal sealed class LoopBlock /// public string CollectionName { get; } + /// + /// Gets the name of the iteration variable, or null if using implicit syntax. + /// For {{#foreach item in Items}}, this is "item". + /// For {{#foreach Items}}, this is null (implicit). + /// + public string? IterationVariableName { get; } + /// /// Gets the OpenXML elements that make up the loop content. /// These elements will be cloned for each item in the collection. @@ -44,6 +52,7 @@ internal sealed class LoopBlock public LoopBlock( string collectionName, + string? iterationVariableName, IReadOnlyList contentElements, OpenXmlElement startMarker, OpenXmlElement endMarker, @@ -51,6 +60,7 @@ public LoopBlock( LoopBlock? emptyBlock = null) { CollectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); + IterationVariableName = iterationVariableName; ContentElements = contentElements ?? throw new ArgumentNullException(nameof(contentElements)); StartMarker = startMarker ?? throw new ArgumentNullException(nameof(startMarker)); EndMarker = endMarker ?? throw new ArgumentNullException(nameof(endMarker)); diff --git a/TriasDev.Templify/Loops/LoopContext.cs b/TriasDev.Templify/Loops/LoopContext.cs index 5a5d8f3..c01aab4 100644 --- a/TriasDev.Templify/Loops/LoopContext.cs +++ b/TriasDev.Templify/Loops/LoopContext.cs @@ -32,6 +32,13 @@ internal sealed class LoopContext /// public string CollectionName { get; } + /// + /// Gets the name of the iteration variable, or null if using implicit syntax. + /// For {{#foreach item in Items}}, this is "item". + /// For {{#foreach Items}}, this is null (implicit). + /// + public string? IterationVariableName { get; } + /// /// Gets the parent loop context (for nested loops). /// @@ -52,12 +59,14 @@ public LoopContext( int index, int count, string collectionName, + string? iterationVariableName = null, LoopContext? parent = null) { CurrentItem = currentItem ?? throw new ArgumentNullException(nameof(currentItem)); Index = index; Count = count; CollectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); + IterationVariableName = iterationVariableName; Parent = parent; } @@ -67,6 +76,7 @@ public LoopContext( public static IReadOnlyList CreateContexts( IEnumerable collection, string collectionName, + string? iterationVariableName = null, LoopContext? parent = null) { if (collection == null) @@ -83,7 +93,7 @@ public static IReadOnlyList CreateContexts( List contexts = new List(items.Count); for (int i = 0; i < items.Count; i++) { - contexts.Add(new LoopContext(items[i], i, items.Count, collectionName, parent)); + contexts.Add(new LoopContext(items[i], i, items.Count, collectionName, iterationVariableName, parent)); } return contexts; @@ -91,7 +101,8 @@ public static IReadOnlyList CreateContexts( /// /// Tries to resolve a variable in this context or parent contexts. - /// Supports direct property access ({{Name}}) and metadata ({{@index}}). + /// Supports direct property access ({{Name}}), named iteration variable access ({{item.Name}}), + /// and metadata ({{@index}}). /// public bool TryResolveVariable(string variableName, out object? value) { @@ -101,13 +112,31 @@ public bool TryResolveVariable(string variableName, out object? value) return TryResolveMetadata(variableName, out value); } - // Try to resolve from current item first + // Check if accessing via named iteration variable (e.g., "item" or "item.Name") + if (IterationVariableName != null) + { + // Direct reference to iteration variable (e.g., {{item}}) + if (variableName == IterationVariableName) + { + value = CurrentItem; + return true; + } + + // Property access via iteration variable (e.g., {{item.Name}}) + if (variableName.StartsWith(IterationVariableName + ".", StringComparison.Ordinal)) + { + string propertyPath = variableName.Substring(IterationVariableName.Length + 1); + return TryResolveFromCurrentItem(propertyPath, out value); + } + } + + // Try to resolve from current item (implicit syntax - backward compatible) if (TryResolveFromCurrentItem(variableName, out value)) { return true; } - // Try parent context + // Try parent context (for nested loop variable access like {{category.Name}} from inner loop) if (Parent != null && Parent.TryResolveVariable(variableName, out value)) { return true; diff --git a/TriasDev.Templify/Loops/LoopDetector.cs b/TriasDev.Templify/Loops/LoopDetector.cs index cd62e03..931386a 100644 --- a/TriasDev.Templify/Loops/LoopDetector.cs +++ b/TriasDev.Templify/Loops/LoopDetector.cs @@ -10,12 +10,13 @@ namespace TriasDev.Templify.Loops; /// /// Detects and parses loop blocks in Word documents. -/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax. +/// Supports {{#foreach CollectionName}}...{{/foreach}} syntax +/// and {{#foreach item in CollectionName}}...{{/foreach}} for named iteration variables. /// internal static class LoopDetector { private static readonly Regex _foreachStartPattern = new Regex( - @"\{\{#foreach\s+([\w.]+)\}\}", + @"\{\{#foreach\s+(?:(\w+)\s+in\s+)?([\w.]+)\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex _foreachEndPattern = new Regex( @@ -77,7 +78,12 @@ internal static IReadOnlyList DetectLoopsInElements(List 0 + ? foreachMatch.Groups[1].Value + : null; + string collectionName = foreachMatch.Groups[2].Value; // Find the matching end marker int endIndex = FindMatchingEnd(elements, i); @@ -97,6 +103,7 @@ internal static IReadOnlyList DetectLoopsInElements(List DetectTableRowLoops(Table table) Match foreachMatch = _foreachStartPattern.Match(text); if (foreachMatch.Success) { - string collectionName = foreachMatch.Groups[1].Value; + // Group 1: optional iteration variable (e.g., "item" from "item in Items") + // Group 2: collection name (e.g., "Items") + string? iterationVariableName = foreachMatch.Groups[1].Success && foreachMatch.Groups[1].Length > 0 + ? foreachMatch.Groups[1].Value + : null; + string collectionName = foreachMatch.Groups[2].Value; // Check if this specific loop is contained in a single cell // If so, skip it - it will be processed when the cell content is walked @@ -270,6 +282,7 @@ internal static IReadOnlyList DetectTableRowLoops(Table table) // Create loop block for table row loop LoopBlock loopBlock = new LoopBlock( collectionName, + iterationVariableName, contentRows, rows[i], // Start marker row rows[endIndex], // End marker row diff --git a/TriasDev.Templify/Loops/LoopProcessor.cs b/TriasDev.Templify/Loops/LoopProcessor.cs index 4e6f9dd..68bb362 100644 --- a/TriasDev.Templify/Loops/LoopProcessor.cs +++ b/TriasDev.Templify/Loops/LoopProcessor.cs @@ -406,6 +406,7 @@ private int ProcessNestedLoops( return new LoopBlock( collectionName, + iterationVariableName: null, contentElements, elements[startIndex], elements[endIndex], diff --git a/TriasDev.Templify/Visitors/LoopVisitor.cs b/TriasDev.Templify/Visitors/LoopVisitor.cs index dcfef17..ab4116e 100644 --- a/TriasDev.Templify/Visitors/LoopVisitor.cs +++ b/TriasDev.Templify/Visitors/LoopVisitor.cs @@ -77,6 +77,7 @@ public void VisitLoop(LoopBlock loop, IEvaluationContext context) IReadOnlyList contexts = LoopContext.CreateContexts( collection, loop.CollectionName, + loop.IterationVariableName, parent: null); // Handle empty collection diff --git a/docs/for-template-authors/loops.md b/docs/for-template-authors/loops.md index 6fa749c..f91b2e5 100644 --- a/docs/for-template-authors/loops.md +++ b/docs/for-template-authors/loops.md @@ -4,6 +4,8 @@ Loops let you repeat content for each item in a list. They're essential for crea ## Basic Loop Syntax +### Implicit Syntax (Simple) + ``` {{#foreach ArrayName}} Content to repeat for each item @@ -12,6 +14,16 @@ Loops let you repeat content for each item in a list. They're essential for crea The content between `{{#foreach}}` and `{{/foreach}}` will be repeated once for each item in the array. +### Named Iteration Variable Syntax (Explicit) + +``` +{{#foreach item in ArrayName}} + {{item.Property}} +{{/foreach}} +``` + +This syntax gives the current item an explicit name (`item`), which you can use to access its properties. This is especially useful in [nested loops](#named-iteration-variables) where you need to access parent loop variables. + ## Simple Lists ### Looping Through Text Items @@ -590,7 +602,7 @@ Category: Electronics Item: Mouse ``` -âš ī¸ **Important:** Inside the inner loop, there is **no way** to access the outer `Name` because it's shadowed by the inner `Name`. +âš ī¸ **Important:** Inside the inner loop, there is **no way** to access the outer `Name` using implicit syntax because it's shadowed by the inner `Name`. However, you can use [named iteration variables](#named-iteration-variables) to solve this problem. ### Best Practice: Use Unique Property Names @@ -635,6 +647,69 @@ Category: {{CategoryName}} {{/foreach}} ``` +### Named Iteration Variables + +An alternative to renaming properties is to use **named iteration variables**. This syntax lets you give each loop item an explicit name, making it possible to access parent loop variables even when property names conflict. + +**Syntax:** +``` +{{#foreach variableName in CollectionName}} + {{variableName.Property}} +{{/foreach}} +``` + +**Example with shadowed names:** + +**JSON:** +```json +{ + "Categories": [ + { + "Name": "Electronics", + "Items": [ + { "Name": "Laptop" }, + { "Name": "Mouse" } + ] + } + ] +} +``` + +**Template using named iteration variables:** +``` +{{#foreach category in Categories}} +Category: {{category.Name}} +{{#foreach item in category.Items}} + Item: {{item.Name}} + Category: {{category.Name}} ← Parent accessible via named variable! +{{/foreach}} +{{/foreach}} +``` + +**Output:** +``` +Category: Electronics + Item: Laptop + Category: Electronics + Item: Mouse + Category: Electronics +``` + +**Key benefits:** +- Access parent loop variables even when names conflict +- More explicit and readable templates +- Both `{{item.Name}}` (explicit) and `{{Name}}` (implicit) work within the loop + +**Mixed syntax example:** +``` +{{#foreach category in Categories}} +{{category.Name}}: ← Named variable +{{#foreach Items}} ← Implicit syntax for inner loop + - {{Name}} ({{category.Name}}) ← Implicit + parent named variable +{{/foreach}} +{{/foreach}} +``` + ### Accessing Global Variables in Nested Loops Global variables remain accessible at any nesting depth (unless shadowed): @@ -729,7 +804,7 @@ The `{{#if IsPremium}}` condition checks the parent category's property from wit | Property only on parent item | ✅ Found after checking current | | Property only in global context | ✅ Found after checking all loop levels | | Same property name at multiple levels | âš ī¸ Innermost wins (shadowing) | -| Need to access shadowed property | ❌ Not possible - use unique names | +| Need to access shadowed property | ✅ Use [named iteration variables](#named-iteration-variables) or unique property names | ## Empty Arrays From 920749cac48c61447b7cd737ff4195f7db669be6 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Tue, 23 Dec 2025 15:21:05 +0100 Subject: [PATCH 2/4] fix: add validation for reserved iteration variable names - Validate that iteration variable names don't start with '@' (reserved for loop metadata) - Validate that 'in' keyword cannot be used as iteration variable name - Update regex to capture '@' prefix for proper validation - Fix locale-dependent test assertions using regex pattern - Add tests for reserved variable name validation --- .../NamedIterationVariableIntegrationTests.cs | 56 ++++++++++++++++++- TriasDev.Templify/Loops/LoopDetector.cs | 48 +++++++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs index fda6553..9fc8f3f 100644 --- a/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs +++ b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs @@ -74,9 +74,9 @@ public void ProcessTemplate_NamedVariable_SimpleLoop() Assert.Equal(3, paragraphs.Count); Assert.Equal("Products:", paragraphs[0]); Assert.Contains("Widget", paragraphs[1]); - Assert.True(paragraphs[1].Contains("19.99") || paragraphs[1].Contains("19,99")); // Handle locale differences + Assert.Matches(@"19[.,]99", paragraphs[1]); // Handle locale differences (period or comma) Assert.Contains("Gadget", paragraphs[2]); - Assert.True(paragraphs[2].Contains("29.99") || paragraphs[2].Contains("29,99")); // Handle locale differences + Assert.Matches(@"29[.,]99", paragraphs[2]); // Handle locale differences (period or comma) } [Fact] @@ -375,4 +375,56 @@ public void ProcessTemplate_NamedVariable_WithLoopMetadata() Assert.Equal("1: Second", paragraphs[1]); Assert.Equal("2: Third (last)", paragraphs[2]); } + + [Fact] + public void ProcessTemplate_NamedVariable_ReservedKeyword_In_ThrowsException() + { + // Arrange: "in" is a reserved keyword and cannot be used as iteration variable name + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach in in Items}}"); + builder.AddParagraph("{{in}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Items"] = new List { "A", "B" } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws( + () => processor.ProcessTemplate(templateStream, outputStream, data)); + + Assert.Contains("'in' is a reserved keyword", exception.Message); + } + + [Fact] + public void ProcessTemplate_NamedVariable_MetadataPrefix_ThrowsException() + { + // Arrange: Variable names starting with @ are reserved for loop metadata + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach @item in Items}}"); + builder.AddParagraph("{{@item}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Items"] = new List { "A", "B" } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws( + () => processor.ProcessTemplate(templateStream, outputStream, data)); + + Assert.Contains("reserved for loop metadata", exception.Message); + } } diff --git a/TriasDev.Templify/Loops/LoopDetector.cs b/TriasDev.Templify/Loops/LoopDetector.cs index 931386a..7e43c84 100644 --- a/TriasDev.Templify/Loops/LoopDetector.cs +++ b/TriasDev.Templify/Loops/LoopDetector.cs @@ -16,9 +16,18 @@ namespace TriasDev.Templify.Loops; internal static class LoopDetector { private static readonly Regex _foreachStartPattern = new Regex( - @"\{\{#foreach\s+(?:(\w+)\s+in\s+)?([\w.]+)\}\}", + @"\{\{#foreach\s+(?:(@?\w+)\s+in\s+)?([\w.]+)\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// + /// Reserved variable names that cannot be used as iteration variable names. + /// These conflict with loop metadata syntax. + /// + private static readonly HashSet _reservedVariableNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "in" // Reserved keyword in loop syntax + }; + private static readonly Regex _foreachEndPattern = new Regex( @"\{\{/foreach\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -31,6 +40,31 @@ internal static class LoopDetector @"\{\{/empty\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// + /// Validates an iteration variable name and throws if invalid. + /// + /// The variable name to validate. + /// The collection name for error messages. + /// Thrown when the variable name is invalid. + private static void ValidateIterationVariableName(string variableName, string collectionName) + { + // Check for reserved names (like "in") + if (_reservedVariableNames.Contains(variableName)) + { + throw new InvalidOperationException( + $"Invalid iteration variable name '{variableName}' in '{{{{#foreach {variableName} in {collectionName}}}}}'. " + + $"'{variableName}' is a reserved keyword."); + } + + // Check for metadata prefix (@) + if (variableName.StartsWith("@", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Invalid iteration variable name '{variableName}' in '{{{{#foreach {variableName} in {collectionName}}}}}'. " + + $"Iteration variable names cannot start with '@' as this is reserved for loop metadata."); + } + } + /// /// Detects all loop blocks in the document body. /// @@ -85,6 +119,12 @@ internal static IReadOnlyList DetectLoopsInElements(List DetectTableRowLoops(Table table) : null; string collectionName = foreachMatch.Groups[2].Value; + // Validate iteration variable name if provided + if (iterationVariableName != null) + { + ValidateIterationVariableName(iterationVariableName, collectionName); + } + // Check if this specific loop is contained in a single cell // If so, skip it - it will be processed when the cell content is walked if (IsLoopContainedInSingleCell(row, collectionName)) From 33d152f0a3013200b77828b79ddb3b51fc74c801 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Tue, 23 Dec 2025 15:26:37 +0100 Subject: [PATCH 3/4] docs: document variable resolution precedence and add shadowing test Add XML documentation to LoopContext.TryResolveVariable explaining the resolution order (local scope wins over parent scope). Also add integration test verifying that same-named variables in nested loops shadow correctly. --- .../NamedIterationVariableIntegrationTests.cs | 41 +++++++++++++++++++ TriasDev.Templify/Loops/LoopContext.cs | 15 +++++++ 2 files changed, 56 insertions(+) diff --git a/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs index 9fc8f3f..cdd3c59 100644 --- a/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs +++ b/TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs @@ -376,6 +376,47 @@ public void ProcessTemplate_NamedVariable_WithLoopMetadata() Assert.Equal("2: Third (last)", paragraphs[2]); } + [Fact] + public void ProcessTemplate_NamedVariable_SameNameInNestedLoops_InnerShadowsOuter() + { + // Arrange: Using the same variable name in nested loops - inner shadows outer + // This test documents that local scope takes precedence over parent scope + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph("{{#foreach item in Outer}}"); + builder.AddParagraph("Outer: {{item}}"); + builder.AddParagraph("{{#foreach item in Inner}}"); + builder.AddParagraph("Inner: {{item}}"); + builder.AddParagraph("{{/foreach}}"); + builder.AddParagraph("{{/foreach}}"); + + MemoryStream templateStream = builder.ToStream(); + + Dictionary data = new Dictionary + { + ["Outer"] = new List { "A" }, + ["Inner"] = new List { "X", "Y" } + }; + + DocumentTemplateProcessor processor = new DocumentTemplateProcessor(); + MemoryStream outputStream = new MemoryStream(); + + // Act + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + // Assert + Assert.True(result.IsSuccess); + + using DocumentVerifier verifier = new DocumentVerifier(outputStream); + List paragraphs = verifier.GetAllParagraphTexts(); + + // Inner loop shadows outer - {{item}} in inner loop resolves to inner value + Assert.Contains(paragraphs, p => p == "Outer: A"); + Assert.Contains(paragraphs, p => p == "Inner: X"); + Assert.Contains(paragraphs, p => p == "Inner: Y"); + // Verify outer value is NOT leaked into inner loop + Assert.DoesNotContain(paragraphs, p => p == "Inner: A"); + } + [Fact] public void ProcessTemplate_NamedVariable_ReservedKeyword_In_ThrowsException() { diff --git a/TriasDev.Templify/Loops/LoopContext.cs b/TriasDev.Templify/Loops/LoopContext.cs index c01aab4..bec039b 100644 --- a/TriasDev.Templify/Loops/LoopContext.cs +++ b/TriasDev.Templify/Loops/LoopContext.cs @@ -104,6 +104,21 @@ public static IReadOnlyList CreateContexts( /// Supports direct property access ({{Name}}), named iteration variable access ({{item.Name}}), /// and metadata ({{@index}}). /// + /// + /// Variable resolution follows this precedence order (first match wins): + /// + /// Loop metadata (@index, @first, @last, @count) + /// Named iteration variable direct reference (e.g., {{item}} when using "item in Items") + /// Named iteration variable property access (e.g., {{item.Name}}) + /// Current item property (implicit syntax, e.g., {{Name}}) + /// Parent loop context (recursive, for nested loop variable access) + /// + /// + /// This means local scope always takes precedence over parent scope. If the current item + /// has a property with the same name as a parent loop's iteration variable, the current + /// item's property will be resolved first. + /// + /// public bool TryResolveVariable(string variableName, out object? value) { // Check for loop metadata variables From 2dfc0ade20f10e9d332862a9286c74b29802dd78 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Tue, 23 Dec 2025 16:04:34 +0100 Subject: [PATCH 4/4] perf: cache iteration variable prefix to reduce string allocations - Add _iterationVariablePrefix field in LoopContext to avoid repeated string concatenation during variable resolution - Cache prefix as local variable in TemplateValidator.CanResolveInScope - Add comment explaining why @? is kept in regex (for better error messages) Addresses review comments from Copilot. --- TriasDev.Templify/Core/TemplateValidator.cs | 6 ++++-- TriasDev.Templify/Loops/LoopContext.cs | 11 +++++++++-- TriasDev.Templify/Loops/LoopDetector.cs | 2 ++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/TriasDev.Templify/Core/TemplateValidator.cs b/TriasDev.Templify/Core/TemplateValidator.cs index bf2642e..5776ec9 100644 --- a/TriasDev.Templify/Core/TemplateValidator.cs +++ b/TriasDev.Templify/Core/TemplateValidator.cs @@ -538,9 +538,11 @@ private static bool CanResolveInScope( } // Property access via iteration variable (e.g., {{item.Name}}) - if (placeholder.StartsWith(iterationVariableName + ".", StringComparison.Ordinal)) + // Cache prefix to avoid repeated string concatenation + string iterationVariablePrefix = iterationVariableName + "."; + if (placeholder.StartsWith(iterationVariablePrefix, StringComparison.Ordinal)) { - string propertyPath = placeholder.Substring(iterationVariableName.Length + 1); + string propertyPath = placeholder.Substring(iterationVariablePrefix.Length); // Extract root property from the path int nextDotIndex = propertyPath.IndexOf('.'); string rootProperty = nextDotIndex > 0 ? propertyPath.Substring(0, nextDotIndex) : propertyPath; diff --git a/TriasDev.Templify/Loops/LoopContext.cs b/TriasDev.Templify/Loops/LoopContext.cs index bec039b..ab75201 100644 --- a/TriasDev.Templify/Loops/LoopContext.cs +++ b/TriasDev.Templify/Loops/LoopContext.cs @@ -44,6 +44,12 @@ internal sealed class LoopContext /// public LoopContext? Parent { get; } + /// + /// Cached prefix for iteration variable property access (e.g., "item."). + /// Null if using implicit syntax. + /// + private readonly string? _iterationVariablePrefix; + /// /// Gets whether this is the first item in the collection. /// @@ -67,6 +73,7 @@ public LoopContext( Count = count; CollectionName = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); IterationVariableName = iterationVariableName; + _iterationVariablePrefix = iterationVariableName != null ? iterationVariableName + "." : null; Parent = parent; } @@ -138,9 +145,9 @@ public bool TryResolveVariable(string variableName, out object? value) } // Property access via iteration variable (e.g., {{item.Name}}) - if (variableName.StartsWith(IterationVariableName + ".", StringComparison.Ordinal)) + if (variableName.StartsWith(_iterationVariablePrefix!, StringComparison.Ordinal)) { - string propertyPath = variableName.Substring(IterationVariableName.Length + 1); + string propertyPath = variableName.Substring(_iterationVariablePrefix!.Length); return TryResolveFromCurrentItem(propertyPath, out value); } } diff --git a/TriasDev.Templify/Loops/LoopDetector.cs b/TriasDev.Templify/Loops/LoopDetector.cs index 7e43c84..bf5f282 100644 --- a/TriasDev.Templify/Loops/LoopDetector.cs +++ b/TriasDev.Templify/Loops/LoopDetector.cs @@ -15,6 +15,8 @@ namespace TriasDev.Templify.Loops; /// internal static class LoopDetector { + // Note: The @? in the regex allows capturing invalid variable names starting with @ + // so we can provide a helpful validation error message instead of silently not matching. private static readonly Regex _foreachStartPattern = new Regex( @"\{\{#foreach\s+(?:(@?\w+)\s+in\s+)?([\w.]+)\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);