Skip to content

Commit 18f504f

Browse files
Merge pull request #14 from Stillpoint-Software/feature/13-feature-add-foreach-looping
[FEATURE]: Add foreach looping
2 parents 1b34f4e + 26d16bf commit 18f504f

23 files changed

Lines changed: 684 additions & 290 deletions

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,38 @@ var result = parser.Render(template);
141141
Console.WriteLine(result); // Output: 012.
142142
```
143143

144+
### Each Statement
145+
146+
```csharp
147+
var template = "{{each n:x => x.list.Split( \",\" )}}World {{n}},{{/each}}";
148+
149+
var parser = new TemplateParser
150+
{
151+
Variables = { ["list"] = "John,James,Sarah" }
152+
};
153+
154+
var result = parser.Render(template);
155+
Console.WriteLine(result); // hello World John,World James,World Sarah,.
156+
```
157+
158+
```csharp
159+
160+
var template = "{{each n:x => x.Where( t => Regex.IsMatch( t.Key, \"people*\" ) ).Select( t => t.Value )}}hello {{n}}. {{/each}}";
161+
162+
var parser = new TemplateParser
163+
{
164+
Variables =
165+
{
166+
["people[0]"] = "John",
167+
["people[1]"] = "Jane",
168+
["people[2]"] = "Doe"
169+
}
170+
};
171+
172+
var result = parser.Render(template);
173+
Console.WriteLine(result); // hello John. hello Jane. hello Doe.
174+
```
175+
144176
### Methods
145177

146178
You can invoke methods within token expressions.

docs/syntax/examples.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,38 @@ var result = parser.Render(template);
116116
Console.WriteLine(result); // Output: 012.
117117
```
118118

119+
### Each Statement
120+
121+
```csharp
122+
var template = "{{each n:x => x.list.Split( \",\" )}}World {{n}},{{/each}}";
123+
124+
var parser = new TemplateParser
125+
{
126+
Variables = { ["list"] = "John,James,Sarah" }
127+
};
128+
129+
var result = parser.Render(template);
130+
Console.WriteLine(result); // hello World John,World James,World Sarah,.
131+
```
132+
133+
```csharp
134+
135+
var template = "{{each n:x => x.Where( t => Regex.IsMatch( t.Key, \"people*\" ) ).Select( t => t.Value )}}hello {{n}}. {{/each}}";
136+
137+
var parser = new TemplateParser
138+
{
139+
Variables =
140+
{
141+
["people[0]"] = "John",
142+
["people[1]"] = "Jane",
143+
["people[2]"] = "Doe"
144+
}
145+
};
146+
147+
var result = parser.Render(template);
148+
Console.WriteLine(result); // hello John. hello Jane. hello Doe.
149+
```
150+
119151
## Inline Definitions
120152

