Skip to content

Commit e12e15d

Browse files
bfarmer67claude
andcommitted
Fix thread safety, reduce duplication, improve error context, add tests and XML docs
- Fix race condition in RoslynTokenExpressionProvider: volatile + Interlocked.Exchange for __runtimeContext, capture context locally in GetTokenExpression/Compile - Extract shared ParseConditionalToken in TokenParser to eliminate duplicated ParseIfToken/ParseWhileToken logic - Add TokenId property to TemplateException for error diagnostics; pass token.Id from TokenProcessor error sites; preserve TemplateException re-throw in parser - Add 17 new tests: edge cases (unmatched blocks, invalid identifiers, malformed expressions), token style variants (SingleBrace, DollarBrace, PoundBrace), and thread safety (concurrent caching, reset during concurrent use) - Add XML documentation to all public API types and members Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ffbe11 commit e12e15d

18 files changed

+413
-70
lines changed
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-

1+
22
namespace Hyperbee.Templating.Compiler;
33

4+
/// <summary>Defines a method that can be invoked from within template expressions.</summary>
45
public interface IMethodInvoker
56
{
7+
/// <summary>Invokes the method with the specified arguments.</summary>
8+
/// <param name="args">The arguments to pass to the method.</param>
9+
/// <returns>The result of the method invocation.</returns>
610
object Invoke( params object[] args );
711
}
812

13+
/// <summary>Wraps a delegate as an <see cref="IMethodInvoker"/> for use in template expressions.</summary>
914
public sealed class MethodInvoker( Func<object[], object> invoker ) : IMethodInvoker
1015
{
1116
private readonly Func<object[], object> _invoker = invoker ?? throw new ArgumentNullException( nameof( invoker ) );
1217

18+
/// <inheritdoc />
1319
public object Invoke( params object[] args ) => _invoker( args );
1420
}

src/Hyperbee.Templating/Compiler/RoslynTokenExpressionProvider.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ private sealed class RuntimeContext( ImmutableArray<MetadataReference> metadataR
3434
public DynamicAssemblyLoadContext AssemblyLoadContext { get; } = new( metadataReferences );
3535
}
3636

37-
private static RuntimeContext __runtimeContext = new( MetadataReferences );
37+
private static volatile RuntimeContext __runtimeContext = new( MetadataReferences );
3838
private static int __counter;
3939

4040
private static readonly CSharpCompilationOptions CompilationOptions =
@@ -43,15 +43,16 @@ private sealed class RuntimeContext( ImmutableArray<MetadataReference> metadataR
4343
[MethodImpl( MethodImplOptions.AggressiveInlining )]
4444
public TokenExpression GetTokenExpression( string codeExpression, MemberDictionary members )
4545
{
46-
return __runtimeContext.TokenExpressions.GetOrAdd( codeExpression, Compile( codeExpression, members ) );
46+
var context = __runtimeContext;
47+
return context.TokenExpressions.GetOrAdd( codeExpression, _ => Compile( codeExpression, members, context ) );
4748
}
4849

4950
public static void Reset()
5051
{
51-
__runtimeContext = new RuntimeContext( MetadataReferences );
52+
Interlocked.Exchange( ref __runtimeContext, new RuntimeContext( MetadataReferences ) );
5253
}
5354

54-
private static TokenExpression Compile( string codeExpression, MemberDictionary members )
55+
private static TokenExpression Compile( string codeExpression, MemberDictionary members, RuntimeContext context )
5556
{
5657
// Create a shim to compile the expression
5758

@@ -126,7 +127,7 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members )
126127
}
127128

128129
peStream.Seek( 0, SeekOrigin.Begin );
129-
var assembly = __runtimeContext.AssemblyLoadContext.LoadFromStream( peStream );
130+
var assembly = context.AssemblyLoadContext.LoadFromStream( peStream );
130131

131132
var methodDelegate = assembly!
132133
.GetType( "TokenExpressionInvoker" )!
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
using Hyperbee.Templating.Text;
1+
using Hyperbee.Templating.Text;
22

33
namespace Hyperbee.Templating.Compiler;
44

