diff --git a/docs/generators/index.md b/docs/generators/index.md index 34b421b8..67887582 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -52,6 +52,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Chain**](chain.md) | Chain-of-responsibility pipelines | `[Chain]` | | [**Command**](command.md) | Command objects and invokers | `[Command]` | | [**Composer**](composer.md) | Pipeline composition from ordered steps | `[Composer]` | +| [**Interpreter**](interpreter.md) | DSL rule factories for terminal and non-terminal expressions | `[GenerateInterpreter]` | | [**Iterator**](iterator.md) | Enumerable/async-enumerable iteration helpers | `[Iterator]` | | [**Memento**](memento.md) | Immutable snapshots with optional undo/redo history | `[Memento]` | | [**Observer**](observer.md) | Event hubs and observer dispatch | `[ObserverHub]` | @@ -134,6 +135,10 @@ public partial class EditorState { } [GenerateStrategy("Router", typeof(Request), StrategyKind.Action)] public partial class Router { } +// Interpreter - generated DSL rule factory +[GenerateInterpreter(typeof(PricingContext), typeof(decimal))] +public static partial class PricingRules { } + // Template Method - algorithm skeleton [Template] public abstract partial class DataProcessor { } diff --git a/docs/generators/interpreter.md b/docs/generators/interpreter.md new file mode 100644 index 00000000..c4532152 --- /dev/null +++ b/docs/generators/interpreter.md @@ -0,0 +1,93 @@ +# Interpreter Generator + +The Interpreter generator turns annotated rule methods into an immutable `Interpreter` factory. Use it when a DSL grammar is stable enough to describe in code and you want compile-time diagnostics, deterministic registration order, and a factory that can be imported through `IServiceCollection`. + +## Quick Start + +```csharp +using PatternKit.Generators.Interpreter; + +[GenerateInterpreter(typeof(PricingContext), typeof(decimal), FactoryMethodName = "Build")] +public static partial class PricingRules +{ + [InterpreterTerminal("number")] + private static decimal Number(string token) => decimal.Parse(token); + + [InterpreterTerminal("cart_total")] + private static decimal CartTotal(string token, PricingContext context) => context.CartTotal; + + [InterpreterNonTerminal("add")] + private static decimal Add(decimal[] args) => args[0] + args[1]; + + [InterpreterNonTerminal("round")] + private static decimal Round(decimal[] args) => Math.Round(args[0], 2); +} + +var interpreter = PricingRules.Build(); +``` + +The generated method composes the existing fluent runtime: + +```csharp +var builder = Interpreter.Create(); +builder.Terminal("number", static (token, context) => Number(token)); +builder.Terminal("cart_total", static (token, context) => CartTotal(token, context)); +builder.NonTerminal("add", static (args, context) => Add(args)); +builder.NonTerminal("round", static (args, context) => Round(args)); +return builder.Build(); +``` + +## Attribute Model + +| Attribute | Target | Purpose | +| --- | --- | --- | +| `[GenerateInterpreter(typeof(TContext), typeof(TResult))]` | partial class or struct | Generates an `Interpreter` factory. | +| `[InterpreterTerminal("name")]` | static method | Registers a terminal handler for token values. | +| `[InterpreterNonTerminal("name")]` | static method | Registers a non-terminal handler for child results. | + +## Valid Rule Signatures + +Terminal handlers return `TResult` and accept either: + +```csharp +static TResult Rule(string token) +static TResult Rule(string token, TContext context) +``` + +Non-terminal handlers return `TResult` and accept either: + +```csharp +static TResult Rule(TResult[] args) +static TResult Rule(TResult[] args, TContext context) +``` + +Rules can be private because the generated factory is emitted into the same partial type. + +## Diagnostics + +| Id | Meaning | +| --- | --- | +| `PKINT001` | The host type is marked with `[GenerateInterpreter]` but is not partial. | +| `PKINT002` | The host has no terminal or non-terminal rules. | +| `PKINT003` | A rule method is not static, returns the wrong type, or has an invalid parameter list. | +| `PKINT004` | A terminal or non-terminal rule name is registered more than once. | + +## IServiceCollection Integration + +Register the generated interpreter as a singleton. The interpreter is immutable after build and can be safely shared by request handlers, hosted services, ASP.NET Core endpoints, and background workers. + +```csharp +public static IServiceCollection AddPricingRules(this IServiceCollection services) +{ + services.AddSingleton(_ => PricingRules.Build()); + return services; +} +``` + +PatternKit's e-commerce Interpreter example exposes both fluent and generated pricing/eligibility interpreters and registers the generated path through `AddPatternKitExamples`. + +## See Also + +- [Interpreter Pattern](../patterns/behavioral/interpreter/index.md) +- [Interpreter Real-World Examples](../patterns/behavioral/interpreter/real-world-examples.md) +- [Pattern Coverage Guide](../guides/pattern-coverage.md) diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index c5bd06c2..7e35cba8 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -43,6 +43,9 @@ - name: Flyweight href: flyweight.md +- name: Interpreter + href: interpreter.md + - name: Iterator href: iterator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index a5271296..fecb81cc 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -30,7 +30,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Structural | Proxy | `Proxy` | Proxy generator | | Behavioral | Chain of Responsibility | `ActionChain` and `ResultChain` | Chain generator | | Behavioral | Command | `Command` | Command generator | -| Behavioral | Interpreter | `Interpreter` | Tracked in [#206](https://github.com/JerrettDavis/PatternKit/issues/206) | +| Behavioral | Interpreter | `Interpreter` | Interpreter generator | | Behavioral | Iterator | `Flow` and sequence helpers | Iterator generator | | Behavioral | Mediator | `Mediator` | Dispatcher generator | | Behavioral | Memento | `Memento` | Memento generator | diff --git a/docs/patterns/behavioral/interpreter/index.md b/docs/patterns/behavioral/interpreter/index.md index 105c9a11..1b33d87b 100644 --- a/docs/patterns/behavioral/interpreter/index.md +++ b/docs/patterns/behavioral/interpreter/index.md @@ -34,6 +34,8 @@ PatternKit's implementation uses a fluent builder API to define: The interpreter recursively evaluates the expression tree, producing a final result. +PatternKit also includes a source-generated path for stable grammars. Annotate a partial rule host with `[GenerateInterpreter]`, mark static methods with `[InterpreterTerminal]` and `[InterpreterNonTerminal]`, and the generator emits an immutable interpreter factory that composes the same runtime without reflection. + ## When to Use - **Domain-specific languages (DSLs)**: Building custom languages for configuration, rules, or queries @@ -58,6 +60,27 @@ The interpreter recursively evaluates the expression tree, producing a final res | `AsyncInterpreter` | Async interpreter returning a value | External lookups, I/O | | `AsyncActionInterpreter` | Async interpreter with side effects | Async commands | +## Generated Path + +```csharp +[GenerateInterpreter(typeof(PricingContext), typeof(decimal), FactoryMethodName = "Build")] +public static partial class PricingRules +{ + [InterpreterTerminal("number")] + private static decimal Number(string token) => decimal.Parse(token); + + [InterpreterTerminal("cart_total")] + private static decimal CartTotal(string token, PricingContext context) => context.CartTotal; + + [InterpreterNonTerminal("add")] + private static decimal Add(decimal[] args) => args[0] + args[1]; +} + +var interpreter = PricingRules.Build(); +``` + +Use the fluent builder when a grammar is assembled dynamically. Use the generated path when the grammar belongs to the application contract and should be validated at compile time. + ## Diagram ```mermaid @@ -97,5 +120,6 @@ classDiagram - [Comprehensive Guide](guide.md) - Detailed usage and patterns - [API Reference](api-reference.md) - Complete API documentation - [Real-World Examples](real-world-examples.md) - Production-ready examples +- [Interpreter Generator](../../../generators/interpreter.md) - Source-generated rule factories - [Strategy Pattern](../strategy/index.md) - For simple conditional logic - [Composite Pattern](../../structural/composite/index.md) - For tree structures without interpretation diff --git a/docs/patterns/behavioral/interpreter/real-world-examples.md b/docs/patterns/behavioral/interpreter/real-world-examples.md index c1632070..eaf1b901 100644 --- a/docs/patterns/behavioral/interpreter/real-world-examples.md +++ b/docs/patterns/behavioral/interpreter/real-world-examples.md @@ -99,6 +99,34 @@ var tierDiscount = interpreter.Interpret(tierDiscountRule, context); // 15.00 var thresholdDiscount = interpreter.Interpret(thresholdRule, context); // 5.00 ``` +### Source-Generated Variant + +For production applications with a stable pricing grammar, the same rule set can be declared with generator attributes and registered through dependency injection: + +```csharp +[GenerateInterpreter(typeof(PricingContext), typeof(decimal), FactoryMethodName = "Create")] +public static partial class GeneratedPricingRuleInterpreter +{ + [InterpreterTerminal("number")] + private static decimal Number(string token) => decimal.Parse(token); + + [InterpreterTerminal("var")] + private static decimal Variable(string token, PricingContext context) => + token == "cart_total" ? context.CartTotal : 0m; + + [InterpreterNonTerminal("mul")] + private static decimal Multiply(decimal[] args) => args[0] * args[1]; +} + +public static IServiceCollection AddPricingRules(this IServiceCollection services) +{ + services.AddSingleton(_ => GeneratedPricingRuleInterpreter.Create()); + return services; +} +``` + +The full `InterpreterDemo` includes generated pricing and eligibility interpreters that are validated by TinyBDD tests and imported through `AddPatternKitExamples`. + ### Why This Pattern - **Business agility**: Rules can be stored in a database and changed without deployment diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index ef4b57e9..e1c5aec2 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using PatternKit.Behavioral.Chain; +using PatternKit.Behavioral.Interpreter; using PatternKit.Behavioral.Strategy; using PatternKit.Behavioral.TypeDispatcher; using PatternKit.Creational.AbstractFactory; @@ -44,6 +45,7 @@ using TransactionPipeline = PatternKit.Examples.Chain.TransactionPipeline; using VisitorTender = PatternKit.Examples.VisitorDemo.Tender; using WidgetDemo = PatternKit.Examples.AbstractFactoryDemo.AbstractFactoryDemo; +using InterpreterRulesDemo = PatternKit.Examples.InterpreterDemo.InterpreterDemo; namespace PatternKit.Examples.DependencyInjection; @@ -98,6 +100,7 @@ public sealed record GeneratedMailboxExample(MailboxExampleRunner Runner); public sealed record GeneratedReliabilityPipelineExample(ReliabilityExampleRunner Runner); public sealed record ResilientCheckoutMailboxesExample(Func Run); public sealed record MessagingBackplaneFacadeExample(Func> RunAsync); +public sealed record GeneratedInterpreterRulesExample(Interpreter Pricing, Interpreter Eligibility); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -143,6 +146,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddGeneratedReliabilityPipelineExample() .AddResilientCheckoutMailboxesExample() .AddMessagingBackplaneFacadeExample() + .AddGeneratedInterpreterRulesExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -423,6 +427,16 @@ public static IServiceCollection AddMessagingBackplaneFacadeExample(this IServic return services.RegisterExample("Messaging Backplane Facade", ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.ExternalInfrastructure | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddGeneratedInterpreterRulesExample(this IServiceCollection services) + { + services.AddSingleton(static _ => InterpreterRulesDemo.CreateGeneratedPricingInterpreter()); + services.AddSingleton(static _ => InterpreterRulesDemo.CreateGeneratedEligibilityInterpreter()); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService>())); + return services.RegisterExample("Generated Interpreter Rules", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) { services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); diff --git a/src/PatternKit.Examples/InterpreterDemo/InterpreterDemo.cs b/src/PatternKit.Examples/InterpreterDemo/InterpreterDemo.cs index 4a8d7ec2..c630b8b3 100644 --- a/src/PatternKit.Examples/InterpreterDemo/InterpreterDemo.cs +++ b/src/PatternKit.Examples/InterpreterDemo/InterpreterDemo.cs @@ -1,4 +1,5 @@ using PatternKit.Behavioral.Interpreter; +using PatternKit.Generators.Interpreter; using static PatternKit.Behavioral.Interpreter.ExpressionExtensions; namespace PatternKit.Examples.InterpreterDemo; @@ -120,6 +121,12 @@ public static Interpreter CreatePricingInterpreter() .Build(); } + /// + /// Creates the source-generated pricing interpreter used by importable examples and IoC integrations. + /// + public static Interpreter CreateGeneratedPricingInterpreter() + => GeneratedPricingRuleInterpreter.Create(); + /// /// Creates an async interpreter for rules that need external lookups. /// @@ -194,6 +201,12 @@ public static Interpreter CreateEligibilityInterpreter() .Build(); } + /// + /// Creates the source-generated eligibility interpreter used by importable examples and IoC integrations. + /// + public static Interpreter CreateGeneratedEligibilityInterpreter() + => GeneratedEligibilityRuleInterpreter.Create(); + // ───────────────────────────────────────────────────────────────────────── // Sample Rule Expressions // ───────────────────────────────────────────────────────────────────────── @@ -374,3 +387,103 @@ public static async Task RunAsync() public static void Run() => RunAsync().GetAwaiter().GetResult(); } + +[GenerateInterpreter(typeof(InterpreterDemo.PricingContext), typeof(decimal), FactoryMethodName = "Create")] +public static partial class GeneratedPricingRuleInterpreter +{ + [InterpreterTerminal("number")] + private static decimal Number(string token) => decimal.Parse(token); + + [InterpreterTerminal("percent")] + private static decimal Percent(string token) => decimal.Parse(token.TrimEnd('%')) / 100m; + + [InterpreterTerminal("var")] + private static decimal Variable(string token, InterpreterDemo.PricingContext context) + => token switch + { + "cart_total" => context.CartTotal, + "item_count" => context.ItemCount, + "tier_discount" => context.CustomerTier switch + { + "Gold" => 0.10m, + "Platinum" => 0.15m, + "Diamond" => 0.20m, + _ => 0.0m + }, + _ => context.Variables.TryGetValue(token, out var value) ? value : 0m + }; + + [InterpreterNonTerminal("add")] + private static decimal Add(decimal[] args) => args[0] + args[1]; + + [InterpreterNonTerminal("div")] + private static decimal Divide(decimal[] args) => args[1] != 0 ? args[0] / args[1] : 0m; + + [InterpreterNonTerminal("eq")] + private static decimal Equal(decimal[] args) => args[0] == args[1] ? 1m : 0m; + + [InterpreterNonTerminal("gt")] + private static decimal GreaterThan(decimal[] args) => args[0] > args[1] ? 1m : 0m; + + [InterpreterNonTerminal("if")] + private static decimal If(decimal[] args) + { + if (args.Length != 3) + throw new InvalidOperationException("if requires 3 arguments: condition, then, else"); + return args[0] > 0 ? args[1] : args[2]; + } + + [InterpreterNonTerminal("lt")] + private static decimal LessThan(decimal[] args) => args[0] < args[1] ? 1m : 0m; + + [InterpreterNonTerminal("max")] + private static decimal Max(decimal[] args) => Math.Max(args[0], args[1]); + + [InterpreterNonTerminal("min")] + private static decimal Min(decimal[] args) => Math.Min(args[0], args[1]); + + [InterpreterNonTerminal("mul")] + private static decimal Multiply(decimal[] args) => args[0] * args[1]; + + [InterpreterNonTerminal("round")] + private static decimal Round(decimal[] args) => Math.Round(args[0], 2, MidpointRounding.AwayFromZero); + + [InterpreterNonTerminal("sub")] + private static decimal Subtract(decimal[] args) => args[0] - args[1]; +} + +[GenerateInterpreter(typeof(InterpreterDemo.PricingContext), typeof(bool), FactoryMethodName = "Create")] +public static partial class GeneratedEligibilityRuleInterpreter +{ + [InterpreterTerminal("bool")] + private static bool Boolean(string token) => bool.Parse(token); + + [InterpreterTerminal("cartOver")] + private static bool CartOver(string token, InterpreterDemo.PricingContext context) + => context.CartTotal > decimal.Parse(token); + + [InterpreterTerminal("isHoliday")] + private static bool IsHoliday(string token, InterpreterDemo.PricingContext context) + => context.IsHoliday; + + [InterpreterTerminal("itemsOver")] + private static bool ItemsOver(string token, InterpreterDemo.PricingContext context) + => context.ItemCount > int.Parse(token); + + [InterpreterTerminal("promo")] + private static bool Promo(string token, InterpreterDemo.PricingContext context) + => context.PromoCode.Equals(token, StringComparison.OrdinalIgnoreCase); + + [InterpreterTerminal("tier")] + private static bool Tier(string token, InterpreterDemo.PricingContext context) + => context.CustomerTier.Equals(token, StringComparison.OrdinalIgnoreCase); + + [InterpreterNonTerminal("and")] + private static bool And(bool[] args) => args[0] && args[1]; + + [InterpreterNonTerminal("not")] + private static bool Not(bool[] args) => !args[0]; + + [InterpreterNonTerminal("or")] + private static bool Or(bool[] args) => args[0] || args[1]; +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 26486f8e..e4d509ec 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -250,14 +250,14 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/patterns/behavioral/interpreter/index.md", "src/PatternKit.Core/Behavioral/Interpreter/Interpreter.cs", "test/PatternKit.Tests/Behavioral/InterpreterTests.cs", + "docs/generators/interpreter.md", + "src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs", + "test/PatternKit.Generators.Tests/InterpreterGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/206", "docs/patterns/behavioral/interpreter/real-world-examples.md", "src/PatternKit.Examples/InterpreterDemo/InterpreterDemo.cs", "test/PatternKit.Examples.Tests/InterpreterDemo/InterpreterDemoTests.cs", - ["fluent interpreter", "dedicated generator tracked", "rules engine example"]), + ["fluent interpreter", "generated interpreter", "rules engine example importable through AddPatternKitExamples"]), Pattern("Iterator", PatternFamily.Behavioral, "docs/patterns/behavioral/iterator/index.md", diff --git a/src/PatternKit.Generators.Abstractions/Interpreter/InterpreterAttributes.cs b/src/PatternKit.Generators.Abstractions/Interpreter/InterpreterAttributes.cs new file mode 100644 index 00000000..32c5fe2e --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Interpreter/InterpreterAttributes.cs @@ -0,0 +1,25 @@ +namespace PatternKit.Generators.Interpreter; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateInterpreterAttribute(Type contextType, Type resultType) : Attribute +{ + public Type ContextType { get; } = contextType ?? throw new ArgumentNullException(nameof(contextType)); + public Type ResultType { get; } = resultType ?? throw new ArgumentNullException(nameof(resultType)); + public string FactoryMethodName { get; set; } = "Create"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class InterpreterTerminalAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Terminal name is required.", nameof(name)) + : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class InterpreterNonTerminalAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Non-terminal name is required.", nameof(name)) + : name; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 074b561f..9257d7d9 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -1,4 +1,4 @@ -; Unshipped analyzer release +; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md ### New Rules @@ -193,6 +193,10 @@ PKAF001 | PatternKit.Generators.Factories | Error | Abstract factory host must b PKAF002 | PatternKit.Generators.Factories | Error | Abstract factory must declare at least one product. PKAF003 | PatternKit.Generators.Factories | Error | Abstract factory product declaration is invalid. PKAF004 | PatternKit.Generators.Factories | Error | Abstract factory product declaration is duplicated. +PKINT001 | PatternKit.Generators.Interpreter | Error | Interpreter host must be partial. +PKINT002 | PatternKit.Generators.Interpreter | Error | Interpreter must declare at least one rule. +PKINT003 | PatternKit.Generators.Interpreter | Error | Interpreter rule signature is invalid. +PKINT004 | PatternKit.Generators.Interpreter | Error | Interpreter rule declaration is duplicated. PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be partial. PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. diff --git a/src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs b/src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs new file mode 100644 index 00000000..626bde7e --- /dev/null +++ b/src/PatternKit.Generators/Interpreter/InterpreterGenerator.cs @@ -0,0 +1,287 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Interpreter; + +[Generator] +public sealed class InterpreterGenerator : IIncrementalGenerator +{ + private const string GenerateInterpreterAttributeName = "PatternKit.Generators.Interpreter.GenerateInterpreterAttribute"; + private const string InterpreterTerminalAttributeName = "PatternKit.Generators.Interpreter.InterpreterTerminalAttribute"; + private const string InterpreterNonTerminalAttributeName = "PatternKit.Generators.Interpreter.InterpreterNonTerminalAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKINT001", + "Interpreter host must be partial", + "Type '{0}' is marked with [GenerateInterpreter] but is not declared as partial", + "PatternKit.Generators.Interpreter", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingRules = new( + "PKINT002", + "Interpreter has no rules", + "Type '{0}' is marked with [GenerateInterpreter] but does not declare any terminal or non-terminal rules", + "PatternKit.Generators.Interpreter", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidRule = new( + "PKINT003", + "Interpreter rule signature is invalid", + "Rule method '{0}' must be static and return the configured result type with either a terminal signature (string token[, context]) or non-terminal signature (result[] args[, context])", + "PatternKit.Generators.Interpreter", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateRule = new( + "PKINT004", + "Interpreter rule is duplicated", + "Interpreter rule '{0}' is registered more than once as a {1}", + "PatternKit.Generators.Interpreter", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateInterpreterAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => + a.AttributeClass?.ToDisplayString() == GenerateInterpreterAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var contextType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + var resultType = attribute.ConstructorArguments.Length >= 2 + ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol + : null; + if (contextType is null || resultType is null) + return; + + var rules = GetRules(type, contextType, resultType, context, out var hasAnnotatedRules); + if (!hasAnnotatedRules) + { + context.ReportDiagnostic(Diagnostic.Create(MissingRules, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (rules.Length == 0) + return; + + if (TryFindDuplicate(rules, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRule, duplicate.Location, duplicate.Name, duplicate.KindText)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + context.AddSource($"{type.Name}.Interpreter.g.cs", SourceText.From( + GenerateSource(type, contextType, resultType, rules, factoryMethodName), + Encoding.UTF8)); + } + + private static ImmutableArray GetRules( + INamedTypeSymbol type, + INamedTypeSymbol contextType, + INamedTypeSymbol resultType, + SourceProductionContext context, + out bool hasAnnotatedRules) + { + hasAnnotatedRules = false; + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + foreach (var attr in method.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString(); + if (attrName != InterpreterTerminalAttributeName && attrName != InterpreterNonTerminalAttributeName) + continue; + + hasAnnotatedRules = true; + var isTerminal = attrName == InterpreterTerminalAttributeName; + if (!TryGetRule(method, attr, isTerminal, contextType, resultType, out var rule)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidRule, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + builder.Add(rule); + } + } + + return builder.ToImmutable(); + } + + private static bool TryGetRule( + IMethodSymbol method, + AttributeData attribute, + bool isTerminal, + INamedTypeSymbol contextType, + INamedTypeSymbol resultType, + out Rule rule) + { + rule = default; + var name = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as string + : null; + if (string.IsNullOrWhiteSpace(name)) + return false; + + if (!method.IsStatic || method.IsGenericMethod || method.ReturnsVoid) + return false; + + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, resultType)) + return false; + + var parameters = method.Parameters; + if (parameters.Length is < 1 or > 2) + return false; + + var firstParameterValid = isTerminal + ? parameters[0].Type.SpecialType == SpecialType.System_String + : parameters[0].Type is IArrayTypeSymbol arrayType && SymbolEqualityComparer.Default.Equals(arrayType.ElementType, resultType); + if (!firstParameterValid) + return false; + + if (parameters.Length == 2 && !SymbolEqualityComparer.Default.Equals(parameters[1].Type, contextType)) + return false; + + rule = new Rule( + name!, + isTerminal, + method.Name, + parameters.Length == 2, + method.Locations.FirstOrDefault()); + return true; + } + + private static bool TryFindDuplicate(IReadOnlyList rules, out Rule duplicate) + { + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var rule in rules) + { + var key = rule.KindText + ":" + rule.Name; + if (!seen.Add(key)) + { + duplicate = rule; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol contextType, + INamedTypeSymbol resultType, + IReadOnlyList rules, + string factoryMethodName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Behavioral.Interpreter.Interpreter<") + .Append(contextType.ToDisplayString(TypeFormat)) + .Append(", ") + .Append(resultType.ToDisplayString(TypeFormat)) + .Append("> ") + .Append(factoryMethodName) + .AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Behavioral.Interpreter.Interpreter.Create<") + .Append(contextType.ToDisplayString(TypeFormat)) + .Append(", ") + .Append(resultType.ToDisplayString(TypeFormat)) + .AppendLine(">();"); + + foreach (var rule in rules.Where(static rule => rule.IsTerminal).OrderBy(static rule => rule.Name, System.StringComparer.Ordinal)) + EmitRule(sb, rule, "Terminal", "token"); + + foreach (var rule in rules.Where(static rule => !rule.IsTerminal).OrderBy(static rule => rule.Name, System.StringComparer.Ordinal)) + EmitRule(sb, rule, "NonTerminal", "args"); + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void EmitRule(StringBuilder sb, Rule rule, string builderMethodName, string firstArgumentName) + { + sb.Append(" builder.") + .Append(builderMethodName) + .Append("(\"") + .Append(Escape(rule.Name)) + .Append("\", static (") + .Append(firstArgumentName) + .Append(", context) => ") + .Append(rule.MethodName) + .Append('(') + .Append(firstArgumentName); + + if (rule.UsesContext) + sb.Append(", context"); + + sb.AppendLine("));"); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private readonly record struct Rule( + string Name, + bool IsTerminal, + string MethodName, + bool UsesContext, + Location? Location) + { + public string KindText => IsTerminal ? "terminal" : "non-terminal"; + } +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 46ff37bc..2121cf67 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -86,6 +86,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var envelope = provider.GetRequiredService(); var cqrs = provider.GetRequiredService(); var checkout = provider.GetRequiredService(); + var interpreter = provider.GetRequiredService(); auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary())); @@ -143,7 +144,9 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("message envelope example tracks first attempt", envelope.Run().Attempt == 1), ("CQRS fluent path matches command writes to query reads", cqrsFluent.QueryMatchedCommand), ("CQRS generated path matches command writes to query reads", cqrsGenerated.QueryMatchedCommand), - ("resilient checkout succeeds", checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded) + ("resilient checkout succeeds", checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded), + ("generated interpreter computes tier discounts", interpreter.Pricing.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.TierDiscountRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 100m, CustomerTier = "Gold" }) == 10m), + ("generated interpreter evaluates VIP eligibility", interpreter.Eligibility.Interpret(PatternKit.Examples.InterpreterDemo.InterpreterDemo.VipEligibilityRule, new PatternKit.Examples.InterpreterDemo.InterpreterDemo.PricingContext { CartTotal = 150m, CustomerTier = "Gold" })) ]; } diff --git a/test/PatternKit.Examples.Tests/InterpreterDemo/InterpreterDemoTests.cs b/test/PatternKit.Examples.Tests/InterpreterDemo/InterpreterDemoTests.cs index 899c6d57..b242b4d8 100644 --- a/test/PatternKit.Examples.Tests/InterpreterDemo/InterpreterDemoTests.cs +++ b/test/PatternKit.Examples.Tests/InterpreterDemo/InterpreterDemoTests.cs @@ -1,5 +1,6 @@ using PatternKit.Examples.InterpreterDemo; using static PatternKit.Examples.InterpreterDemo.InterpreterDemo; +using Expr = PatternKit.Behavioral.Interpreter.ExpressionExtensions; using TinyBDD; namespace PatternKit.Examples.Tests.InterpreterDemoTests; @@ -130,6 +131,59 @@ public void TierDiscountRule_Gold_Customer() ScenarioExpect.Equal(15m, result); // 150 * 0.10 = 15 } + [Scenario("CreateGeneratedPricingInterpreter Matches Fluent Pricing Rules")] + [Fact] + public void CreateGeneratedPricingInterpreter_Matches_Fluent_Pricing_Rules() + { + var fluent = CreatePricingInterpreter(); + var generated = CreateGeneratedPricingInterpreter(); + var ctx = new PricingContext { CartTotal = 150m, CustomerTier = "Platinum" }; + + var fluentResult = fluent.Interpret(TierDiscountRule, ctx); + var generatedResult = generated.Interpret(TierDiscountRule, ctx); + + ScenarioExpect.Equal(fluentResult, generatedResult); + ScenarioExpect.True(generated.HasTerminal("percent")); + ScenarioExpect.True(generated.HasNonTerminal("round")); + } + + [Scenario("CreateGeneratedPricingInterpreter Covers Production Pricing Rules")] + [Fact] + public void CreateGeneratedPricingInterpreter_Covers_Production_Pricing_Rules() + { + var interpreter = CreateGeneratedPricingInterpreter(); + var ctx = new PricingContext + { + CartTotal = 120m, + ItemCount = 4, + CustomerTier = "Diamond", + Variables = { ["manual_credit"] = 8m } + }; + var arithmeticRule = Expr.NonTerminal("sub", + Expr.NonTerminal("add", Expr.Terminal("number", "10"), Expr.Terminal("var", "manual_credit")), + Expr.NonTerminal("div", Expr.Terminal("number", "9"), Expr.Terminal("number", "3"))); + var fallbackRule = Expr.NonTerminal("add", + Expr.Terminal("var", "missing"), + Expr.Terminal("var", "item_count")); + var comparisonRule = Expr.NonTerminal("add", + Expr.NonTerminal("eq", Expr.Terminal("number", "2"), Expr.Terminal("number", "2")), + Expr.NonTerminal("lt", Expr.Terminal("number", "1"), Expr.Terminal("number", "2"))); + + ScenarioExpect.Equal(24m, interpreter.Interpret(TierDiscountRule, ctx)); + ScenarioExpect.Equal(15m, interpreter.Interpret(arithmeticRule, ctx)); + ScenarioExpect.Equal(4m, interpreter.Interpret(fallbackRule, ctx)); + ScenarioExpect.Equal(2m, interpreter.Interpret(comparisonRule, ctx)); + ScenarioExpect.Equal(0m, interpreter.Interpret( + Expr.NonTerminal("div", Expr.Terminal("number", "9"), Expr.Terminal("number", "0")), + ctx)); + ScenarioExpect.Equal(3m, interpreter.Interpret( + Expr.NonTerminal("if", Expr.Terminal("number", "0"), Expr.Terminal("number", "7"), Expr.Terminal("number", "3")), + ctx)); + ScenarioExpect.Throws(() => interpreter.Interpret( + Expr.NonTerminal("if", Expr.Terminal("number", "1")), + ctx)); + } + [Scenario("ThresholdDiscountRule Below Threshold")] [Fact] public void ThresholdDiscountRule_Below_Threshold() @@ -230,6 +284,45 @@ public void VipEligibilityRule_Gold_High_Cart() ScenarioExpect.True(result); } + [Scenario("CreateGeneratedEligibilityInterpreter Matches Fluent Eligibility Rules")] + [Fact] + public void CreateGeneratedEligibilityInterpreter_Matches_Fluent_Eligibility_Rules() + { + var fluent = CreateEligibilityInterpreter(); + var generated = CreateGeneratedEligibilityInterpreter(); + var ctx = new PricingContext { CustomerTier = "Gold", CartTotal = 150m }; + + var fluentResult = fluent.Interpret(VipEligibilityRule, ctx); + var generatedResult = generated.Interpret(VipEligibilityRule, ctx); + + ScenarioExpect.Equal(fluentResult, generatedResult); + ScenarioExpect.True(generated.HasTerminal("cartOver")); + ScenarioExpect.True(generated.HasNonTerminal("and")); + } + + [Scenario("CreateGeneratedEligibilityInterpreter Covers Production Eligibility Rules")] + [Fact] + public void CreateGeneratedEligibilityInterpreter_Covers_Production_Eligibility_Rules() + { + var interpreter = CreateGeneratedEligibilityInterpreter(); + var ctx = new PricingContext + { + CartTotal = 120m, + ItemCount = 6, + CustomerTier = "Gold", + PromoCode = "SPRING", + IsHoliday = true + }; + var eligibilityRule = Expr.NonTerminal("and", + Expr.NonTerminal("or", Expr.Terminal("promo", "SPRING"), Expr.Terminal("bool", "false")), + Expr.NonTerminal("and", Expr.Terminal("isHoliday", ""), Expr.Terminal("itemsOver", "5"))); + + ScenarioExpect.True(interpreter.Interpret(eligibilityRule, ctx)); + ScenarioExpect.False(interpreter.Interpret(Expr.NonTerminal("not", Expr.Terminal("tier", "Gold")), ctx)); + ScenarioExpect.False(interpreter.Interpret(Expr.Terminal("itemsOver", "10"), ctx)); + ScenarioExpect.False(interpreter.Interpret(Expr.Terminal("promo", "WINTER"), ctx)); + } + [Scenario("VipEligibilityRule Standard High Cart")] [Fact] public void VipEligibilityRule_Standard_High_Cart() diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index c8c4b64d..4680611a 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -118,10 +118,7 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() .OrderBy(static issue => issue) .ToArray(); - ScenarioExpect.Equal( - [ - "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206" - ], tracked); + ScenarioExpect.Empty(tracked); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 2b318a14..82409ccf 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -8,6 +8,7 @@ using PatternKit.Generators.Facade; using PatternKit.Generators.Flyweight; using PatternKit.Generators.Factories; +using PatternKit.Generators.Interpreter; using PatternKit.Generators.Iterator; using PatternKit.Generators.Messaging; using PatternKit.Generators.Observer; @@ -72,6 +73,9 @@ private enum TestTrigger { typeof(FactoryDefaultAttribute), AttributeTargets.Method, false, false }, { typeof(FactoryClassAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, { typeof(FactoryClassKeyAttribute), AttributeTargets.Class, false, false }, + { typeof(GenerateInterpreterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(InterpreterTerminalAttribute), AttributeTargets.Method, true, false }, + { typeof(InterpreterNonTerminalAttribute), AttributeTargets.Method, true, false }, { typeof(IteratorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(IteratorStepAttribute), AttributeTargets.Method, false, false }, { typeof(TraversalIteratorAttribute), AttributeTargets.Class, false, false }, @@ -144,6 +148,28 @@ public void AttributeUsage_Is_Declared_As_Expected( ScenarioExpect.Equal(inherited, usage.Inherited); } + [Scenario("Interpreter Attributes Expose Defaults And Validation")] + [Fact] + public void Interpreter_Attributes_Expose_Defaults_And_Validation() + { + var generator = new GenerateInterpreterAttribute(typeof(string), typeof(decimal)) + { + FactoryMethodName = "BuildRules" + }; + var terminal = new InterpreterTerminalAttribute("number"); + var nonTerminal = new InterpreterNonTerminalAttribute("add"); + + ScenarioExpect.Equal(typeof(string), generator.ContextType); + ScenarioExpect.Equal(typeof(decimal), generator.ResultType); + ScenarioExpect.Equal("BuildRules", generator.FactoryMethodName); + ScenarioExpect.Equal("number", terminal.Name); + ScenarioExpect.Equal("add", nonTerminal.Name); + ScenarioExpect.Throws(() => new GenerateInterpreterAttribute(null!, typeof(decimal))); + ScenarioExpect.Throws(() => new GenerateInterpreterAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new InterpreterTerminalAttribute("")); + ScenarioExpect.Throws(() => new InterpreterNonTerminalAttribute(" ")); + } + [Scenario("Adapter Attributes Expose Defaults And Configuration")] [Fact] public void Adapter_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/InterpreterGeneratorTests.cs b/test/PatternKit.Generators.Tests/InterpreterGeneratorTests.cs new file mode 100644 index 00000000..f317be5d --- /dev/null +++ b/test/PatternKit.Generators.Tests/InterpreterGeneratorTests.cs @@ -0,0 +1,222 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Interpreter; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class InterpreterGeneratorTests +{ + [Scenario("Generates interpreter factory from terminal and non-terminal rules")] + [Fact] + public void GeneratesInterpreterFactoryFromTerminalAndNonTerminalRules() + { + var source = """ + using PatternKit.Generators.Interpreter; + + namespace Demo; + + public sealed class PricingContext + { + public decimal CartTotal { get; set; } + } + + [GenerateInterpreter(typeof(PricingContext), typeof(decimal), FactoryMethodName = "BuildPricingRules")] + public static partial class PricingRules + { + [InterpreterTerminal("number")] + private static decimal Number(string token) => decimal.Parse(token); + + [InterpreterTerminal("cart")] + private static decimal Cart(string token, PricingContext context) => context.CartTotal; + + [InterpreterNonTerminal("add")] + private static decimal Add(decimal[] args) => args[0] + args[1]; + + [InterpreterNonTerminal("mul")] + private static decimal Multiply(decimal[] args, PricingContext context) => args[0] * args[1]; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesInterpreterFactoryFromTerminalAndNonTerminalRules)); + var gen = new InterpreterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("PricingRules.Interpreter.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("BuildPricingRules()", text); + ScenarioExpect.Contains("builder.Terminal(\"cart\", static (token, context) => Cart(token, context));", text); + ScenarioExpect.Contains("builder.Terminal(\"number\", static (token, context) => Number(token));", text); + ScenarioExpect.Contains("builder.NonTerminal(\"add\", static (args, context) => Add(args));", text); + ScenarioExpect.Contains("builder.NonTerminal(\"mul\", static (args, context) => Multiply(args, context));", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial interpreter host")] + [Fact] + public void ReportsDiagnosticForNonPartialInterpreterHost() + { + var source = """ + using PatternKit.Generators.Interpreter; + + namespace Demo; + + [GenerateInterpreter(typeof(object), typeof(decimal))] + public static class PricingRules; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForNonPartialInterpreterHost)); + + ScenarioExpect.Equal("PKINT001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for interpreter without rules")] + [Fact] + public void ReportsDiagnosticForInterpreterWithoutRules() + { + var source = """ + using PatternKit.Generators.Interpreter; + + namespace Demo; + + [GenerateInterpreter(typeof(object), typeof(decimal))] + public static partial class PricingRules; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInterpreterWithoutRules)); + + ScenarioExpect.Equal("PKINT002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid interpreter rule signature")] + [Fact] + public void ReportsDiagnosticForInvalidInterpreterRuleSignature() + { + var source = """ + using PatternKit.Generators.Interpreter; + + namespace Demo; + + [GenerateInterpreter(typeof(object), typeof(decimal))] + public static partial class PricingRules + { + [InterpreterTerminal("number")] + private static string Number(string token) => token; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidInterpreterRuleSignature)); + + ScenarioExpect.Equal("PKINT003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for duplicate interpreter rules")] + [Fact] + public void ReportsDiagnosticForDuplicateInterpreterRules() + { + var source = """ + using PatternKit.Generators.Interpreter; + + namespace Demo; + + [GenerateInterpreter(typeof(object), typeof(decimal))] + public static partial class PricingRules + { + [InterpreterTerminal("number")] + private static decimal Number(string token) => 1m; + + [InterpreterTerminal("number")] + private static decimal AlsoNumber(string token) => 2m; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForDuplicateInterpreterRules)); + + ScenarioExpect.Equal("PKINT004", diagnostic.Id); + } + + [Scenario("Generates interpreter for global struct host")] + [Fact] + public void GeneratesInterpreterForGlobalStructHost() + { + var source = """ + using PatternKit.Generators.Interpreter; + + [GenerateInterpreter(typeof(object), typeof(int))] + public partial struct RuleHost + { + [InterpreterTerminal("number")] + private static int Number(string token) => int.Parse(token); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesInterpreterForGlobalStructHost)); + var gen = new InterpreterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("partial struct RuleHost", text); + ScenarioExpect.DoesNotContain("namespace ", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Escapes generated rule names")] + [Fact] + public void EscapesGeneratedRuleNames() + { + var source = """ + using PatternKit.Generators.Interpreter; + + namespace Demo; + + [GenerateInterpreter(typeof(object), typeof(string))] + public static partial class RuleHost + { + [InterpreterTerminal("quote\"rule")] + private static string Quote(string token) => token; + } + """; + + var comp = CreateCompilation(source, nameof(EscapesGeneratedRuleNames)); + var gen = new InterpreterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Contains("quote\\\"rule", generated.SourceText.ToString()); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(PatternKit.Behavioral.Interpreter.Interpreter<,>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(InterpreterGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new InterpreterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + } +}