Skip to content

Conversation

@vaceslav
Copy link
Contributor

Summary

Adds support for named iteration variable syntax in foreach loops, enabling explicit access to parent loop variables in nested loops.

New syntax:

{{#foreach item in Items}}
  {{item.Name}}
{{/foreach}}

Nested loop parent access:

{{#foreach category in Categories}}
  {{#foreach product in category.Products}}
    {{category.Name}}: {{product.Name}}
  {{/foreach}}
{{/foreach}}

Changes

  • 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)

Backward Compatibility

Both implicit ({{Name}}) and explicit ({{item.Name}}) syntax work when using named iteration variables - existing templates continue to work unchanged.

Test plan

  • All 876 tests pass (868 existing + 8 new)
  • Format check passes
  • Simple loop with named variable
  • Nested loops with parent access
  • Mixed syntax (named outer, implicit inner)
  • Backward compatibility with implicit syntax

Closes #58

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
- 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
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.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for named iteration variable syntax in foreach loops, enabling explicit access to parent loop variables in nested loops. This solves a long-standing issue where shadowed property names in nested loops couldn't be accessed.

Key Changes:

  • Introduces new syntax {{#foreach item in Collection}} alongside the existing implicit {{#foreach Collection}} syntax
  • Enables parent loop variable access in nested loops (e.g., {{category.Name}} from within inner product loop)
  • Maintains full backward compatibility - existing templates continue to work unchanged

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
docs/for-template-authors/loops.md Comprehensive documentation of named iteration variable syntax with examples and best practices
TriasDev.Templify/Loops/LoopDetector.cs Updated regex pattern to parse "item in Collection" syntax and added validation for reserved keywords
TriasDev.Templify/Loops/LoopBlock.cs Added IterationVariableName property to store the optional named variable
TriasDev.Templify/Loops/LoopContext.cs Enhanced variable resolution to handle named variable references and property access
TriasDev.Templify/Visitors/LoopVisitor.cs Updated to pass iteration variable name when creating loop contexts
TriasDev.Templify/Loops/LoopProcessor.cs Updated constructor signature for backward compatibility (sets null for unnamed variables)
TriasDev.Templify/Core/TemplateValidator.cs Extended validation logic to support named iteration variable resolution
TriasDev.Templify.Tests/Integration/NamedIterationVariableIntegrationTests.cs Added 8 comprehensive integration tests covering all new functionality including error cases
TriasDev.Templify.Tests/Visitors/*Tests.cs Updated existing unit tests to pass null for new parameter (backward compatibility)
TriasDev.Templify.Tests/LoopContext*Tests.cs Updated reflection-based tests to handle new constructor parameters
README.md Updated feature list to show both loop syntax options
CLAUDE.md Added documentation of named iteration variable syntax with nested loop examples
Comments suppressed due to low confidence (1)

TriasDev.Templify/Loops/LoopProcessor.cs:415

  • The named iteration variable feature was not implemented in TextTemplateProcessor, only in the Word document processing path (DocumentTemplateProcessor with LoopVisitor). The regex patterns in LoopProcessor.TryDetectLoop (line 383) and FindMatchingEndInElements (line 432) still use the old pattern that doesn't support "item in Collection" syntax.

If this limitation is intentional, it should be documented. If it's an oversight, the LoopProcessor regex patterns should be updated to match the LoopDetector patterns and support named iteration variables in text templates as well.

        // Check for {{#foreach CollectionName}}
        System.Text.RegularExpressions.Match match = System.Text.RegularExpressions.Regex.Match(
            startText,
            @"\{\{#foreach\s+(\w+)\}\}",
            System.Text.RegularExpressions.RegexOptions.IgnoreCase);

        if (!match.Success)
        {
            return null;
        }

        string collectionName = match.Groups[1].Value;

        // Find matching end marker
        int endIndex = FindMatchingEndInElements(elements, startIndex);
        if (endIndex == -1)
        {
            return null;
        }

        // Get content elements
        List<OpenXmlElement> contentElements = new List<OpenXmlElement>();
        for (int j = startIndex + 1; j < endIndex; j++)
        {
            contentElements.Add(elements[j]);
        }

        return new LoopBlock(
            collectionName,
            iterationVariableName: null,
            contentElements,
            elements[startIndex],
            elements[endIndex],
            isTableRowLoop: false,
            emptyBlock: null);
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

{
private static readonly Regex _foreachStartPattern = new Regex(
@"\{\{#foreach\s+([\w.]+)\}\}",
@"\{\{#foreach\s+(?:(@?\w+)\s+in\s+)?([\w.]+)\}\}",
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern captures the '@' prefix in the iteration variable name, but the validation logic checks if the variable name starts with '@'. This creates an inconsistency: the regex allows '@' at the start of the variable name (via @?\w+), but the validation immediately rejects it.

Consider removing the '@?' from the regex pattern since '@' is never valid as a prefix for iteration variable names. The pattern should be \{\{#foreach\s+(?:(\w+)\s+in\s+)?([\w.]+)\}\} instead.

Suggested change
@"\{\{#foreach\s+(?:(@?\w+)\s+in\s+)?([\w.]+)\}\}",
@"\{\{#foreach\s+(?:(\w+)\s+in\s+)?([\w.]+)\}\}",

Copilot uses AI. Check for mistakes.
}

// Property access via iteration variable (e.g., {{item.Name}})
if (variableName.StartsWith(IterationVariableName + ".", StringComparison.Ordinal))
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string concatenation IterationVariableName + "." is performed on every variable resolution when an iteration variable name is present. For templates with many variable references in loops, this could create unnecessary string allocations. Consider caching the concatenated prefix (e.g., storing IterationVariableNamePrefix as IterationVariableName + "." in the constructor) to avoid repeated allocations.

Copilot uses AI. Check for mistakes.
}

// Property access via iteration variable (e.g., {{item.Name}})
if (placeholder.StartsWith(iterationVariableName + ".", StringComparison.Ordinal))
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the issue in LoopContext.cs, the string concatenation iterationVariableName + "." is performed repeatedly during validation. Consider caching this value to avoid repeated allocations during validation of templates with many variable references.

Copilot uses AI. Check for mistakes.
};

DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
MemoryStream outputStream = new MemoryStream();
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'MemoryStream' is created but not disposed.

Copilot uses AI. Check for mistakes.
};

DocumentTemplateProcessor processor = new DocumentTemplateProcessor();
MemoryStream outputStream = new MemoryStream();
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable 'MemoryStream' is created but not disposed.

Copilot uses AI. Check for mistakes.
- 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.
@vaceslav vaceslav merged commit b0839af into main Dec 23, 2025
14 checks passed
@vaceslav vaceslav deleted the feat/named-iteration-variables branch December 23, 2025 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider adding explicit parent scope access syntax for nested loops

2 participants