5+
/// <summary>A delegate that evaluates a compiled token expression against a member dictionary.</summary>
6+
/// <param name="members">The read-only dictionary of template variables and methods.</param>
7+
/// <returns>The result of evaluating the expression.</returns>
58
public delegate object TokenExpression( IReadOnlyMemberDictionary members );
69

10+
/// <summary>Provides compilation of token expressions for template evaluation.</summary>
711
public interface ITokenExpressionProvider
812
{
13+
/// <summary>Compiles a code expression string into an executable <see cref="TokenExpression"/>.</summary>
14+
/// <param name="codeExpression">The expression string to compile (e.g., a lambda expression).</param>
15+
/// <param name="members">The member dictionary providing variable and method context.</param>
16+
/// <returns>A compiled delegate that can be invoked to evaluate the expression.</returns>
917
public TokenExpression GetTokenExpression( string codeExpression, MemberDictionary members );
1018
}

src/Hyperbee.Templating/Configure/MethodBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Hyperbee.Templating.Configure;
44

5+
/// <summary>Provides a fluent API for registering typed method delegates with template options.</summary>
56
public class MethodBuilder( string name, TemplateOptions options )
67
{
78
public TemplateOptions Expression<TOutput>( Func<TOutput> func )

src/Hyperbee.Templating/Configure/TemplateOptions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77

88
namespace Hyperbee.Templating.Configure;
99

10+
/// <summary>Configuration options for template rendering, including variables, methods, and expression providers.</summary>
1011
public class TemplateOptions
1112
{
13+
/// <summary>Creates a new <see cref="TemplateOptions"/> instance.</summary>
14+
/// <returns>A new default options instance.</returns>
1215
public static TemplateOptions Create() => new();
1316

1417
public IDictionary<string, IMethodInvoker> Methods { get; }

src/Hyperbee.Templating/Core/KeyHelper.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
namespace Hyperbee.Templating.Core;
22

3+
/// <summary>A delegate that validates whether a token identifier is well-formed.</summary>
4+
/// <param name="key">The token identifier to validate.</param>
5+
/// <returns><c>true</c> if the identifier is valid; otherwise, <c>false</c>.</returns>
36
public delegate bool KeyValidator( ReadOnlySpan<char> key );
47

58
internal static class KeyHelper

src/Hyperbee.Templating/Text/MemberDictionary.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,23 @@
44

55
namespace Hyperbee.Templating.Text;
66

7+
/// <summary>A read-only view of template variables and methods available during expression evaluation.</summary>
78
public interface IReadOnlyMemberDictionary : IReadOnlyDictionary<string, string>
89
{
10+
/// <summary>Gets a variable value converted to the specified type.</summary>
11+
/// <typeparam name="TType">The target type to convert to.</typeparam>
12+
/// <param name="name">The variable name.</param>
13+
/// <returns>The converted value.</returns>
914
public TType GetValueAs<TType>( string name ) where TType : IConvertible;
15+
16+
/// <summary>Invokes a registered method by name.</summary>
17+
/// <param name="methodName">The method name.</param>
18+
/// <param name="args">The arguments to pass to the method.</param>
19+
/// <returns>The result of the method invocation.</returns>
1020
public object Invoke( string methodName, params object[] args );
1121
}
1222

23+
/// <summary>Stores template variables and methods, providing dictionary-style access for token resolution.</summary>
1324
public class MemberDictionary : IReadOnlyMemberDictionary
1425
{
1526
protected internal IDictionary<string, string> Variables { get; }

src/Hyperbee.Templating/Text/Runtime/TemplateParser.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,13 @@ private void ParseTemplate( ref BufferManager bufferManager, TextReader reader,
198198
if ( state.Frames.Depth != 0 )
199199
throw new TemplateException( "Missing end if, or end while." );
200200
}
201+
catch ( TemplateException )
202+
{
203+
throw;
204+
}
201205
catch ( Exception ex )
202206
{
203-
throw new TemplateException( "Error processing template.", ex );
207+
throw new TemplateException( $"Error processing template at token {state.NextTokenId}.", ex );
204208
}
205209
finally
206210
{

src/Hyperbee.Templating/Text/Runtime/TokenParser.cs

Lines changed: 20 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -93,39 +93,7 @@ private TokenType ParseIfToken( ReadOnlySpan<char> span, ref TokenEvaluation tok
9393
if ( span.Length != 2 && !char.IsWhiteSpace( span[2] ) )
9494
return TokenType.Undefined;
9595

96-
// Remove the "if" prefix
97-
span = span[2..].Trim();
98-
99-
if ( span.IsEmpty )
100-
throw new TemplateException( "Invalid `if` statement. Missing identifier." );
101-
102-
var bang = false;
103-
104-
if ( span[0] == '!' )
105-
{
106-
bang = true;
107-
span = span[1..].Trim();
108-
}
109-
110-
var isFatArrow = span.IndexOfIgnoreDelimitedRanges( "=>", "\"" ) != -1;
111-
112-
if ( !isFatArrow && !_validateKey( span ) )
113-
throw new TemplateException( "Invalid `if` statement. Invalid identifier in truthy expression." );
114-
115-
if ( bang && isFatArrow )
116-
throw new TemplateException( "Invalid `if` statement. The '!' operator is not supported for token expressions." );
117-
118-
if ( isFatArrow )
119-
{
120-
tokenEvaluation = TokenEvaluation.Expression;
121-
tokenExpression = span;
122-
}
123-
else
124-
{
125-
tokenEvaluation = bang ? TokenEvaluation.Falsy : TokenEvaluation.Truthy;
126-
name = span;
127-
}
128-
96+
ParseConditionalToken( span, 2, "if", ref tokenEvaluation, ref tokenExpression, ref name );
12997
return TokenType.If;
13098
}
13199

@@ -153,11 +121,25 @@ private TokenType ParseWhileToken( ReadOnlySpan<char> span, ref TokenEvaluation
153121
if ( span.Length != 5 && !char.IsWhiteSpace( span[5] ) )
154122
return TokenType.Undefined;
155123

156-
// Remove the "while" prefix
157-
span = span[5..].Trim();
124+
ParseConditionalToken( span, 5, "while", ref tokenEvaluation, ref tokenExpression, ref name );
125+
return TokenType.While;
126+
}
127+
128+
private static TokenType ParseEndWhileToken( ReadOnlySpan<char> span )
129+
{
130+
if ( span.Length != 6 )
131+
throw new TemplateException( "Invalid `/while` statement. Invalid characters." );
132+
133+
return TokenType.EndWhile;
134+
}
135+
136+
private void ParseConditionalToken( ReadOnlySpan<char> span, int keywordLength, string keywordName,
137+
ref TokenEvaluation tokenEvaluation, ref ReadOnlySpan<char> tokenExpression, ref ReadOnlySpan<char> name )
138+
{
139+
span = span[keywordLength..].Trim();
158140

159141
if ( span.IsEmpty )
160-
throw new TemplateException( "Invalid `while` statement. Missing identifier." );
142+
throw new TemplateException( $"Invalid `{keywordName}` statement. Missing identifier." );
161143

162144
var bang = false;
163145

@@ -170,10 +152,10 @@ private TokenType ParseWhileToken( ReadOnlySpan<char> span, ref TokenEvaluation
170152
var isFatArrow = span.IndexOfIgnoreDelimitedRanges( "=>", "\"" ) != -1;
171153

172154
if ( !isFatArrow && !_validateKey( span ) )
173-
throw new TemplateException( "Invalid `while` statement. Invalid identifier in truthy expression." );
155+
throw new TemplateException( $"Invalid `{keywordName}` statement. Invalid identifier in truthy expression." );
174156

175157
if ( bang && isFatArrow )
176-
throw new TemplateException( "Invalid `while` statement. The '!' operator is not supported for token expressions." );
158+
throw new TemplateException( $"Invalid `{keywordName}` statement. The '!' operator is not supported for token expressions." );
177159

178160
if ( isFatArrow )
179161
{
@@ -185,16 +167,6 @@ private TokenType ParseWhileToken( ReadOnlySpan<char> span, ref TokenEvaluation
185167
tokenEvaluation = bang ? TokenEvaluation.Falsy : TokenEvaluation.Truthy;
186168
name = span;
187169
}
188-
189-
return TokenType.While;
190-
}
191-
192-
private static TokenType ParseEndWhileToken( ReadOnlySpan<char> span )
193-
{
194-
if ( span.Length != 6 )
195-
throw new TemplateException( "Invalid `/while` statement. Invalid characters." );
196-
197-
return TokenType.EndWhile;
198170
}
199171

200172
private TokenType ParseEachToken( ReadOnlySpan<char> span, ref TokenEvaluation tokenEvaluation, ref ReadOnlySpan<char> tokenExpression, ref ReadOnlySpan<char> name )

src/Hyperbee.Templating/Text/Runtime/TokenProcessor.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ public TokenAction ProcessToken( TokenDefinition token, TemplateState state, out
6262
return ProcessElseToken( frames, token );
6363

6464
case TokenType.Endif:
65-
return ProcessEndIfToken( frames );
65+
return ProcessEndIfToken( frames, token );
6666

6767
case TokenType.EndWhile:
68-
return ProcessEndWhileToken( frames );
68+
return ProcessEndWhileToken( frames, token );
6969

7070
case TokenType.EndEach:
71-
return ProcessEndEachToken( frames );
71+
return ProcessEndEachToken( frames, token );
7272

7373
case TokenType.Define:
7474
return ProcessDefineToken( token );
@@ -149,16 +149,16 @@ private static TokenAction ProcessIfToken( TokenDefinition token, FrameStack fra
149149
private static TokenAction ProcessElseToken( FrameStack frames, TokenDefinition token )
150150
{
151151
if ( !frames.IsTokenType( TokenType.If ) )
152-
throw new TemplateException( "Syntax error. Invalid `else` without matching `if`." );
152+
throw new TemplateException( "Syntax error. Invalid `else` without matching `if`.", token.Id );
153153

154154
frames.Push( token, !frames.IsTruthy );
155155
return TokenAction.Ignore;
156156
}
157157

158-
private static TokenAction ProcessEndIfToken( FrameStack frames )
158+
private static TokenAction ProcessEndIfToken( FrameStack frames, TokenDefinition token )
159159
{
160160
if ( frames.Depth == 0 || !frames.IsTokenType( TokenType.If ) && !frames.IsTokenType( TokenType.Else ) )
161-
throw new TemplateException( "Syntax error. Invalid `/if` without matching `if`." );
161+
throw new TemplateException( "Syntax error. Invalid `/if` without matching `if`.", token.Id );
162162

163163
if ( frames.IsTokenType( TokenType.Else ) )
164164
frames.Pop(); // pop the else
@@ -174,10 +174,10 @@ private static TokenAction ProcessWhileToken( TokenDefinition token, FrameStack
174174
return TokenAction.Ignore;
175175
}
176176

177-
private TokenAction ProcessEndWhileToken( FrameStack frames )
177+
private TokenAction ProcessEndWhileToken( FrameStack frames, TokenDefinition token )
178178
{
179179
if ( frames.Depth == 0 || !frames.IsTokenType( TokenType.While ) )
180-
throw new TemplateException( "Syntax error. Invalid `/while` without matching `while`." );
180+
throw new TemplateException( "Syntax error. Invalid `/while` without matching `while`.", token.Id );
181181

182182
var whileToken = frames.Peek().Token;
183183

@@ -217,10 +217,10 @@ private TokenAction ProcessEachToken( TokenDefinition token, FrameStack frames,
217217
return TokenAction.Ignore;
218218
}
219219

220-
private TokenAction ProcessEndEachToken( FrameStack frames )
220+
private TokenAction ProcessEndEachToken( FrameStack frames, TokenDefinition token )
221221
{
222222
if ( frames.Depth == 0 || !frames.IsTokenType( TokenType.Each ) )
223-
throw new TemplateException( "Syntax error. Invalid /each without matching each." );
223+
throw new TemplateException( "Syntax error. Invalid /each without matching each.", token.Id );
224224

225225
var frame = frames.Peek();
226226
var (currentName, enumerator) = frame.EnumeratorDefinition;

0 commit comments

Comments
 (0)