diff --git a/Justfile b/Justfile index 3272540..4acff0d 100644 --- a/Justfile +++ b/Justfile @@ -5,7 +5,7 @@ solution := root_folder / "SourceGeneratorFramework.slnx" build_configuration := "Release" artifacts_folder := "./artifacts" default_test_filter := "/*/*/*/*/" -pipeline_version := "0.2.1" + pipeline_feed := "https://api.nuget.org/v3/index.json" pipeline_tool := ".tools/purview-build/purview-build" @@ -19,7 +19,7 @@ default: [private] ensure-pipeline-tool: if [ ! -x "{{ pipeline_tool }}" ]; then \ - dotnet tool install Purview.Build --tool-path .tools/purview-build --add-source "{{ pipeline_feed }}" --version "{{ pipeline_version }}"; \ + dotnet tool install Purview.Build --tool-path .tools/purview-build --add-source "{{ pipeline_feed }}"; \ fi # Run the PR pipeline (restore, build, lint, tests) @@ -46,7 +46,7 @@ pipeline-release *args: # Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) # Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. # Use the LOCAL_NUGET_FEED_PATH environment variable or forward slashes, e.g. -# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ [group('Pipeline')] pipeline-local-release *args: just ensure-pipeline-tool diff --git a/docs/code-writer.md b/docs/code-writer.md index 922054c..a3aea23 100644 --- a/docs/code-writer.md +++ b/docs/code-writer.md @@ -108,6 +108,34 @@ writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeR // factory.Create(x); ``` +A **chained** invocation — where the result of each call is the receiver of the next, and a postfix is +applied to the final result — is expressed with `MethodCallChain`/`AwaitedMethodCallChain`. The chain +is written as an expression (no terminating semicolon), so it composes as the value of an +`Assignment`/`Return` expression callback: + +```csharp +writer.Assignment( + "var hostKitOptions", + expression => expression.MethodCallChain( + "builder.Configuration.GetSection", + [$"{name}.SectionName"], + chain => chain.Method("Get", genericArguments: [optionsType]).Postfix(" ?? new()"))); +// var hostKitOptions = builder.Configuration.GetSection("x.SectionName").Get() ?? new(); +``` + +- `rootMethod` may include the receiver (e.g. `builder.Configuration.GetSection`); each subsequent + `.Method(...)` call implicitly uses the previous result as its receiver. +- `genericArguments` provides the `<...>` type arguments for a segment. +- `Postfix(expression)` appends a trailing expression such as `?? new()` or `!`. + +A null-conditional receiver — `onBuilt?.Invoke(this, builder);` — is written with the `nullConditional` +argument on the structured `MethodCallOn`/`AwaitedMethodCallOn` overloads: + +```csharp +writer.MethodCallOn("onBuilt", "Invoke", ["this", "builder"], nullConditional: true); +// onBuilt?.Invoke(this, builder); +``` + ### Conditional statements `IfBlock`/`IfBlockScope` write an `if` block. `ElseIf`/`ElseIfScope` chain an `else if` block after an diff --git a/package.json b/package.json index 94b15df..107268b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "purview-sourcegeneratorframework", - "version": "1.0.0-prerelease.34", + "version": "1.0.0-prerelease.35", "private": true } diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs index eb184c0..2dbcc01 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs @@ -133,6 +133,36 @@ options with .ElseIf("value == 0", branch => branch.Return("\"zero\"")) .Else(branch => branch.Return("\"positive\"")) ); + + body.Method( + "Configure", + TypeIdentity.Create().AsTypeReference(), + TypeDeclarationAccessibility.Public, + options => + options with + { + IsStatic = true, + Parameters = + [ + new("source", TypeIdentity.Create().AsTypeReference()), + new("onBuilt", PurviewTypeLibrary.System.Action.AsTypeReference()), + ], + }, + methodBody => + { + methodBody.Assignment( + "var hostKitOptions", + expression => + expression.MethodCallChain( + "source.Trim", + [], + chain => chain.Method("ToUpper").Postfix(" ?? string.Empty") + ) + ); + methodBody.MethodCallOn("onBuilt", "Invoke", [], nullConditional: true); + methodBody.Return("hostKitOptions"); + } + ); } ); diff --git a/src/src/SourceGeneratorShared/CodeWriter.cs b/src/src/SourceGeneratorShared/CodeWriter.cs index 2eca3af..4e071cd 100644 --- a/src/src/SourceGeneratorShared/CodeWriter.cs +++ b/src/src/SourceGeneratorShared/CodeWriter.cs @@ -2128,7 +2128,7 @@ public CodeWriter MultiLineParameters(params string[] parameters) /// The current writer. /// writer.MethodCall("Run", "value", "cancellationToken"); // Run(value, cancellationToken); public CodeWriter MethodCall(string methodName, params string[] arguments) => - MethodCallCore(methodName, arguments, receiver: null, genericArguments: null, false, false); + MethodCallCore(methodName, arguments, receiver: null, genericArguments: null, false, false, false); /// /// Writes an awaited method invocation statement. @@ -2138,7 +2138,7 @@ public CodeWriter MethodCall(string methodName, params string[] arguments) => /// The current writer. /// writer.AwaitedMethodCall("LoadAsync", "cancellationToken"); // await LoadAsync(cancellationToken); public CodeWriter AwaitedMethodCall(string methodName, params string[] arguments) => - MethodCallCore(methodName, arguments, receiver: null, genericArguments: null, false, true); + MethodCallCore(methodName, arguments, receiver: null, genericArguments: null, false, true, false); /// /// Writes a method invocation on a receiver, such as variable.Method(arg). @@ -2149,7 +2149,35 @@ public CodeWriter AwaitedMethodCall(string methodName, params string[] arguments /// The current writer. /// writer.MethodCallOn("service", "Add", "value"); // service.Add(value); public CodeWriter MethodCallOn(string receiver, string methodName, params string[] arguments) => - MethodCallCore(methodName, arguments, receiver, genericArguments: null, false, false); + MethodCallCore(methodName, arguments, receiver, genericArguments: null, false, false, false); + + /// + /// Writes a method invocation on a receiver from structured argument declarations. + /// + /// The receiver expression written before the method name. + /// The method name. + /// The structured arguments to invoke the method with. + /// + /// Whether the receiver is invoked with the null-conditional operator (?.) so the call is + /// skipped when the receiver is . + /// + /// The current writer. + /// writer.MethodCallOn("onBuilt", "Invoke", ["this", "builder"], nullConditional: true); // onBuilt?.Invoke(this, builder); + public CodeWriter MethodCallOn( + string receiver, + string methodName, + IEnumerable arguments, + bool nullConditional = false + ) => + MethodCallCore( + methodName, + (arguments ?? throw new ArgumentNullException(nameof(arguments))).Select(RenderCallArgument), + receiver, + genericArguments: null, + false, + false, + nullConditional + ); /// /// Writes an awaited method invocation on a receiver, such as await variable.MethodAsync(arg). @@ -2160,7 +2188,35 @@ public CodeWriter MethodCallOn(string receiver, string methodName, params string /// The current writer. /// writer.AwaitedMethodCallOn("service", "LoadAsync", "token"); // await service.LoadAsync(token); public CodeWriter AwaitedMethodCallOn(string receiver, string methodName, params string[] arguments) => - MethodCallCore(methodName, arguments, receiver, genericArguments: null, false, true); + MethodCallCore(methodName, arguments, receiver, genericArguments: null, false, true, false); + + /// + /// Writes an awaited method invocation on a receiver from structured argument declarations. + /// + /// The receiver expression written before the method name. + /// The method name. + /// The structured arguments to invoke the method with. + /// + /// Whether the receiver is invoked with the null-conditional operator (?.) so the call is + /// skipped when the receiver is . + /// + /// The current writer. + /// writer.AwaitedMethodCallOn("service", "LoadAsync", ["token"], nullConditional: true); // await service?.LoadAsync(token); + public CodeWriter AwaitedMethodCallOn( + string receiver, + string methodName, + IEnumerable arguments, + bool nullConditional = false + ) => + MethodCallCore( + methodName, + (arguments ?? throw new ArgumentNullException(nameof(arguments))).Select(RenderCallArgument), + receiver, + genericArguments: null, + false, + true, + nullConditional + ); /// /// Writes a method invocation from structured argument declarations. @@ -2217,7 +2273,8 @@ public CodeWriter AwaitedMethodCall( receiver, genericArguments, writeArgumentsOnSeparateLines, - true + true, + false ); /// @@ -2236,7 +2293,7 @@ public CodeWriter MethodCall( string? receiver = null, IEnumerable? genericArguments = null, bool writeArgumentsOnSeparateLines = false - ) => MethodCallCore(methodName, arguments, receiver, genericArguments, writeArgumentsOnSeparateLines, false); + ) => MethodCallCore(methodName, arguments, receiver, genericArguments, writeArgumentsOnSeparateLines, false, false); CodeWriter MethodCallCore( string methodName, @@ -2244,7 +2301,8 @@ CodeWriter MethodCallCore( string? receiver, IEnumerable? genericArguments, bool writeArgumentsOnSeparateLines, - bool isAwaited + bool isAwaited, + bool nullConditional ) { ValidateStatementPart(methodName, nameof(methodName)); @@ -2262,7 +2320,7 @@ bool isAwaited if (isAwaited) Write("await "); if (receiver is not null) - Write(receiver).Write('.'); + Write(receiver).Write(nullConditional ? "?." : "."); Write(methodName); if (genericArgumentList.Length > 0) { @@ -2332,6 +2390,123 @@ bool isAwaited return true; } + /// + /// Writes a chained method-call expression in which the result of each call is the receiver of the + /// next. The chain is written without a trailing semicolon so it composes as the value of an + /// , , or + /// other expression. A standalone chain statement is terminated by appending .Line(";"). + /// + /// The root invocation, optionally including a receiver, such as builder.Configuration.GetSection. + /// The root argument expressions. + /// The callback that appends chained invocations and the postfix. + /// Optional generic type arguments for the root invocation. + /// The current writer. + /// writer.Assignment("var value", value => value.MethodCallChain( + /// "builder.Configuration.GetSection", [$"{name}.SectionName"], + /// chain => chain.Method("Get", genericArguments: [optionsType]).Postfix("?? new()"))); + /// // var value = builder.Configuration.GetSection("x.SectionName").Get<Options>() ?? new(); + public CodeWriter MethodCallChain( + string rootMethod, + IEnumerable arguments, + Action configure, + IEnumerable? genericArguments = null + ) => MethodCallChainCore(rootMethod, arguments, configure, genericArguments, isAwaited: false); + + /// + /// Writes an awaited chained method-call expression in which the result of each call is the receiver + /// of the next. The chain is written without a trailing semicolon so it composes as an expression. + /// + /// The root invocation, optionally including a receiver. + /// The root argument expressions. + /// The callback that appends chained invocations and the postfix. + /// Optional generic type arguments for the root invocation. + /// The current writer. + /// writer.Return(value => value.AwaitedMethodCallChain( + /// "service.LoadAsync", ["token"], chain => chain.Method("Configure"))); + public CodeWriter AwaitedMethodCallChain( + string rootMethod, + IEnumerable arguments, + Action configure, + IEnumerable? genericArguments = null + ) => MethodCallChainCore(rootMethod, arguments, configure, genericArguments, isAwaited: true); + + CodeWriter MethodCallChainCore( + string rootMethod, + IEnumerable arguments, + Action configure, + IEnumerable? genericArguments, + bool isAwaited + ) + { + ValidateStatementPart(rootMethod, nameof(rootMethod)); + if (arguments is null) + throw new ArgumentNullException(nameof(arguments)); + if (configure is null) + throw new ArgumentNullException(nameof(configure)); + + var rootArguments = arguments.ToArray(); + for (var index = 0; index < rootArguments.Length; index++) + ValidateStatementPart(rootArguments[index], nameof(arguments)); + + var builder = new MethodChainBuilder(rootMethod, rootArguments, genericArguments); + configure(builder); + + return RenderMethodChain(builder, isAwaited); + } + + CodeWriter RenderMethodChain(MethodChainBuilder builder, bool isAwaited) + { + if (isAwaited) + Write("await "); + + Write(builder.RootMethod); + RenderGenericArgumentList(builder.RootGenericArguments); + RenderInvocationArguments(builder.RootArguments); + + for (var index = 0; index < builder.Segments.Count; index++) + { + var segment = builder.Segments[index]; + Write('.'); + Write(segment.MethodName); + RenderGenericArgumentList(segment.GenericArguments); + RenderInvocationArguments(segment.Arguments); + } + + if (builder.PostfixExpression is not null) + Write(builder.PostfixExpression); + + return this; + } + + void RenderGenericArgumentList(ImmutableArray genericArguments) + { + if (genericArguments.IsDefaultOrEmpty) + return; + + Write('<'); + for (var index = 0; index < genericArguments.Length; index++) + { + if (index != 0) + Write(", "); + if (genericArguments[index].IsEmpty) + throw new ArgumentException("Generic arguments cannot be empty."); + TypeReference(genericArguments[index]); + } + Write('>'); + } + + void RenderInvocationArguments(ImmutableArray arguments) + { + Write('('); + for (var index = 0; index < arguments.Length; index++) + { + if (index != 0) + Write(", "); + Write(arguments[index]); + } + Write(')'); + } + /// /// Writes an assignment statement. /// diff --git a/src/src/SourceGeneratorShared/MethodChainBuilder.cs b/src/src/SourceGeneratorShared/MethodChainBuilder.cs new file mode 100644 index 0000000..dc0a6ad --- /dev/null +++ b/src/src/SourceGeneratorShared/MethodChainBuilder.cs @@ -0,0 +1,90 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Describes one invocation in a . The receiver of every segment is +/// implicitly the result of the preceding invocation. +/// +/// The method name, without a leading receiver. +/// The argument expressions. +/// The generic type arguments, or empty for none. +readonly record struct MethodChainSegment( + string MethodName, + ImmutableArray Arguments, + ImmutableArray GenericArguments +); + +/// +/// Accumulates the invocations of a chained method-call expression, where the result of each call is +/// the receiver of the next. Configure it through the configure callback of +/// and . +/// +/// +/// writer.Assignment("var value", value => value.MethodCallChain( +/// "builder.Configuration.GetSection", [$"{name}.SectionName"], +/// chain => chain.Method("Get", genericArguments: [optionsType]).Postfix("?? new()"))); +/// +public sealed class MethodChainBuilder +{ + internal string RootMethod { get; } + internal ImmutableArray RootArguments { get; } + internal ImmutableArray RootGenericArguments { get; } + internal List Segments { get; } = []; + internal string? PostfixExpression { get; private set; } + + internal MethodChainBuilder( + string rootMethod, + IEnumerable? rootArguments, + IEnumerable? rootGenericArguments + ) + { + RootMethod = rootMethod; + RootArguments = rootArguments is null ? [] : [.. rootArguments]; + RootGenericArguments = rootGenericArguments is null ? [] : [.. rootGenericArguments]; + } + + /// + /// Appends an invocation to the chain. The receiver is implicitly the result of the previous call. + /// + /// The method name, without a receiver. + /// The argument expressions, or for a no-argument call. + /// The generic type arguments, or for none. + /// The current builder. + /// chain.Method("Get", genericArguments: [optionsType]) + public MethodChainBuilder Method( + string methodName, + IEnumerable? arguments = null, + IEnumerable? genericArguments = null + ) + { + if (string.IsNullOrWhiteSpace(methodName)) + throw new ArgumentException("Method name cannot be null or whitespace.", nameof(methodName)); + + var argumentList = arguments?.ToArray() ?? []; + for (var index = 0; index < argumentList.Length; index++) + { + if (string.IsNullOrWhiteSpace(argumentList[index])) + throw new ArgumentException("Argument values cannot be null or whitespace.", nameof(arguments)); + } + + Segments.Add(new(methodName, [.. argumentList], genericArguments is null ? [] : [.. genericArguments])); + return this; + } + + /// + /// Appends a trailing expression to the result of the last invocation, such as ?? new() or !. + /// The value is written verbatim, so include any leading separator required, e.g. " ?? new()". + /// + /// The postfix expression, without a trailing semicolon. + /// The current builder. + /// chain.Postfix(" ?? new()") + public MethodChainBuilder Postfix(string expression) + { + if (string.IsNullOrWhiteSpace(expression)) + throw new ArgumentException("Postfix expression cannot be null or whitespace.", nameof(expression)); + + PostfixExpression = expression; + return this; + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs index 32ddf36..9bbb98c 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs @@ -28,6 +28,7 @@ public class SampleTarget { } await Assert.That(result).HasGeneratedMethod("Describe"); await Assert.That(result).HasGeneratedMethod("Format"); await Assert.That(result).HasGeneratedMethod("Categorize"); + await Assert.That(result).HasGeneratedMethod("Configure"); var defaultAccessibility = await Assert.That(result).HasGeneratedProperty("DefaultAccessibility"); await Assert.That(defaultAccessibility.Modifiers.ToString()).IsEqualTo("public"); @@ -63,6 +64,25 @@ public class SampleTarget { } await Assert.That(constructor.ToString()).Contains("_value = value;"); } + [Test] + public async Task GenerateSample_EmitsChainedInvocationAndNullConditional(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + [GenerateCodeWriterSample] + public class SampleTarget { } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + var configure = await Assert.That(result).HasGeneratedMethod("Configure"); + var configureText = configure.ToString(); + await Assert.That(configureText).Contains("var hostKitOptions = source.Trim().ToUpper() ?? string.Empty;"); + await Assert.That(configureText).Contains("onBuilt?.Invoke();"); + } + [Test] public async Task GenerateSample_EmitsConditionalBranches(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs index a1e9095..7ce2f1d 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs @@ -1836,6 +1836,110 @@ public async Task MethodCall_WithStructuredArgument_WritesNamedArgument() await Assert.That(writer.ToString()).IsEqualTo("Configure(option: value);\n"); } + [Test] + public async Task MethodCallOn_WithNullConditional_WritesNullConditionalOperator() + { + var writer = CodeWriterFactory.ForTests(); + + writer.MethodCallOn("onBuilt", "Invoke", ["this", "builder"], nullConditional: true); + + await Assert.That(writer.ToString()).IsEqualTo("onBuilt?.Invoke(this, builder);\n"); + } + + [Test] + public async Task AwaitedMethodCallOn_WithNullConditional_WritesAwaitAndNullConditionalOperator() + { + var writer = CodeWriterFactory.ForTests(); + + writer.AwaitedMethodCallOn("service", "LoadAsync", ["token"], nullConditional: true); + + await Assert.That(writer.ToString()).IsEqualTo("await service?.LoadAsync(token);\n"); + } + + [Test] + public async Task MethodCallChain_WritesChainedInvocations() + { + var writer = CodeWriterFactory.ForTests(); + + writer.MethodCallChain( + "builder.Configuration.GetSection", + ["x.SectionName"], + chain => chain.Method("Get", genericArguments: [Type("Options")]).Postfix(" ?? new()") + ); + + await Assert + .That(writer.ToString()) + .IsEqualTo("builder.Configuration.GetSection(x.SectionName).Get() ?? new()"); + } + + [Test] + public async Task MethodCallChain_AsAssignmentValue_WritesDeclarationAndSemicolon() + { + var writer = CodeWriterFactory.ForTests(); + + writer.Assignment( + "var value", + value => + value.MethodCallChain( + "builder.Configuration.GetSection", + ["x.SectionName"], + chain => chain.Method("Get", genericArguments: [Type("Options")]).Postfix(" ?? new()") + ) + ); + + await Assert + .That(writer.ToString()) + .IsEqualTo("var value = builder.Configuration.GetSection(x.SectionName).Get() ?? new();\n"); + } + + [Test] + public async Task AwaitedMethodCallChain_WritesAwaitPrefix() + { + var writer = CodeWriterFactory.ForTests(); + + writer.Return(value => + value.AwaitedMethodCallChain("service.LoadAsync", ["token"], chain => chain.Method("Configure")) + ); + + await Assert.That(writer.ToString()).IsEqualTo("return await service.LoadAsync(token).Configure();\n"); + } + + [Test] + public async Task MethodCallChain_WithMultipleSegments_WritesEachInvocation() + { + var writer = CodeWriterFactory.ForTests(); + + writer.MethodCallChain( + "items.Where", + ["value => value.Enabled"], + chain => chain.Method("OrderBy", ["value => value.Name"]).Method("ToList") + ); + + await Assert + .That(writer.ToString()) + .IsEqualTo("items.Where(value => value.Enabled).OrderBy(value => value.Name).ToList()"); + } + + [Test] + public async Task MethodCallChain_GivenWhitespaceMethodName_Throws() + { + var writer = CodeWriterFactory.ForTests(); + + await Assert + .That(() => writer.MethodCallChain(" ", [], chain => chain.Method("Get"))) + .Throws(); + } + + [Test] + public async Task MethodCallChain_GivenWhitespaceArgument_Throws() + { + var writer = CodeWriterFactory.ForTests(); + + await Assert + .That(() => writer.MethodCallChain("GetSection", [" "], chain => chain.Method("Get"))) + .Throws(); + } + [Test] public async Task Assignment_WithObjectCreationOptions_WritesOptionalVarAndMixedArguments() {