121153
```csharp

docs/syntax/overview.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ The `while` statement repeats a template block while a condition is true.
6464

6565
`{{ while condition }} ... {{ /while }}`
6666

67+
### Each Statement
68+
69+
The `each` statement repeats a template block for each a condition.
70+
71+
`{{ each condition }} ... {{ /each }}`
72+
6773
## Inline Declarations
6874

6975
You can declare variable tokens inline within the template.

docs/syntax/syntax.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,18 @@ nav_order: 1
77

88
# Templating Syntax
99

10-
Hyperbee Templating provides a variety of token syntaxes for different use cases. This section provides a
11-
guide to to the available syntax forms.
10+
Hyperbee Templating is a lightweight templating and variable substitution syntax engine. The library supports value replacements,
11+
code expressions, token nesting, in-line definitions, conditional flow, and looping. It is designed to be lightweight and fast,
12+
and does not rely on any external dependencies.
13+
14+
## Features
15+
16+
* Variable substitution syntax engine
17+
* Value replacements
18+
* Expression replacements
19+
* Token nesting
20+
* Conditional tokens
21+
* Conditional flow
22+
* Iterators
23+
* User-defined methods
24+

src/Hyperbee.Templating/Compiler/RoslynTokenExpressionProvider.cs

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
using System.Collections.Concurrent;
1+
using System.Collections;
2+
using System.Collections.Concurrent;
23
using System.Collections.Immutable;
3-
using System.Diagnostics;
44
using System.Reflection;
55
using System.Runtime.CompilerServices;
6+
using System.Text.Json.Serialization;
7+
using System.Text.RegularExpressions;
68
using Hyperbee.Templating.Text;
79
using Microsoft.CodeAnalysis;
810
using Microsoft.CodeAnalysis.CSharp;
@@ -16,10 +18,14 @@ internal sealed class RoslynTokenExpressionProvider : ITokenExpressionProvider
1618
private static readonly ImmutableArray<MetadataReference> MetadataReferences =
1719
[
1820
MetadataReference.CreateFromFile( typeof( object ).Assembly.Location ),
19-
MetadataReference.CreateFromFile( typeof( object ).Assembly.Location.Replace( "System.Private.CoreLib", "System.Runtime" ) ),
2021
MetadataReference.CreateFromFile( typeof( RuntimeBinderException ).Assembly.Location ),
2122
MetadataReference.CreateFromFile( typeof( DynamicAttribute ).Assembly.Location ),
22-
MetadataReference.CreateFromFile( typeof( RoslynTokenExpressionProvider ).Assembly.Location )
23+
MetadataReference.CreateFromFile( typeof( RoslynTokenExpressionProvider ).Assembly.Location ),
24+
MetadataReference.CreateFromFile( typeof( Regex ).Assembly.Location ),
25+
MetadataReference.CreateFromFile( typeof( Enumerable ).Assembly.Location ),
26+
27+
MetadataReference.CreateFromFile( typeof( object ).Assembly.Location.Replace( "System.Private.CoreLib", "System.Runtime" ) ),
28+
MetadataReference.CreateFromFile( typeof( IList ).Assembly.Location.Replace( "System.Private.CoreLib", "System.Collections" ) )
2329
];
2430

2531
private sealed class RuntimeContext( ImmutableArray<MetadataReference> metadataReferences )
@@ -35,33 +41,40 @@ private sealed class RuntimeContext( ImmutableArray<MetadataReference> metadataR
3541
new( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release );
3642

3743
[MethodImpl( MethodImplOptions.AggressiveInlining )]
38-
public TokenExpression GetTokenExpression( string codeExpression )
44+
public TokenExpression GetTokenExpression( string codeExpression, MemberDictionary members )
3945
{
40-
return __runtimeContext.TokenExpressions.GetOrAdd( codeExpression, Compile );
46+
return __runtimeContext.TokenExpressions.GetOrAdd( codeExpression, Compile( codeExpression, members ) );
4147
}
4248

4349
public static void Reset()
4450
{
4551
__runtimeContext = new RuntimeContext( MetadataReferences );
4652
}
4753

48-
private static TokenExpression Compile( string codeExpression )
54+
private static TokenExpression Compile( string codeExpression, MemberDictionary members )
4955
{
5056
// Create a shim to compile the expression
57+
//AF: I added the linq and regular Expression usings
58+
//AF: the error is in the Regex as it doesn't know what the people are.
5159
var codeShim =
52-
$$"""
53-
using Hyperbee.Templating.Text;
54-
using Hyperbee.Templating.Compiler;
55-
56-
public static class TokenExpressionInvoker
57-
{
58-
public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members )
59-
{
60-
TokenExpression expr = {{codeExpression}};
61-
return expr( members );
62-
}
63-
}
64-
""";
60+
$$$"""
61+
using System;
62+
using System.Linq;
63+
using System.Text.RegularExpressions;
64+
using System.Collections;
65+
using System.Collections.Generic;
66+
using Hyperbee.Templating.Text;
67+
using Hyperbee.Templating.Compiler;
68+
69+
public static class TokenExpressionInvoker
70+
{
71+
public static object Invoke( {{{nameof( IReadOnlyMemberDictionary )}}} members )
72+
{
73+
TokenExpression expr = {{{codeExpression}}};
74+
return expr( members );
75+
}
76+
}
77+
""";
6578

6679
// Parse the code expression
6780
var syntaxTree = CSharpSyntaxTree.ParseText( codeShim );
@@ -80,10 +93,10 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members )
8093
};
8194

8295
// Rewrite the lambda expression to use the dictionary lookup
83-
var rewriter = new TokenExpressionRewriter( parameterName );
96+
var rewriter = new TokenExpressionRewriter( parameterName, members );
8497
var rewrittenSyntaxTree = rewriter.Visit( root );
8598

86-
//var rewrittenCode = rewrittenSyntaxTree.ToFullString(); // Keep for debugging
99+
var rewrittenCode = rewrittenSyntaxTree.ToFullString(); // Keep for debugging
87100

88101
// Compile the rewritten code
89102
var counter = Interlocked.Increment( ref __counter );
@@ -101,9 +114,9 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members )
101114
{
102115
var failures = result.Diagnostics.Where( diagnostic =>
103116
diagnostic.IsWarningAsError ||
104-
diagnostic.Severity == DiagnosticSeverity.Error );
117+
diagnostic.Severity == DiagnosticSeverity.Error ).ToArray();
105118

106-
throw new InvalidOperationException( "Compilation failed: " + string.Join( "\n", failures.Select( diagnostic => diagnostic.GetMessage() ) ) );
119+
throw new TokenExpressionProviderException( "Compilation failed: " + failures[0]?.GetMessage(), failures );
107120
}
108121

109122
peStream.Seek( 0, SeekOrigin.Begin );
@@ -118,16 +131,36 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members )
118131
}
119132
}
120133

134+
[Serializable]
135+
internal class TokenExpressionProviderException : Exception
136+
{
137+
public Diagnostic[] Diagnostic { get; }
138+
public string Id => Diagnostic != null && Diagnostic.Length > 0 ? Diagnostic[0].Id : string.Empty;
139+
140+
public TokenExpressionProviderException( string message, Diagnostic[] diagnostic )
141+
: base( message )
142+
{
143+
Diagnostic = diagnostic;
144+
}
145+
146+
public TokenExpressionProviderException( string message, Diagnostic[] diagnostic, Exception innerException )
147+
: base( message, innerException )
148+
{
149+
Diagnostic = diagnostic;
150+
}
151+
152+
}
153+
121154
// This rewriter will transform the lambda expression to use dictionary lookup
122155
// for property access, method invocation, and 'generic' property casting.
123156
//
124157
// we want to transform these syntactic-sugar patterns:
125158
//
126159
// 1. x => x.someProp to x["someProp"]
127160
// 2. x => x.someProp<T> to x.GetValueAs<T>("someProp")
128-
// 3. x => x.someMethod(..) to x.InvokeMethod("someMethod", ..)
161+
// 3. x => x.someMethod(..) to x.Invoke("someMethod", ..)
129162

130-
internal class TokenExpressionRewriter( string parameterName ) : CSharpSyntaxRewriter
163+
internal class TokenExpressionRewriter( string parameterName, MemberDictionary members ) : CSharpSyntaxRewriter
131164
{
132165
private readonly HashSet<string> _aliases = [parameterName];
133166

@@ -151,7 +184,12 @@ memberAccess.Expression is IdentifierNameSyntax identifier &&
151184
_aliases.Contains( identifier.Identifier.Text ) )
152185
{
153186
// Handle method invocation rewrite
154-
return RewriteMethodInvocation( memberAccess, node );
187+
188+
if ( members.Methods.ContainsKey( memberAccess.Name.Identifier.Text ) )
189+
return RewriteMethodInvocation( memberAccess, node );
190+
191+
return node.Update( node.Expression, (ArgumentListSyntax) VisitArgumentList( node.ArgumentList )! );
192+
//return RewriteMethodInvocation( memberAccess, node ); //BF this rewrite causes the error. we need to disambiguate calls to template lambdas methods
155193
}
156194

157195
return base.VisitInvocationExpression( node );
@@ -232,12 +270,12 @@ private InvocationExpressionSyntax RewriteMethodInvocation( MemberAccessExpressi
232270
.Select( arg => (ExpressionSyntax) Visit( arg.Expression ) )
233271
.ToArray();
234272

235-
// Create the InvokeMethod call: x.InvokeMethod("MethodName", arg1, arg2, ...)
273+
// Create the InvokeMethod call: x.Invoke("MethodName", arg1, arg2, ...)
236274
var invokeMethodCall = SyntaxFactory.InvocationExpression(
237275
SyntaxFactory.MemberAccessExpression(
238276
SyntaxKind.SimpleMemberAccessExpression,
239277
memberAccess.Expression, // This is `x`
240-
SyntaxFactory.IdentifierName( "InvokeMethod" )
278+
SyntaxFactory.IdentifierName( "Invoke" )
241279
),
242280
SyntaxFactory.ArgumentList(
243281
SyntaxFactory.SeparatedList(

src/Hyperbee.Templating/Compiler/TokenExpression.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ namespace Hyperbee.Templating.Compiler;
66

77
public interface ITokenExpressionProvider
88
{
9-
public TokenExpression GetTokenExpression( string codeExpression );
9+
public TokenExpression GetTokenExpression( string codeExpression, MemberDictionary members );
1010
}

src/Hyperbee.Templating/Configure/TemplateOptions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.ComponentModel;
22
using System.Reflection;
33
using Hyperbee.Templating.Compiler;
4+
using Hyperbee.Templating.Core;
45
using Hyperbee.Templating.Text;
56

67
namespace Hyperbee.Templating.Configure;
@@ -13,7 +14,7 @@ public class TemplateOptions
1314
public IDictionary<string, string> Variables { get; init; }
1415

1516
public TokenStyle TokenStyle { get; set; } = TokenStyle.Default;
16-
public KeyValidator Validator { get; set; } = TemplateHelper.ValidateKey;
17+
public KeyValidator Validator { get; set; } = KeyHelper.ValidateKey;
1718

1819
public bool IgnoreMissingTokens { get; set; }
1920
public bool SubstituteEnvironmentVariables { get; set; }
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Collections;
2+
3+
namespace Hyperbee.Templating.Core;
4+
5+
internal sealed class EnumeratorAdapter : IEnumerator<string>
6+
{
7+
private readonly IEnumerator _inner;
8+
9+
internal EnumeratorAdapter( IEnumerable enumerable )
10+
{
11+
if ( enumerable is not IEnumerable<IConvertible> typedEnumerable )
12+
throw new ArgumentException( "The enumerable must be of type IEnumerable<IConvertible>.", nameof( enumerable ) );
13+
14+
// take a snapshot of the enumerable to prevent changes during enumeration
15+
var snapshot = new List<IConvertible>( typedEnumerable );
16+
17+
// ReSharper disable once GenericEnumeratorNotDisposed
18+
_inner = snapshot.GetEnumerator();
19+
}
20+
21+
public string Current => (string) _inner.Current;
22+
object IEnumerator.Current => _inner.Current;
23+
24+
public bool MoveNext() => _inner.MoveNext();
25+
public void Reset() => _inner.Reset();
26+
public void Dispose() => (_inner as IDisposable)?.Dispose();
27+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace Hyperbee.Templating.Core;
2+
3+
public delegate bool KeyValidator( ReadOnlySpan<char> key );
4+
5+
internal static class KeyHelper
6+
{
7+
public static bool ValidateKey( string key )
8+
{
9+
// do-not-remove this method.
10+
//
11+
// this method is required despite code analysis claiming the method isn't referenced.
12+
//
13+
// this overload is required (and used) by generic delegates which don't support
14+
// ReadOnlySpan<char> as a generic argument.
15+
16+
return ValidateKey( key.AsSpan() );
17+
}
18+
19+
public static bool ValidateKey( ReadOnlySpan<char> key )
20+
{
21+
if ( key.IsEmpty || !char.IsLetter( key[0] ) )
22+
{
23+
return false;
24+
}
25+
26+
var length = key.Length;
27+
28+
for ( var i = 1; i < length; i++ )
29+
{
30+
var current = key[i];
31+
32+
if ( current == '[' )
33+
{
34+
if ( ++i >= length || !char.IsDigit( key[i] ) )
35+
return false;
36+
37+
while ( i < length && char.IsDigit( key[i] ) )
38+
i++;
39+
40+
if ( i >= length || key[i] != ']' )
41+
return false;
42+
43+
// Ensure that the bracket is at the end of the string
44+
if ( i != length - 1 )
45+
return false;
46+
}
47+
else if ( !char.IsLetterOrDigit( current ) && current != '_' )
48+
{
49+
return false;
50+
}
51+
}
52+
53+
return true;
54+
}
55+
}

0 commit comments

Comments
 (0)