diff --git a/Hyperbee.Templating.sln b/Hyperbee.Templating.sln index d20387d..df19061 100644 --- a/Hyperbee.Templating.sln +++ b/Hyperbee.Templating.sln @@ -37,6 +37,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hyperbee.Templating.Benchma EndProject Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "docs", "docs\docs.shproj", "{8409DDE0-C540-4A94-BF7F-9403888BEDAC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hyperbee.Templating.Provider.XS", "src\Hyperbee.Templating.Provider.XS\Hyperbee.Templating.Provider.XS.csproj", "{EA264696-D88A-288E-0D8E-C834780D5F9E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,10 @@ Global {EB7D2A85-3C82-444A-84CD-D245DCF951CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB7D2A85-3C82-444A-84CD-D245DCF951CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB7D2A85-3C82-444A-84CD-D245DCF951CE}.Release|Any CPU.Build.0 = Release|Any CPU + {EA264696-D88A-288E-0D8E-C834780D5F9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA264696-D88A-288E-0D8E-C834780D5F9E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA264696-D88A-288E-0D8E-C834780D5F9E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA264696-D88A-288E-0D8E-C834780D5F9E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Hyperbee.Templating.Provider.XS/Compiler/XsTokenExpressionProvider.cs b/src/Hyperbee.Templating.Provider.XS/Compiler/XsTokenExpressionProvider.cs new file mode 100644 index 0000000..c197991 --- /dev/null +++ b/src/Hyperbee.Templating.Provider.XS/Compiler/XsTokenExpressionProvider.cs @@ -0,0 +1,142 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using Hyperbee.Templating.Compiler; +using Hyperbee.Templating.Text; +using Hyperbee.XS; +using Hyperbee.XS.Core; +using static System.Linq.Expressions.Expression; + +namespace Hyperbee.Templating.Provider.XS.Compiler; + +public delegate TokenExpression CompileLambda( Expression lambda ); + +public sealed class XsTokenExpressionProvider : ITokenExpressionProvider +{ + private readonly ConcurrentDictionary TokenExpressions = new(); + private readonly CompileLambda _compile; + private readonly XsParser _xsParser; + + public XsTokenExpressionProvider( + CompileLambda compile = null, + TypeResolver typeResolver = null, + List extensions = null ) + { + _compile = compile ?? (lambda => lambda.Compile()); + typeResolver ??= new MemberTypeResolver( ReferenceManager.Create() ); + + _xsParser = new XsParser( + new XsConfig( typeResolver ) { Extensions = extensions ?? [] } + ); + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public TokenExpression GetTokenExpression( string codeExpression, MemberDictionary members ) + { + return TokenExpressions.GetOrAdd( codeExpression, _ => Compile( codeExpression ) ); + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public void Reset() + { + TokenExpressions.Clear(); + } + + private TokenExpression Compile( ReadOnlySpan codeExpression ) + { + var start = codeExpression.IndexOf( "=>" ); + var argument = codeExpression[..start].Trim().ToString(); + var body = codeExpression[(start + 2)..].Trim().ToString(); + + var scope = new ParseScope(); + + try + { + scope.EnterScope( FrameType.Method ); + + var codeParameter = Parameter( typeof( IReadOnlyMemberDictionary ), argument ); + + scope.Variables.Add( argument, codeParameter ); + + var expression = _xsParser.Parse( body, scope: scope ); + var expressionBody = expression as BlockExpression; + + var lambdaParameter = Parameter( typeof( IReadOnlyMemberDictionary ) ); + + // create a new block expression assigning the parameter to the argument + var expressions = new List { Assign( codeParameter, lambdaParameter ) }; + if ( expressionBody == null ) + expressions.Add( expression ); + else + expressions.AddRange( expressionBody.Expressions ); + + var lambda = Lambda( + Convert( + Block( + expressionBody?.Variables, + expressions + ), + typeof( object ) + ), + lambdaParameter ); + + return _compile( lambda ); + } + finally + { + scope.ExitScope(); + } + } + + public class MemberTypeResolver : TypeResolver + { + private static readonly MethodInfo MemberInvoke = typeof( IReadOnlyMemberDictionary ) + .GetMethod( nameof( IReadOnlyMemberDictionary.Invoke ), [typeof( string ), typeof( object[] )] )!; + + private static readonly MethodInfo MemberGetValueAs = typeof( IReadOnlyMemberDictionary ) + .GetMethod( nameof( IReadOnlyMemberDictionary.GetValueAs ), [typeof( string )] )!; + + private static readonly PropertyInfo MemberIndexer = typeof( MemberDictionary ) + .GetProperties() + .First( x => x.GetIndexParameters().Length > 0 ); + public MemberTypeResolver( ReferenceManager referenceManager ) : base( referenceManager ) { } + + // Resolves a member expression for the given target expression. + // + // 1. x => x.someProp to x["someProp"] + // 2. x => x.someProp to x.GetValueAs("someProp") + // 3. x => x.someMethod(..) to x.Invoke("someMethod", ..) + + public override Expression RewriteMemberExpression( Expression targetExpression, string name, IReadOnlyList typeArgs, IReadOnlyList args ) + { + if ( targetExpression.Type != typeof( IReadOnlyMemberDictionary ) ) + return base.RewriteMemberExpression( targetExpression, name, typeArgs, args ); + + if ( args != null ) + { + return Call( + targetExpression, + MemberInvoke, + Constant( name ), + NewArrayInit( typeof( object ), args ) + ); + } + + if ( typeArgs != null ) + { + return Call( + targetExpression, + MemberGetValueAs + .MakeGenericMethod( typeArgs[0] ), + Constant( name ) ); + } + + return Property( + Convert( targetExpression, typeof( MemberDictionary ) ), + MemberIndexer, + Constant( name ) ); + + } + } +} diff --git a/src/Hyperbee.Templating.Provider.XS/Hyperbee.Templating.Provider.XS.csproj b/src/Hyperbee.Templating.Provider.XS/Hyperbee.Templating.Provider.XS.csproj new file mode 100644 index 0000000..b492d59 --- /dev/null +++ b/src/Hyperbee.Templating.Provider.XS/Hyperbee.Templating.Provider.XS.csproj @@ -0,0 +1,52 @@ + + + net9.0 + enable + true + Stillpoint Software, Inc. + README.md + templating;token;xs + icon.png + https://stillpoint-software.github.io/hyperbee.templating/ + net9.0 + LICENSE + Stillpoint Software, Inc. + Hyperbee Templating Provider XS + + Adds an ITokenExpressionProvider powder by Hyperbee.XS + + https://github.com/Stillpoint-Software/Hyperbee.Templating + git + https://github.com/Stillpoint-Software/hyperbee.templating/releases/latest + true + + + + + <_Parameter1>$(AssemblyName).Tests + + + <_Parameter1>$(AssemblyName).Benchmark + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + \ No newline at end of file diff --git a/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj b/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj index 61d711a..8f8d1c0 100644 --- a/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj +++ b/test/Hyperbee.Templating.Benchmark/Hyperbee.Templating.Benchmark.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,9 +11,11 @@ + + diff --git a/test/Hyperbee.Templating.Benchmark/TemplateBenchmarks.cs b/test/Hyperbee.Templating.Benchmark/TemplateBenchmarks.cs index 024e578..f9d3521 100644 --- a/test/Hyperbee.Templating.Benchmark/TemplateBenchmarks.cs +++ b/test/Hyperbee.Templating.Benchmark/TemplateBenchmarks.cs @@ -1,10 +1,16 @@ using BenchmarkDotNet.Attributes; +using FastExpressionCompiler; +using Hyperbee.Templating.Configure; +using Hyperbee.Templating.Provider.XS.Compiler; using Hyperbee.Templating.Text; +using Hyperbee.XS.Core; namespace Hyperbee.Templating.Benchmark; public class TemplateBenchmarks { + private static readonly TypeResolver TypeResolver = new XsTokenExpressionProvider.MemberTypeResolver( ReferenceManager.Create() ); + [Benchmark( Baseline = true )] public void ParserSingleLine() { @@ -68,5 +74,33 @@ public void InlineBlockExpression() } } ); } + + [Benchmark] + public void InlineBlockExpressionXs() + { + const string expression = "{{name}}"; + const string definition = + """ + {{name:{{x => { + switch( x.choice ) + { + case "1": "me"; + case "2": "you"; + default: "default"; + }; + } }} }} + """; + + const string template = $"{definition}hello {expression}."; + + Template.Render( template, new TemplateOptions + { + Variables = { ["choice"] = "2" }, + TokenExpressionProvider = new XsTokenExpressionProvider( + compile: lambda => lambda.CompileFast(), + typeResolver: TypeResolver + ) + } ); + } } diff --git a/test/Hyperbee.Templating.Tests/Compiler/XsTokenExpressionProviderTests.cs b/test/Hyperbee.Templating.Tests/Compiler/XsTokenExpressionProviderTests.cs new file mode 100644 index 0000000..4720ee2 --- /dev/null +++ b/test/Hyperbee.Templating.Tests/Compiler/XsTokenExpressionProviderTests.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using Hyperbee.Templating.Provider.XS.Compiler; +using Hyperbee.Templating.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Templating.Tests.Compiler; + +[TestClass] +public class XsTokenExpressionProviderTests +{ + [TestMethod] + public void Should_compile_expression() + { + // arrange + + const string expression = """" + vars => String.Concat( "all your ", + vars.Value, + " are belong to us." + ).ToUpper(); + """"; + + var compiler = new XsTokenExpressionProvider(); + + var tokens = new Dictionary + { + ["Value"] = "base" + }; + + var variables = new MemberDictionary( tokens ); + var tokenExpression = compiler.GetTokenExpression( expression, variables ); + + // act + + var result = tokenExpression( variables ); + + // assert + + Assert.AreEqual( "ALL YOUR BASE ARE BELONG TO US.", result ); + } + + [TestMethod] + public void Should_compile_cast_expression() + { + // arrange + + const string expression = """" + vars => String.Concat( "all your ", + (1 + vars.Value).ToString(), + " base are belong to us." + ).ToUpper(); + """"; + + var compiler = new XsTokenExpressionProvider(); + + var tokens = new Dictionary + { + ["Value"] = "1" + }; + + var variables = new MemberDictionary( tokens ); + var tokenExpression = compiler.GetTokenExpression( expression, variables ); + + // act + + var result = tokenExpression( variables ); + + // assert + + Assert.AreEqual( "ALL YOUR 2 BASE ARE BELONG TO US.", result ); + } + + [TestMethod] + public void Should_compile_statement_expression() + { + // arrange + + const string expression = """" + vars => String.Concat( "all your ", + vars.Value, + " are belong to us." + ).ToUpper(); + """"; + + var compiler = new XsTokenExpressionProvider(); + + var tokens = new Dictionary + { + ["Value"] = "base" + }; + + var variables = new MemberDictionary( tokens ); + var tokenExpression = compiler.GetTokenExpression( expression, variables ); + + // act + + var result = tokenExpression( variables ); + + // assert + + Assert.AreEqual( "ALL YOUR BASE ARE BELONG TO US.", result ); + } + + [TestMethod] + public void Should_compile_multiple_expressions() + { + // arrange + + const string expression1 = """" + vars => String.Concat( "all your ", + vars.Value, + " are belong to us." + ).ToUpper(); + """"; + const string expression2 = """" + vars => String.Concat( "all your ", + vars.Value, + " are not belong to us." + ).ToUpper(); + """"; + + var compiler = new XsTokenExpressionProvider(); + + var tokens = new Dictionary { ["Value"] = "base" }; + + var variables = new MemberDictionary( tokens ); + + var tokenExpression1 = compiler.GetTokenExpression( expression1, variables ); + var tokenExpression2 = compiler.GetTokenExpression( expression2, variables ); + + // act + + var result1 = tokenExpression1( variables ); + var result2 = tokenExpression2( variables ); + + // assert + + Assert.AreEqual( "ALL YOUR BASE ARE BELONG TO US.", result1 ); + Assert.AreEqual( "ALL YOUR BASE ARE NOT BELONG TO US.", result2 ); + } +} diff --git a/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj b/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj index f70cabc..9081531 100644 --- a/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj +++ b/test/Hyperbee.Templating.Tests/Hyperbee.Templating.Tests.csproj @@ -13,6 +13,9 @@ + + + @@ -23,6 +26,7 @@ + diff --git a/test/Hyperbee.Templating.Tests/TestSupport/ServiceProvider.cs b/test/Hyperbee.Templating.Tests/TestSupport/ServiceProvider.cs new file mode 100644 index 0000000..d6cf077 --- /dev/null +++ b/test/Hyperbee.Templating.Tests/TestSupport/ServiceProvider.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Hyperbee.Templating.Tests.TestSupport; + +public interface ITestService +{ + string DoSomething(); +} + +public class TestService : ITestService +{ + public TestService() { } + public TestService( string extra ) => Extra = extra; + public string Extra { get; set; } + + public string DoSomething() => "World" + Extra; +} + +public static class ServiceProvider +{ + public static IServiceProvider GetServiceProvider() + { + var host = Host.CreateDefaultBuilder() + .ConfigureServices( ( _, services ) => + { + services.AddSingleton(); + services.AddKeyedSingleton( "TestKey", ( _, _ ) => new TestService( " and Universe" ) ); + } ) + .ConfigureAppConfiguration( ( _, config ) => + { + config.AddInMemoryCollection( new Dictionary + { + {"hello", "aliens"} + } ); + } ) + .Build(); + + return host.Services; + } +} diff --git a/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs b/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs index 64c0c34..a273d35 100644 --- a/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs +++ b/test/Hyperbee.Templating.Tests/Text/TemplateParser.ExpressionTests.cs @@ -1,16 +1,43 @@ -using Hyperbee.Templating.Text; +using System.Reflection; +using Hyperbee.Expressions; +using Hyperbee.Templating.Configure; +using Hyperbee.Templating.Provider.XS.Compiler; +using Hyperbee.Templating.Text; +using Hyperbee.Xs.Extensions; +using Hyperbee.XS.Core; using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Hyperbee.Templating.Provider.XS.Compiler.XsTokenExpressionProvider; namespace Hyperbee.Templating.Tests.Text; [TestClass] public class TemplateParserExpressionTests { + [TestMethod] + public void Should_honor_while_xs_condition() + { + // arrange + const string expression = "{{while x => x.counter < 3; }}{{counter}}{{counter:{{x => x.counter + 1;}}}}{{/while}}"; + const string template = $"count: {expression}."; + + // act + var options = new TemplateOptions() + .AddVariable( "counter", "0" ) + .SetTokenExpressionProvider( new XsTokenExpressionProvider() ); + + var result = Template.Render( template, options ); + + // assert + const string expected = "count: 012."; + + Assert.AreEqual( expected, result ); + } + [TestMethod] public void Should_honor_while_condition() { // arrange - const string expression = "{{while x => int.Parse(x.counter) < 3}}{{counter}}{{counter:{{x => int.Parse(x.counter) + 1}}}}{{/while}}"; + const string expression = "{{while x => x.counter < 3}}{{counter}}{{counter:{{x => x.counter + 1}}}}{{/while}}"; const string template = $"count: {expression}."; // act @@ -28,6 +55,78 @@ public void Should_honor_while_condition() Assert.AreEqual( expected, result ); } + [TestMethod] + public void Should_honor_block_xs_expression() + { + // arrange + const string expression = + """ + {{ x => { + switch( x.choice ){ + case "1": x.TheBest("me", "no") as string; + case "2": x.TheBest("you", "yes") as string; + default: "error"; + } + } }} + """; + + const string template = $"hello {expression}."; + + // act + var options = new TemplateOptions() + .AddVariable( "choice", "2" ) + .AddMethod( "TheBest" ).Expression( ( arg0, arg1 ) => + { + var result = $"{arg0} {(arg1 == "yes" ? "ARE" : "are NOT")} the best"; + return result; + } ) + .SetTokenExpressionProvider( new XsTokenExpressionProvider() ); + + var result = Template.Render( template, options ); + + // assert + + var expected = template.Replace( expression, "you ARE the best" ); + + Assert.AreEqual( expected, result ); + } + + [TestMethod] + public void Should_honor_xs_expression_extentions() + { + // arrange + const string expression = + """ + {{ _ => { + var service = inject::TestKey; + service.DoSomething(); + } }} also {{ _ => config::hello}} + """; + + const string template = $"hello {expression}."; + + var serviceProvider = TestSupport.ServiceProvider.GetServiceProvider(); + + // act + var options = new TemplateOptions() + .SetTokenExpressionProvider( new XsTokenExpressionProvider( + compile: lambda => lambda.Compile( serviceProvider ), + typeResolver: new MemberTypeResolver( ReferenceManager.Create( Assembly.GetExecutingAssembly() ) ), + extensions: + [ + new InjectParseExtension(), + new ConfigurationParseExtension() + ] + ) ); + + var result = Template.Render( template, options ); + + // assert + + var expected = template.Replace( expression, "World and Universe also aliens" ); + + Assert.AreEqual( expected, result ); + } [TestMethod] public void Should_honor_block_expression() @@ -64,6 +163,28 @@ public void Should_honor_block_expression() Assert.AreEqual( expected, result ); } + [TestMethod] + public void Should_honor_xs_inline_define() + { + // arrange + const string expression = "{{choice:me}}{{choice}}"; + + const string template = $"hello {expression}."; + + // act + + var options = new TemplateOptions() + .SetTokenExpressionProvider( new XsTokenExpressionProvider() ); + + var result = Template.Render( template, options ); + + // assert + + var expected = template.Replace( expression, "me" ); + + Assert.AreEqual( expected, result ); + } + [TestMethod] public void Should_honor_inline_define() { @@ -83,6 +204,43 @@ public void Should_honor_inline_define() Assert.AreEqual( expected, result ); } + [TestMethod] + public void Should_honor_xs_inline_block_expression() + { + // arrange + const string expression = "{{name}}"; + const string definition = + """ + {{name:{{ input => { + switch( input.choice ) + { + case 1: "me"; + case 2: "you"; + default: "default"; + }; + } }} }} + """; + + const string template = $"{definition}hello {expression}."; + + // act + var result = Template.Render( template, new() + { + Variables = + { + ["choice"] = "2" + }, + TokenExpressionProvider = new XsTokenExpressionProvider() + } ); + + // assert + var expected = template + .Replace( definition, "" ) + .Replace( expression, "you" ); + + Assert.AreEqual( expected, result ); + } + [TestMethod] public void Should_honor_inline_block_expression() { diff --git a/test/Hyperbee.Templating.Tests/Text/TemplateParser.LinkedDictionaryTests.cs b/test/Hyperbee.Templating.Tests/Text/TemplateParser.LinkedDictionaryTests.cs index 4540c00..2e8c650 100644 --- a/test/Hyperbee.Templating.Tests/Text/TemplateParser.LinkedDictionaryTests.cs +++ b/test/Hyperbee.Templating.Tests/Text/TemplateParser.LinkedDictionaryTests.cs @@ -33,11 +33,11 @@ public void Should_resolve_conditional_nested_tokens_with_custom_source() ["upper"] = "True" } ); - var config = new TemplateOptions( source ); + var options = new TemplateOptions( source ); // act - var result = Template.Render( template, config ); + var result = Template.Render( template, options ); // assert