diff --git a/README.md b/README.md index 557ca25..e7d81b0 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,38 @@ var result = parser.Render(template); Console.WriteLine(result); // Output: 012. ``` +### Each Statement + +```csharp +var template = "{{each n:x => x.list.Split( \",\" )}}World {{n}},{{/each}}"; + +var parser = new TemplateParser +{ + Variables = { ["list"] = "John,James,Sarah" } +}; + +var result = parser.Render(template); +Console.WriteLine(result); // hello World John,World James,World Sarah,. +``` + +```csharp + +var template = "{{each n:x => x.Where( t => Regex.IsMatch( t.Key, \"people*\" ) ).Select( t => t.Value )}}hello {{n}}. {{/each}}"; + +var parser = new TemplateParser +{ + Variables = + { + ["people[0]"] = "John", + ["people[1]"] = "Jane", + ["people[2]"] = "Doe" + } +}; + +var result = parser.Render(template); +Console.WriteLine(result); // hello John. hello Jane. hello Doe. +``` + ### Methods You can invoke methods within token expressions. diff --git a/docs/syntax/examples.md b/docs/syntax/examples.md index f794e3e..0de4151 100644 --- a/docs/syntax/examples.md +++ b/docs/syntax/examples.md @@ -116,6 +116,38 @@ var result = parser.Render(template); Console.WriteLine(result); // Output: 012. ``` +### Each Statement + +```csharp +var template = "{{each n:x => x.list.Split( \",\" )}}World {{n}},{{/each}}"; + +var parser = new TemplateParser +{ + Variables = { ["list"] = "John,James,Sarah" } +}; + +var result = parser.Render(template); +Console.WriteLine(result); // hello World John,World James,World Sarah,. +``` + +```csharp + +var template = "{{each n:x => x.Where( t => Regex.IsMatch( t.Key, \"people*\" ) ).Select( t => t.Value )}}hello {{n}}. {{/each}}"; + +var parser = new TemplateParser +{ + Variables = + { + ["people[0]"] = "John", + ["people[1]"] = "Jane", + ["people[2]"] = "Doe" + } +}; + +var result = parser.Render(template); +Console.WriteLine(result); // hello John. hello Jane. hello Doe. +``` + ## Inline Definitions ```csharp diff --git a/docs/syntax/overview.md b/docs/syntax/overview.md index 8caa4dc..28060a5 100644 --- a/docs/syntax/overview.md +++ b/docs/syntax/overview.md @@ -64,6 +64,12 @@ The `while` statement repeats a template block while a condition is true. `{{ while condition }} ... {{ /while }}` +### Each Statement + +The `each` statement repeats a template block for each a condition. + +`{{ each condition }} ... {{ /each }}` + ## Inline Declarations You can declare variable tokens inline within the template. diff --git a/docs/syntax/syntax.md b/docs/syntax/syntax.md index 6dfb234..09b2a78 100644 --- a/docs/syntax/syntax.md +++ b/docs/syntax/syntax.md @@ -7,5 +7,18 @@ nav_order: 1 # Templating Syntax -Hyperbee Templating provides a variety of token syntaxes for different use cases. This section provides a -guide to to the available syntax forms. +Hyperbee Templating is a lightweight templating and variable substitution syntax engine. The library supports value replacements, +code expressions, token nesting, in-line definitions, conditional flow, and looping. It is designed to be lightweight and fast, +and does not rely on any external dependencies. + +## Features + +* Variable substitution syntax engine +* Value replacements +* Expression replacements +* Token nesting +* Conditional tokens +* Conditional flow +* Iterators +* User-defined methods + diff --git a/src/Hyperbee.Templating/Compiler/RoslynTokenExpressionProvider.cs b/src/Hyperbee.Templating/Compiler/RoslynTokenExpressionProvider.cs index c7d643a..fb81598 100644 --- a/src/Hyperbee.Templating/Compiler/RoslynTokenExpressionProvider.cs +++ b/src/Hyperbee.Templating/Compiler/RoslynTokenExpressionProvider.cs @@ -1,8 +1,10 @@ -using System.Collections.Concurrent; +using System.Collections; +using System.Collections.Concurrent; using System.Collections.Immutable; -using System.Diagnostics; using System.Reflection; using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Hyperbee.Templating.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -16,10 +18,14 @@ internal sealed class RoslynTokenExpressionProvider : ITokenExpressionProvider private static readonly ImmutableArray MetadataReferences = [ MetadataReference.CreateFromFile( typeof( object ).Assembly.Location ), - MetadataReference.CreateFromFile( typeof( object ).Assembly.Location.Replace( "System.Private.CoreLib", "System.Runtime" ) ), MetadataReference.CreateFromFile( typeof( RuntimeBinderException ).Assembly.Location ), MetadataReference.CreateFromFile( typeof( DynamicAttribute ).Assembly.Location ), - MetadataReference.CreateFromFile( typeof( RoslynTokenExpressionProvider ).Assembly.Location ) + MetadataReference.CreateFromFile( typeof( RoslynTokenExpressionProvider ).Assembly.Location ), + MetadataReference.CreateFromFile( typeof( Regex ).Assembly.Location ), + MetadataReference.CreateFromFile( typeof( Enumerable ).Assembly.Location ), + + MetadataReference.CreateFromFile( typeof( object ).Assembly.Location.Replace( "System.Private.CoreLib", "System.Runtime" ) ), + MetadataReference.CreateFromFile( typeof( IList ).Assembly.Location.Replace( "System.Private.CoreLib", "System.Collections" ) ) ]; private sealed class RuntimeContext( ImmutableArray metadataReferences ) @@ -35,9 +41,9 @@ private sealed class RuntimeContext( ImmutableArray metadataR new( OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release ); [MethodImpl( MethodImplOptions.AggressiveInlining )] - public TokenExpression GetTokenExpression( string codeExpression ) + public TokenExpression GetTokenExpression( string codeExpression, MemberDictionary members ) { - return __runtimeContext.TokenExpressions.GetOrAdd( codeExpression, Compile ); + return __runtimeContext.TokenExpressions.GetOrAdd( codeExpression, Compile( codeExpression, members ) ); } public static void Reset() @@ -45,23 +51,30 @@ public static void Reset() __runtimeContext = new RuntimeContext( MetadataReferences ); } - private static TokenExpression Compile( string codeExpression ) + private static TokenExpression Compile( string codeExpression, MemberDictionary members ) { // Create a shim to compile the expression + //AF: I added the linq and regular Expression usings + //AF: the error is in the Regex as it doesn't know what the people are. var codeShim = - $$""" - using Hyperbee.Templating.Text; - using Hyperbee.Templating.Compiler; - - public static class TokenExpressionInvoker - { - public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members ) - { - TokenExpression expr = {{codeExpression}}; - return expr( members ); - } - } - """; + $$$""" + using System; + using System.Linq; + using System.Text.RegularExpressions; + using System.Collections; + using System.Collections.Generic; + using Hyperbee.Templating.Text; + using Hyperbee.Templating.Compiler; + + public static class TokenExpressionInvoker + { + public static object Invoke( {{{nameof( IReadOnlyMemberDictionary )}}} members ) + { + TokenExpression expr = {{{codeExpression}}}; + return expr( members ); + } + } + """; // Parse the code expression var syntaxTree = CSharpSyntaxTree.ParseText( codeShim ); @@ -80,10 +93,10 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members ) }; // Rewrite the lambda expression to use the dictionary lookup - var rewriter = new TokenExpressionRewriter( parameterName ); + var rewriter = new TokenExpressionRewriter( parameterName, members ); var rewrittenSyntaxTree = rewriter.Visit( root ); - //var rewrittenCode = rewrittenSyntaxTree.ToFullString(); // Keep for debugging + var rewrittenCode = rewrittenSyntaxTree.ToFullString(); // Keep for debugging // Compile the rewritten code var counter = Interlocked.Increment( ref __counter ); @@ -101,9 +114,9 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members ) { var failures = result.Diagnostics.Where( diagnostic => diagnostic.IsWarningAsError || - diagnostic.Severity == DiagnosticSeverity.Error ); + diagnostic.Severity == DiagnosticSeverity.Error ).ToArray(); - throw new InvalidOperationException( "Compilation failed: " + string.Join( "\n", failures.Select( diagnostic => diagnostic.GetMessage() ) ) ); + throw new TokenExpressionProviderException( "Compilation failed: " + failures[0]?.GetMessage(), failures ); } peStream.Seek( 0, SeekOrigin.Begin ); @@ -118,6 +131,26 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members ) } } +[Serializable] +internal class TokenExpressionProviderException : Exception +{ + public Diagnostic[] Diagnostic { get; } + public string Id => Diagnostic != null && Diagnostic.Length > 0 ? Diagnostic[0].Id : string.Empty; + + public TokenExpressionProviderException( string message, Diagnostic[] diagnostic ) + : base( message ) + { + Diagnostic = diagnostic; + } + + public TokenExpressionProviderException( string message, Diagnostic[] diagnostic, Exception innerException ) + : base( message, innerException ) + { + Diagnostic = diagnostic; + } + +} + // This rewriter will transform the lambda expression to use dictionary lookup // for property access, method invocation, and 'generic' property casting. // @@ -125,9 +158,9 @@ public static object Invoke( {{nameof( IReadOnlyMemberDictionary )}} members ) // // 1. x => x.someProp to x["someProp"] // 2. x => x.someProp to x.GetValueAs("someProp") -// 3. x => x.someMethod(..) to x.InvokeMethod("someMethod", ..) +// 3. x => x.someMethod(..) to x.Invoke("someMethod", ..) -internal class TokenExpressionRewriter( string parameterName ) : CSharpSyntaxRewriter +internal class TokenExpressionRewriter( string parameterName, MemberDictionary members ) : CSharpSyntaxRewriter { private readonly HashSet _aliases = [parameterName]; @@ -151,7 +184,12 @@ memberAccess.Expression is IdentifierNameSyntax identifier && _aliases.Contains( identifier.Identifier.Text ) ) { // Handle method invocation rewrite - return RewriteMethodInvocation( memberAccess, node ); + + if ( members.Methods.ContainsKey( memberAccess.Name.Identifier.Text ) ) + return RewriteMethodInvocation( memberAccess, node ); + + return node.Update( node.Expression, (ArgumentListSyntax) VisitArgumentList( node.ArgumentList )! ); + //return RewriteMethodInvocation( memberAccess, node ); //BF this rewrite causes the error. we need to disambiguate calls to template lambdas methods } return base.VisitInvocationExpression( node ); @@ -232,12 +270,12 @@ private InvocationExpressionSyntax RewriteMethodInvocation( MemberAccessExpressi .Select( arg => (ExpressionSyntax) Visit( arg.Expression ) ) .ToArray(); - // Create the InvokeMethod call: x.InvokeMethod("MethodName", arg1, arg2, ...) + // Create the InvokeMethod call: x.Invoke("MethodName", arg1, arg2, ...) var invokeMethodCall = SyntaxFactory.InvocationExpression( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, memberAccess.Expression, // This is `x` - SyntaxFactory.IdentifierName( "InvokeMethod" ) + SyntaxFactory.IdentifierName( "Invoke" ) ), SyntaxFactory.ArgumentList( SyntaxFactory.SeparatedList( diff --git a/src/Hyperbee.Templating/Compiler/TokenExpression.cs b/src/Hyperbee.Templating/Compiler/TokenExpression.cs index 67a1e2e..3bb0132 100644 --- a/src/Hyperbee.Templating/Compiler/TokenExpression.cs +++ b/src/Hyperbee.Templating/Compiler/TokenExpression.cs @@ -6,5 +6,5 @@ namespace Hyperbee.Templating.Compiler; public interface ITokenExpressionProvider { - public TokenExpression GetTokenExpression( string codeExpression ); + public TokenExpression GetTokenExpression( string codeExpression, MemberDictionary members ); } diff --git a/src/Hyperbee.Templating/Configure/TemplateOptions.cs b/src/Hyperbee.Templating/Configure/TemplateOptions.cs index 51ddd40..8a526bc 100644 --- a/src/Hyperbee.Templating/Configure/TemplateOptions.cs +++ b/src/Hyperbee.Templating/Configure/TemplateOptions.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Reflection; using Hyperbee.Templating.Compiler; +using Hyperbee.Templating.Core; using Hyperbee.Templating.Text; namespace Hyperbee.Templating.Configure; @@ -13,7 +14,7 @@ public class TemplateOptions public IDictionary Variables { get; init; } public TokenStyle TokenStyle { get; set; } = TokenStyle.Default; - public KeyValidator Validator { get; set; } = TemplateHelper.ValidateKey; + public KeyValidator Validator { get; set; } = KeyHelper.ValidateKey; public bool IgnoreMissingTokens { get; set; } public bool SubstituteEnvironmentVariables { get; set; } diff --git a/src/Hyperbee.Templating/Core/EnumeratorAdapter.cs b/src/Hyperbee.Templating/Core/EnumeratorAdapter.cs new file mode 100644 index 0000000..8304153 --- /dev/null +++ b/src/Hyperbee.Templating/Core/EnumeratorAdapter.cs @@ -0,0 +1,27 @@ +using System.Collections; + +namespace Hyperbee.Templating.Core; + +internal sealed class EnumeratorAdapter : IEnumerator +{ + private readonly IEnumerator _inner; + + internal EnumeratorAdapter( IEnumerable enumerable ) + { + if ( enumerable is not IEnumerable typedEnumerable ) + throw new ArgumentException( "The enumerable must be of type IEnumerable.", nameof( enumerable ) ); + + // take a snapshot of the enumerable to prevent changes during enumeration + var snapshot = new List( typedEnumerable ); + + // ReSharper disable once GenericEnumeratorNotDisposed + _inner = snapshot.GetEnumerator(); + } + + public string Current => (string) _inner.Current; + object IEnumerator.Current => _inner.Current; + + public bool MoveNext() => _inner.MoveNext(); + public void Reset() => _inner.Reset(); + public void Dispose() => (_inner as IDisposable)?.Dispose(); +} diff --git a/src/Hyperbee.Templating/Core/KeyHelper.cs b/src/Hyperbee.Templating/Core/KeyHelper.cs new file mode 100644 index 0000000..d7bee63 --- /dev/null +++ b/src/Hyperbee.Templating/Core/KeyHelper.cs @@ -0,0 +1,55 @@ +namespace Hyperbee.Templating.Core; + +public delegate bool KeyValidator( ReadOnlySpan key ); + +internal static class KeyHelper +{ + public static bool ValidateKey( string key ) + { + // do-not-remove this method. + // + // this method is required despite code analysis claiming the method isn't referenced. + // + // this overload is required (and used) by generic delegates which don't support + // ReadOnlySpan as a generic argument. + + return ValidateKey( key.AsSpan() ); + } + + public static bool ValidateKey( ReadOnlySpan key ) + { + if ( key.IsEmpty || !char.IsLetter( key[0] ) ) + { + return false; + } + + var length = key.Length; + + for ( var i = 1; i < length; i++ ) + { + var current = key[i]; + + if ( current == '[' ) + { + if ( ++i >= length || !char.IsDigit( key[i] ) ) + return false; + + while ( i < length && char.IsDigit( key[i] ) ) + i++; + + if ( i >= length || key[i] != ']' ) + return false; + + // Ensure that the bracket is at the end of the string + if ( i != length - 1 ) + return false; + } + else if ( !char.IsLetterOrDigit( current ) && current != '_' ) + { + return false; + } + } + + return true; + } +} diff --git a/src/Hyperbee.Templating/Hyperbee.Templating.csproj b/src/Hyperbee.Templating/Hyperbee.Templating.csproj index 3cd9c17..2291a27 100644 --- a/src/Hyperbee.Templating/Hyperbee.Templating.csproj +++ b/src/Hyperbee.Templating/Hyperbee.Templating.csproj @@ -43,6 +43,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/src/Hyperbee.Templating/Text/MemberDictionary.cs b/src/Hyperbee.Templating/Text/MemberDictionary.cs index 524e218..fd65a56 100644 --- a/src/Hyperbee.Templating/Text/MemberDictionary.cs +++ b/src/Hyperbee.Templating/Text/MemberDictionary.cs @@ -1,12 +1,13 @@ using System.Collections; using Hyperbee.Templating.Compiler; +using Hyperbee.Templating.Core; namespace Hyperbee.Templating.Text; public interface IReadOnlyMemberDictionary : IReadOnlyDictionary { public TType GetValueAs( string name ) where TType : IConvertible; - public object InvokeMethod( string methodName, params object[] args ); + public object Invoke( string methodName, params object[] args ); } public class MemberDictionary : IReadOnlyMemberDictionary @@ -17,7 +18,7 @@ public class MemberDictionary : IReadOnlyMemberDictionary public KeyValidator Validator { get; } public MemberDictionary( IDictionary source, IReadOnlyDictionary methods = default ) - : this( TemplateHelper.ValidateKey, source, methods ) + : this( KeyHelper.ValidateKey, source, methods ) { } @@ -101,7 +102,7 @@ public TType GetValueAs( string name ) where TType : IConvertible return (TType) Convert.ChangeType( this[name], typeof( TType ) ); } - public object InvokeMethod( string methodName, params object[] args ) + public object Invoke( string methodName, params object[] args ) { if ( !Methods.TryGetValue( methodName, out var methodInvoker ) ) throw new MissingMethodException( $"Failed to invoke method '{methodName}'." ); diff --git a/src/Hyperbee.Templating/Text/TemplateHelper.cs b/src/Hyperbee.Templating/Text/TemplateHelper.cs deleted file mode 100644 index d47c0e3..0000000 --- a/src/Hyperbee.Templating/Text/TemplateHelper.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Hyperbee.Templating.Text; - -public delegate bool KeyValidator( ReadOnlySpan key ); - -internal static class TemplateHelper -{ - public static bool ValidateKey( string key ) - { - // do-not-remove this method. - // - // this method is required despite code analysis claiming the method is not referenced. - // - // this overload is required (and used) by generic delegates which don't support - // ReadOnlySpan as a generic argument. - - return ValidateKey( key.AsSpan() ); - } - - public static bool ValidateKey( ReadOnlySpan key ) - { - if ( key.IsEmpty || !char.IsLetter( key[0] ) ) - return false; - - for ( var i = 1; i < key.Length; i++ ) - { - if ( !char.IsLetterOrDigit( key[i] ) && key[i] != '_' ) - return false; - } - - return true; - } -} diff --git a/src/Hyperbee.Templating/Text/TemplateParser.cs b/src/Hyperbee.Templating/Text/TemplateParser.cs index f700f19..72c1064 100644 --- a/src/Hyperbee.Templating/Text/TemplateParser.cs +++ b/src/Hyperbee.Templating/Text/TemplateParser.cs @@ -19,7 +19,6 @@ public class TemplateParser private readonly int _maxTokenDepth; private readonly string _tokenLeft; private readonly string _tokenRight; - private enum TemplateScanner { Text, @@ -45,7 +44,6 @@ public TemplateParser( TemplateOptions options ) } // Render - all the ways - public void Render( string templateFile, string outputFile ) { using var reader = new StreamReader( templateFile ); @@ -133,14 +131,14 @@ private void ParseTemplate( ReadOnlySpan templateSpan, TextWriter writer ) private void ParseTemplate( TextReader reader, TextWriter writer ) { - var bufferSize = GetScopedBufferSize( BufferSize, _tokenLeft.Length, _tokenRight.Length ); + var bufferSize = GetAdjustedBufferSize( BufferSize, _tokenLeft.Length, _tokenRight.Length ); var bufferManager = new BufferManager( bufferSize ); ParseTemplate( ref bufferManager, reader, writer ); return; - static int GetScopedBufferSize( int bufferSize, int tokenLeftSize, int tokenRightSize ) + static int GetAdjustedBufferSize( int bufferSize, int tokenLeftSize, int tokenRightSize ) { // because of the way we read the buffer, we need to ensure that the buffer size // is at least the size of the longest token delimiter plus one character. @@ -217,7 +215,6 @@ private void ParseTemplate( ref BufferManager bufferManager, TextReader reader, } } - span = []; // clear span for read break; } @@ -249,11 +246,13 @@ private void ParseTemplate( ref BufferManager bufferManager, TextReader reader, if ( tokenAction == TokenAction.ContinueLoop ) continue; + // write value if ( tokenAction != TokenAction.Ignore ) WriteTokenValue( writer, tokenValue, tokenAction, state ); ignore = state.Frames.IsFalsy; + continue; } @@ -270,13 +269,14 @@ private void ParseTemplate( ref BufferManager bufferManager, TextReader reader, bufferManager.AdvanceCurrentSpan( writeLength ); } - span = []; // clear span for read break; } default: throw new ArgumentOutOfRangeException( scanner.ToString(), $"Invalid scanner state: {scanner}." ); } + + span = []; // clear span for read } if ( bufferManager.IsFixed || bytesRead < bufferManager.BufferSize ) @@ -298,7 +298,7 @@ private void ParseTemplate( ref BufferManager bufferManager, TextReader reader, return; - static void ProcessFrame( FrameStack.Frame frame, TokenAction tokenAction, TokenType tokenType, ref ReadOnlySpan span, ref BufferManager bufferManager, ref int loopDepth ) + static void ProcessFrame( Frame frame, TokenAction tokenAction, TokenType tokenType, ref ReadOnlySpan span, ref BufferManager bufferManager, ref int loopDepth ) { // loop handling @@ -361,7 +361,6 @@ private void WriteTokenValue( TextWriter writer, ReadOnlySpan value, Token } // nested token processing - do { // write any leading literal @@ -382,7 +381,6 @@ private void WriteTokenValue( TextWriter writer, ReadOnlySpan value, Token value = value[(stop + _tokenRight.Length)..]; // process token - var innerToken = TokenParser.ParseToken( innerValue, state.NextTokenId++ ); tokenAction = TokenProcessor.ProcessToken( innerToken, state, out var tokenValue ); @@ -399,6 +397,7 @@ private void WriteTokenValue( TextWriter writer, ReadOnlySpan value, Token } while ( start != -1 ); } + // IndexOf helper private record struct IndexOfState() @@ -419,6 +418,8 @@ private int IndexOfIgnoreQuotedContent( ReadOnlySpan span, ReadOnlySpan span, ReadOnlySpan span, ReadOnlySpan span, ReadOnlySpan - Frames.Depth > 0 ? Frames.Peek() : default; -} - -internal sealed class FrameStack -{ - public record Frame( TokenDefinition Token, bool Truthy, int StartPos = -1 ); - - private readonly Stack _stack = new(); - - public void Push( TokenDefinition token, bool truthy, int startPos = -1 ) - => _stack.Push( new Frame( token, truthy, startPos ) ); - - public Frame Peek() => _stack.Peek(); - public void Pop() => _stack.Pop(); - public int Depth => _stack.Count; - - public bool IsTokenType( TokenType compare ) - => _stack.Count > 0 && _stack.Peek().Token.TokenType == compare; - - public bool IsTruthy => _stack.Count == 0 || _stack.Peek().Truthy; - public bool IsFalsy => !IsTruthy; -} diff --git a/src/Hyperbee.Templating/Text/TemplateState.cs b/src/Hyperbee.Templating/Text/TemplateState.cs new file mode 100644 index 0000000..66373d6 --- /dev/null +++ b/src/Hyperbee.Templating/Text/TemplateState.cs @@ -0,0 +1,34 @@ +namespace Hyperbee.Templating.Text; + +internal sealed class TemplateState +{ + public FrameStack Frames { get; } = new(); + public int NextTokenId { get; set; } = 1; + public int CurrentPos { get; set; } + public Frame CurrentFrame() => Frames.Depth > 0 ? Frames.Peek() : default; +} + +// Minimal frame management for flow control + +internal record EnumeratorDefinition( string Name, IEnumerator Enumerator ); + +internal record Frame( TokenDefinition Token, bool Truthy, EnumeratorDefinition EnumeratorDefinition = null, int StartPos = -1 ); + +internal sealed class FrameStack +{ + private readonly Stack _stack = new(); + + public void Push( TokenDefinition token, bool truthy, EnumeratorDefinition enumeratorDefinition = null, int startPos = -1 ) + => _stack.Push( new Frame( token, truthy, enumeratorDefinition, startPos ) ); + + public Frame Peek() => _stack.Peek(); + public void Pop() => _stack.Pop(); + public int Depth => _stack.Count; + + public bool IsTokenType( TokenType compare ) + => _stack.Count > 0 && _stack.Peek().Token.TokenType == compare; + + public bool IsTruthy => _stack.Count == 0 || _stack.Peek().Truthy; + + public bool IsFalsy => !IsTruthy; +} diff --git a/src/Hyperbee.Templating/Text/TokenEnums.cs b/src/Hyperbee.Templating/Text/TokenEnums.cs index f54a5f5..8b5578e 100644 --- a/src/Hyperbee.Templating/Text/TokenEnums.cs +++ b/src/Hyperbee.Templating/Text/TokenEnums.cs @@ -34,7 +34,8 @@ internal enum TokenEvaluation [Flags] internal enum TokenType { - None = 0x00, + Undefined = 0x00, + Define = 0x01, Value = 0x02, If = 0x03, diff --git a/src/Hyperbee.Templating/Text/TokenParser.cs b/src/Hyperbee.Templating/Text/TokenParser.cs index 1cd8c0f..8b2ba7c 100644 --- a/src/Hyperbee.Templating/Text/TokenParser.cs +++ b/src/Hyperbee.Templating/Text/TokenParser.cs @@ -11,7 +11,7 @@ internal class TokenParser internal TokenParser( TemplateOptions options ) { - _validateKey = options.Validator ?? throw new ArgumentNullException( nameof( options.Validator ) ); + _validateKey = options.Validator ?? throw new ArgumentNullException( nameof( options ), $"{nameof( options.Validator )} cannot be null." ); (_tokenLeft, _tokenRight) = options.TokenDelimiters(); } @@ -19,7 +19,7 @@ public TokenDefinition ParseToken( ReadOnlySpan token, int tokenId ) { // token syntax: // - // {{token:definition}} + // {{token: definition}} // // {{token}} // {{x => x.token}} @@ -33,13 +33,34 @@ public TokenDefinition ParseToken( ReadOnlySpan token, int tokenId ) // {{while [!]token}} // {{while x => x.token}} // {{/while}} + // + // {{each n[,i]: x => enumerable}} #1 + // {{n}} + // {{/each}} + + // {{each n[,i]: Person}} // person values or person[] fallback #2 + // {{n}} + // {{/each}} + + /* + x => x.Person* // rewrites to x => x.Enumerate( "Person[*]" ) #4 + x => x.Enumerate( regex ) #3 + + #5 is nesting + + Person[0].Name + Person[1].Name + + Person = "a,b,c" + Person = { "1", "1", "2" } + */ + var span = token.Trim(); - var tokenType = TokenType.None; + var tokenType = TokenType.Undefined; var tokenEvaluation = TokenEvaluation.None; var tokenExpression = ReadOnlySpan.Empty; - var name = ReadOnlySpan.Empty; // if handling @@ -163,50 +184,26 @@ public TokenDefinition ParseToken( ReadOnlySpan token, int tokenId ) tokenType = TokenType.EndWhile; } - // value handling - - if ( tokenType == TokenType.None ) + // each handling + if ( span.StartsWith( "each", StringComparison.OrdinalIgnoreCase ) ) { - var defineTokenPos = span.IndexOfIgnoreDelimitedRanges( ":", "\"" ); - var fatArrowPos = span.IndexOfIgnoreDelimitedRanges( "=>", "\"" ); - - if ( defineTokenPos > -1 && (fatArrowPos == -1 || defineTokenPos < fatArrowPos) ) - { - // Define value - - tokenType = TokenType.Define; - name = span[..defineTokenPos].Trim(); - tokenExpression = UnQuote( span[(defineTokenPos + 1)..] ); - - if ( fatArrowPos > 0 ) - { - tokenEvaluation = TokenEvaluation.Expression; - - // Check and remove surrounding token delimiters (e.g., {{ and }}) - if ( tokenExpression.StartsWith( _tokenLeft ) && tokenExpression.EndsWith( _tokenRight ) ) - { - tokenExpression = tokenExpression[_tokenLeft.Length..^_tokenRight.Length].Trim(); - } - } - } - else if ( fatArrowPos > -1 && (defineTokenPos == -1 || fatArrowPos < defineTokenPos) ) + if ( span.Length == 4 || char.IsWhiteSpace( span[4] ) ) { - // fat arrow value - - tokenType = TokenType.Value; - tokenEvaluation = TokenEvaluation.Expression; - tokenExpression = span; + tokenType = TokenType.Each; + span = span[4..].Trim(); // eat the 'each' } - else - { - // identifier value + } + else if ( span.StartsWith( "/each", StringComparison.OrdinalIgnoreCase ) ) + { + if ( span.Length != 5 ) + throw new TemplateException( "Invalid `/each` statement. Invalid characters." ); - if ( !_validateKey( span ) ) - throw new TemplateException( "Invalid token name." ); + tokenType = TokenType.EndEach; + } - tokenType = TokenType.Value; - name = span; - } + if ( tokenType == TokenType.Undefined || tokenType == TokenType.Each ) + { + tokenType = GetTokenNameAndExpression( tokenType, span, ref name, ref tokenExpression, ref tokenEvaluation ); } // return the definition @@ -222,6 +219,63 @@ public TokenDefinition ParseToken( ReadOnlySpan token, int tokenId ) }; } + private TokenType GetTokenNameAndExpression( TokenType tokenType, ReadOnlySpan span, ref ReadOnlySpan name, ref ReadOnlySpan tokenExpression, ref TokenEvaluation tokenEvaluation ) + { + if ( tokenType != TokenType.Undefined && tokenType != TokenType.Each ) + { + return tokenType; + } + + var defineTokenPos = span.IndexOfIgnoreDelimitedRanges( ":", "\"" ); + var fatArrowPos = span.IndexOfIgnoreDelimitedRanges( "=>", "\"" ); + + if ( defineTokenPos > -1 && (fatArrowPos == -1 || defineTokenPos < fatArrowPos) ) + { + if ( tokenType == TokenType.Undefined ) + tokenType = TokenType.Define; + + name = span[..defineTokenPos].Trim(); + tokenExpression = UnQuote( span[(defineTokenPos + 1)..] ); + + if ( fatArrowPos <= 0 ) + { + return tokenType; + } + + tokenEvaluation = TokenEvaluation.Expression; + + // Check and remove surrounding token delimiters (e.g., {{ and }}) + if ( tokenExpression.StartsWith( _tokenLeft ) && tokenExpression.EndsWith( _tokenRight ) ) + { + tokenExpression = tokenExpression[_tokenLeft.Length..^_tokenRight.Length].Trim(); + } + } + else if ( fatArrowPos > -1 && (defineTokenPos == -1 || fatArrowPos < defineTokenPos) ) + { + // fat arrow value + + if ( tokenType == TokenType.Undefined ) + tokenType = TokenType.Value; + + tokenEvaluation = TokenEvaluation.Expression; + tokenExpression = span; + } + else + { + // identifier value + + if ( !_validateKey( span ) ) + throw new TemplateException( "Invalid token name." ); + + if ( tokenType == TokenType.Undefined ) + tokenType = TokenType.Value; + + name = span; + } + + return tokenType; + } + private static ReadOnlySpan UnQuote( ReadOnlySpan span ) { var found = false; diff --git a/src/Hyperbee.Templating/Text/TokenProcessor.cs b/src/Hyperbee.Templating/Text/TokenProcessor.cs index 29049cf..25d2ffb 100644 --- a/src/Hyperbee.Templating/Text/TokenProcessor.cs +++ b/src/Hyperbee.Templating/Text/TokenProcessor.cs @@ -1,6 +1,10 @@ -using System.Globalization; +using System.Collections; +using System.Globalization; using Hyperbee.Templating.Compiler; using Hyperbee.Templating.Configure; +using Hyperbee.Templating.Core; + +// ReSharper disable RedundantAssignment namespace Hyperbee.Templating.Text; @@ -12,7 +16,6 @@ internal class TokenProcessor private readonly bool _substituteEnvironmentVariables; private readonly string _tokenLeft; private readonly string _tokenRight; - private readonly MemberDictionary _members; public TokenProcessor( MemberDictionary members, TemplateOptions options ) @@ -29,7 +32,6 @@ public TokenProcessor( MemberDictionary members, TemplateOptions options ) _tokenHandler = options.TokenHandler; _ignoreMissingTokens = options.IgnoreMissingTokens; _substituteEnvironmentVariables = options.SubstituteEnvironmentVariables; - _members = members; (_tokenLeft, _tokenRight) = options.TokenDelimiters(); @@ -40,15 +42,16 @@ public TokenAction ProcessToken( TokenDefinition token, TemplateState state, out value = default; var frames = state.Frames; - // Frame handling: pre-value processing + // Initial handling based on token type switch ( token.TokenType ) { case TokenType.Value: - if ( frames.IsFalsy ) - return TokenAction.Ignore; + if ( frames.IsFalsy ) return TokenAction.Ignore; break; case TokenType.If: + case TokenType.While: + case TokenType.Each: // Fall through to resolve value. break; @@ -58,62 +61,86 @@ public TokenAction ProcessToken( TokenDefinition token, TemplateState state, out case TokenType.Endif: return ProcessEndIfToken( frames ); - case TokenType.While: - // Fall through to resolve value. - break; - case TokenType.EndWhile: return ProcessEndWhileToken( frames ); + case TokenType.EndEach: + return ProcessEndEachToken( frames ); + case TokenType.Define: return ProcessDefineToken( token ); - case TokenType.None: + case TokenType.Undefined: + case TokenType.LoopStart: + case TokenType.LoopEnd: default: throw new NotSupportedException( $"{nameof( ProcessToken )}: Invalid {nameof( TokenType )} {token.TokenType}." ); } - // Resolve value - - ResolveValue( token, out value, out var defined, out var ifResult, out var expressionError ); - - // Frame handling: post-value processing + // Resolve value + ResolveValue( token, out var resolvedValue, out var defined, out var conditionalResult, out var expressionError ); + // Conditional frame handling based on token type after value resolution switch ( token.TokenType ) { case TokenType.If: + return ProcessIfToken( token, frames, conditionalResult ); + case TokenType.While: - { - var frameIsTruthy = token.TokenEvaluation == TokenEvaluation.Falsy ? !ifResult : ifResult; - var startPos = token.TokenType == TokenType.While ? state.CurrentPos : -1; + return ProcessWhileToken( token, frames, conditionalResult, state ); - frames.Push( token, frameIsTruthy, startPos ); + case TokenType.Each: + return ProcessEachToken( token, frames, resolvedValue, state, out value ); - return TokenAction.Ignore; - } + default: + value = (string) resolvedValue; + break; } - // Token handling: user-defined token action - - _ = TryInvokeTokenHandler( token, defined, ref value, out var tokenAction ); + return ProcessTokenHandler( token, defined, ref value, expressionError ); + } - // Handle final token action + private TokenAction ProcessTokenHandler( TokenDefinition token, bool defined, ref string value, string expressionError ) + { + if ( !TryInvokeTokenHandler( token, defined, ref value, out var tokenAction ) ) + { + tokenAction = defined ? TokenAction.Replace : (_ignoreMissingTokens ? TokenAction.Ignore : TokenAction.Error); + } - switch ( tokenAction ) + // Determine final action based on token handler and missing tokens + if ( tokenAction == TokenAction.Error && !defined ) { - case TokenAction.Ignore: - case TokenAction.Replace: - break; + value = $"{_tokenLeft}Error ({token.Id}):{expressionError ?? token.Name}{_tokenRight}"; + } - case TokenAction.Error: - value = $"{_tokenLeft}Error ({token.Id}):{expressionError ?? token.Name}{_tokenRight}"; - break; + return tokenAction; + } + private TokenAction ProcessDefineToken( TokenDefinition token ) + { + string expressionError = null; + string value; + switch ( token.TokenEvaluation ) + { + case TokenEvaluation.Expression when TryInvokeTokenExpression( token, out var expressionResult, out expressionError ): + value = Convert.ToString( expressionResult, CultureInfo.InvariantCulture ); + break; + case TokenEvaluation.Expression: + throw new TemplateException( $"Error evaluating define expression for {token.Name}: {expressionError}" ); default: - throw new NotSupportedException( $"{nameof( ProcessToken )}: Invalid {nameof( TokenAction )} {tokenAction}." ); + value = token.TokenExpression; + break; } - return tokenAction; + _members[token.Name] = value; + return TokenAction.Ignore; + } + + private static TokenAction ProcessIfToken( TokenDefinition token, FrameStack frames, bool conditionalResult ) + { + var frameIsTruthy = token.TokenEvaluation == TokenEvaluation.Falsy ? !conditionalResult : conditionalResult; + frames.Push( token, frameIsTruthy ); + return TokenAction.Ignore; } private static TokenAction ProcessElseToken( FrameStack frames, TokenDefinition token ) @@ -134,7 +161,13 @@ private static TokenAction ProcessEndIfToken( FrameStack frames ) frames.Pop(); // pop the else frames.Pop(); // pop the if + return TokenAction.Ignore; + } + private static TokenAction ProcessWhileToken( TokenDefinition token, FrameStack frames, bool conditionalResult, TemplateState state ) + { + var frameIsTruthy = token.TokenEvaluation == TokenEvaluation.Falsy ? !conditionalResult : conditionalResult; + frames.Push( token, frameIsTruthy, null, state.CurrentPos ); return TokenAction.Ignore; } @@ -145,45 +178,66 @@ private TokenAction ProcessEndWhileToken( FrameStack frames ) var whileToken = frames.Peek().Token; - // ReSharper disable once RedundantAssignment - string expressionError = null; // assign to avoid compiler complaint + bool conditionIsTrue; + string expressionError = null; - var conditionIsTrue = whileToken.TokenEvaluation switch + switch ( whileToken.TokenEvaluation ) { - TokenEvaluation.Expression when TryInvokeTokenExpression( whileToken, out var expressionResult, out expressionError ) => Convert.ToBoolean( expressionResult ), - TokenEvaluation.Expression => throw new TemplateException( $"{_tokenLeft}Error ({whileToken.Id}):{expressionError ?? "Error in while condition."}{_tokenRight}" ), - _ => Truthy( _members[whileToken.Name] ) // Re-evaluate the condition - }; + case TokenEvaluation.Expression when TryInvokeTokenExpression( whileToken, out var expressionResult, out expressionError ): + conditionIsTrue = Convert.ToBoolean( expressionResult ); + break; + case TokenEvaluation.Expression: + throw new TemplateException( $"{_tokenLeft}Error ({whileToken.Id}):{expressionError ?? "Error in while condition."}{_tokenRight}" ); + default: + conditionIsTrue = Truthy( _members[whileToken.Name] ); + break; + } - if ( conditionIsTrue ) // If the condition is true, replay the while block + if ( conditionIsTrue ) return TokenAction.ContinueLoop; - // Otherwise, pop the frame and exit the loop frames.Pop(); return TokenAction.Ignore; } - private TokenAction ProcessDefineToken( TokenDefinition token ) + private TokenAction ProcessEachToken( TokenDefinition token, FrameStack frames, object resolvedValue, TemplateState state, out string value ) { - // ReSharper disable once RedundantAssignment - string expressionError = null; // assign to avoid compiler complaint + value = default; - _members[token.Name] = token.TokenEvaluation switch + if ( resolvedValue is IEnumerator enumerator && enumerator.MoveNext() ) { - TokenEvaluation.Expression when TryInvokeTokenExpression( token, out var expressionResult, out expressionError ) - => Convert.ToString( expressionResult, CultureInfo.InvariantCulture ), - TokenEvaluation.Expression - => throw new TemplateException( $"Error evaluating define expression for {token.Name}: {expressionError}" ), - _ => token.TokenExpression - }; + value = enumerator.Current; + _members[token.Name] = value; + frames.Push( token, true, new EnumeratorDefinition( Name: token.Name, Enumerator: enumerator ), state.CurrentPos ); + } + + return TokenAction.Ignore; + } + + private TokenAction ProcessEndEachToken( FrameStack frames ) + { + if ( frames.Depth == 0 || !frames.IsTokenType( TokenType.Each ) ) + throw new TemplateException( "Syntax error. Invalid /each without matching each." ); + + var frame = frames.Peek(); + var (currentName, enumerator) = frame.EnumeratorDefinition; + + if ( enumerator!.MoveNext() ) + { + _members[currentName] = enumerator.Current; + return TokenAction.ContinueLoop; + } + + _members[currentName] = default; + frames.Pop(); return TokenAction.Ignore; } - private void ResolveValue( TokenDefinition token, out string value, out bool defined, out bool ifResult, out string expressionError ) + private void ResolveValue( TokenDefinition token, out object value, out bool defined, out bool conditionalResult, out string expressionError ) { value = default; defined = false; - ifResult = false; + conditionalResult = false; expressionError = null; switch ( token.TokenType ) @@ -191,44 +245,61 @@ private void ResolveValue( TokenDefinition token, out string value, out bool def case TokenType.Value when token.TokenEvaluation != TokenEvaluation.Expression: case TokenType.If when token.TokenEvaluation != TokenEvaluation.Expression: case TokenType.While when token.TokenEvaluation != TokenEvaluation.Expression: - defined = _members.TryGetValue( token.Name, out value ); - - if ( !defined && _substituteEnvironmentVariables ) { - value = Environment.GetEnvironmentVariable( token.Name ); - defined = value != null; - } + defined = _members.TryGetValue( token.Name, out var valueMember ); + value = defined ? valueMember : GetEnvironmentVariableValue( token.Name ); - if ( token.TokenType == TokenType.If || token.TokenType == TokenType.While ) - ifResult = defined && Truthy( value ); - break; + if ( token.TokenType == TokenType.If || token.TokenType == TokenType.While || token.TokenType == TokenType.Each ) + conditionalResult = defined && Truthy( valueMember ); + break; + } case TokenType.Value when token.TokenEvaluation == TokenEvaluation.Expression: - if ( TryInvokeTokenExpression( token, out var valueExprResult, out expressionError ) ) { - value = Convert.ToString( valueExprResult, CultureInfo.InvariantCulture ); - defined = true; - } + if ( TryInvokeTokenExpression( token, out var valueExprResult, out expressionError ) ) + { + value = Convert.ToString( valueExprResult, CultureInfo.InvariantCulture ); + defined = true; + } - break; + break; + } case TokenType.If when token.TokenEvaluation == TokenEvaluation.Expression: case TokenType.While when token.TokenEvaluation == TokenEvaluation.Expression: - if ( TryInvokeTokenExpression( token, out var condExprResult, out var error ) ) - ifResult = Convert.ToBoolean( condExprResult ); - else - throw new TemplateException( $"{_tokenLeft}Error ({token.Id}):{error ?? "Error in if condition."}{_tokenRight}" ); - break; + { + if ( !TryInvokeTokenExpression( token, out var condExprResult, out var error ) ) + throw new TemplateException( $"{_tokenLeft}Error ({token.Id}):{error ?? "Error in condition."}{_tokenRight}" ); + + conditionalResult = Convert.ToBoolean( condExprResult ); + break; + } + + case TokenType.Each: + { + if ( token.TokenEvaluation != TokenEvaluation.Expression ) + throw new TemplateException( "Invalid token expression for each. Are you missing a fat arrow?" ); + + if ( !TryInvokeTokenExpression( token, out var eachExprResult, out var errorEach ) ) + throw new TemplateException( $"{_tokenLeft}Error ({token.Id}):{errorEach ?? "Error in each condition."}{_tokenRight}" ); + + value = new EnumeratorAdapter( (IEnumerable) eachExprResult ); + break; + } + } + + return; + + string GetEnvironmentVariableValue( string name ) + { + return _substituteEnvironmentVariables ? Environment.GetEnvironmentVariable( token.Name ) : default; } } private bool TryInvokeTokenHandler( TokenDefinition token, bool defined, ref string value, out TokenAction tokenAction ) { tokenAction = defined ? TokenAction.Replace : (_ignoreMissingTokens ? TokenAction.Ignore : TokenAction.Error); - - // Invoke any token handler - if ( _tokenHandler == null ) - return false; + if ( _tokenHandler == null ) return false; var eventArgs = new TemplateEventArgs { @@ -241,10 +312,8 @@ private bool TryInvokeTokenHandler( TokenDefinition token, bool defined, ref str _tokenHandler.Invoke( null, eventArgs ); - // The token handler may have modified token properties value = eventArgs.Value; tokenAction = eventArgs.Action; - return true; } @@ -252,44 +321,42 @@ private bool TryInvokeTokenExpression( TokenDefinition token, out object result, { try { - var tokenExpression = _tokenExpressionProvider.GetTokenExpression( token.TokenExpression ); - + var tokenExpression = _tokenExpressionProvider.GetTokenExpression( token.TokenExpression, _members ); result = tokenExpression( _members ); - error = default; - + error = null; return true; } catch ( Exception ex ) { - error = ex.Message; - } - - result = default; - return false; - } - - private static readonly string[] FalsyStrings = ["False", "No", "Off", "0"]; + if ( ex is TokenExpressionProviderException providerException && providerException.Id == "CS1061" ) + { + string methodName = null; - private static bool Truthy( ReadOnlySpan value ) - { - // falsy => null, String.Empty, False, No, Off, 0 + if ( providerException.Diagnostic.Length > 0 ) + { + var location = providerException.Diagnostic[0].Location; + var sourceTree = location.SourceTree; + var sourceSpan = location.SourceSpan; + methodName = sourceTree?.ToString().Substring( sourceSpan.Start, sourceSpan.Length ); + } - var truthy = !value.IsEmpty; + error = $"Method '{methodName ?? ""}' not found."; + } + else + { + error = ex.Message; + } - if ( !truthy ) + result = null; return false; - - var compare = value.Trim(); - - foreach ( var item in FalsyStrings ) - { - if ( !compare.SequenceEqual( item ) ) - continue; - - truthy = false; - break; } + } - return truthy; + private static readonly HashSet FalsyStrings = new( ["False", "No", "Off", "0"], StringComparer.OrdinalIgnoreCase ); + + private static bool Truthy( ReadOnlySpan value ) + { + var trimmed = value.Trim(); + return !trimmed.IsEmpty && !FalsyStrings.Contains( trimmed.ToString() ); } } diff --git a/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj b/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj index d0d5773..0029a1e 100644 --- a/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj +++ b/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/test/Hyperbee.Templating.Tests/Compiler/RoslynTokenExpressionProviderTests.cs b/test/Hyperbee.Templating.Tests/Compiler/RoslynTokenExpressionProviderTests.cs index da32f1c..c64c953 100644 --- a/test/Hyperbee.Templating.Tests/Compiler/RoslynTokenExpressionProviderTests.cs +++ b/test/Hyperbee.Templating.Tests/Compiler/RoslynTokenExpressionProviderTests.cs @@ -22,8 +22,8 @@ public void Should_compile_expression() ["Value"] = "base" }; - var tokenExpression = compiler.GetTokenExpression( expression ); var variables = new MemberDictionary( tokens ); + var tokenExpression = compiler.GetTokenExpression( expression, variables ); // act @@ -48,8 +48,8 @@ public void Should_compile_cast_expression() ["Value"] = "1" }; - var tokenExpression = compiler.GetTokenExpression( expression ); var variables = new MemberDictionary( tokens ); + var tokenExpression = compiler.GetTokenExpression( expression, variables ); // act @@ -74,8 +74,8 @@ public void Should_compile_statement_expression() ["Value"] = "base" }; - var tokenExpression = compiler.GetTokenExpression( expression ); var variables = new MemberDictionary( tokens ); + var tokenExpression = compiler.GetTokenExpression( expression, variables ); // act @@ -98,11 +98,11 @@ public void Should_compile_multiple_expressions() var tokens = new Dictionary { ["Value"] = "base" }; - var tokenExpression1 = compiler.GetTokenExpression( expression1 ); - var tokenExpression2 = compiler.GetTokenExpression( expression2 ); - var variables = new MemberDictionary( tokens ); + var tokenExpression1 = compiler.GetTokenExpression( expression1, variables ); + var tokenExpression2 = compiler.GetTokenExpression( expression2, variables ); + // act var result1 = tokenExpression1( variables ); diff --git a/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj b/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj index d82c74a..a815591 100644 --- a/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj +++ b/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj @@ -1,6 +1,7 @@ - + net8.0 + false false @@ -12,9 +13,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs b/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs index 44d5210..50171b7 100644 --- a/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs +++ b/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs @@ -7,6 +7,27 @@ namespace Hyperbee.Templating.Tests.Text; [TestClass] public class TemplateParserExpressionTests { + [DataTestMethod] + [DataRow( ParseTemplateMethod.Buffered )] + //[DataRow( ParseTemplateMethod.InMemory )] + public void Should_honor_while_condition( ParseTemplateMethod parseMethod ) + { + // arrange + const string expression = "{{while x => int.Parse(x.counter) < 3}}{{counter}}{{counter:{{x => int.Parse(x.counter) + 1}}}}{{/while}}"; + const string template = $"count: {expression}."; + + var parser = new TemplateParser { Variables = { ["counter"] = "0" } }; + + // act + var result = parser.Render( template, parseMethod ); + + // assert + var expected = "count: 012."; + + Assert.AreEqual( expected, result ); + } + + [DataTestMethod] [DataRow( ParseTemplateMethod.Buffered )] [DataRow( ParseTemplateMethod.InMemory )] @@ -27,13 +48,7 @@ public void Should_honor_block_expression( ParseTemplateMethod parseMethod ) const string template = $"hello {expression}."; - var parser = new TemplateParser - { - Variables = - { - ["choice"] = "2" - } - }; + var parser = new TemplateParser { Variables = { ["choice"] = "2" } }; // act @@ -90,13 +105,7 @@ public void Should_honor_inline_block_expression( ParseTemplateMethod parseMetho const string template = $"{definition}hello {expression}."; - var parser = new TemplateParser - { - Variables = - { - ["choice"] = "2" - } - }; + var parser = new TemplateParser { Variables = { ["choice"] = "2" } }; // act var result = parser.Render( template, parseMethod ); @@ -108,4 +117,5 @@ public void Should_honor_inline_block_expression( ParseTemplateMethod parseMetho Assert.AreEqual( expected, result ); } + } diff --git a/test/Hyperbee.Templating.Tests/Text/TemplateParser.LoopTests.cs b/test/Hyperbee.Templating.Tests/Text/TemplateParser.LoopTests.cs index a57d169..abc9972 100644 --- a/test/Hyperbee.Templating.Tests/Text/TemplateParser.LoopTests.cs +++ b/test/Hyperbee.Templating.Tests/Text/TemplateParser.LoopTests.cs @@ -14,6 +14,7 @@ public void Should_honor_while_condition( ParseTemplateMethod parseMethod ) { // arrange const string expression = "{{while x => int.Parse(x.counter) < 3}}{{counter}}{{counter:{{x => int.Parse(x.counter) + 1}}}}{{/while}}"; + const string template = $"count: {expression}."; var parser = new TemplateParser @@ -32,4 +33,83 @@ public void Should_honor_while_condition( ParseTemplateMethod parseMethod ) Assert.AreEqual( expected, result ); } + + [DataTestMethod] + [DataRow( ParseTemplateMethod.Buffered )] + [DataRow( ParseTemplateMethod.InMemory )] + public void Should_honor_each_expression( ParseTemplateMethod parseMethod ) + { + // arrange + const string expression = "{{each n:x => x.list.Split( \",\" )}}World {{n}},{{/each}}"; + + const string template = $"hello {expression}."; + + var parser = new TemplateParser { Variables = { ["list"] = "1,2,3" } }; + + // act + var result = parser.Render( template, parseMethod ); + + // assert + var expected = "hello World 1,World 2,World 3,."; + + Assert.AreEqual( expected, result ); + } + + [DataTestMethod] + [DataRow( ParseTemplateMethod.Buffered )] + [DataRow( ParseTemplateMethod.InMemory )] + public void Should_honor_each_expression_RegEx( ParseTemplateMethod parseMethod ) + { + // arrange + const string expression = "{{each n:x => x.Where( t => Regex.IsMatch( t.Key, \"people*\" ) ).Select( t => t.Value )}}hello {{n}}. {{/each}}"; + + const string template = $"{expression}"; + + var parser = new TemplateParser + { + Variables = + { + ["people[0]"] = "John", + ["people[1]"] = "Jane", + ["people[2]"] = "Doe" + } + }; + + // act + var result = parser.Render( template, parseMethod ); + + // assert + var expected = "hello John. hello Jane. hello Doe. "; + + Assert.AreEqual( expected, result ); + } + + [DataTestMethod] + [DataRow( ParseTemplateMethod.Buffered )] + [DataRow( ParseTemplateMethod.InMemory )] + public void Should_honor_each_Key( ParseTemplateMethod parseMethod ) + { + // arrange + const string expression = "{{each n:x => x.Where( t => Regex.IsMatch( t.Key, \"people*\" ) ).Select( t => t.Value )}}hello {{n}}. {{/each}}"; + + const string template = $"{expression}"; + + var parser = new TemplateParser + { + Variables = + { + ["people[0]"] = "John", + ["people[1]"] = "Jane", + ["people[2]"] = "Doe" + } + }; + + // act + var result = parser.Render( template, parseMethod ); + + // assert + var expected = "hello John. hello Jane. hello Doe. "; + + Assert.AreEqual( expected, result ); + } } diff --git a/test/Hyperbee.Templating.Tests/Text/TemplateParser.MethodTests.cs b/test/Hyperbee.Templating.Tests/Text/TemplateParser.MethodTests.cs index 6cc8970..0b07aae 100644 --- a/test/Hyperbee.Templating.Tests/Text/TemplateParser.MethodTests.cs +++ b/test/Hyperbee.Templating.Tests/Text/TemplateParser.MethodTests.cs @@ -85,7 +85,7 @@ public void Should_not_replace_token_when_method_is_missing( ParseTemplateMethod // assert - var expected = template.Replace( expression, "{{Error (1):Failed to invoke method 'missing'.}}" ); + var expected = template.Replace( expression, "{{Error (1):Method 'missing' not found.}}" ); Assert.AreEqual( expected, result ); }