diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cba6922..cf8dc8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,8 +29,20 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + # Bootstrap the first source-provider packages while their new package IDs await nuget.org trusted-publisher + # policies. Remove this step once both 0.1.0 releases are available from nuget.org. + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore --property:Configuration=Release --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Build - run: dotnet build --configuration Release + run: dotnet build --no-restore --configuration Release # The integration fixture starts a Chronicle container whose image is named after the Chronicle # package version, so a version bump always faces a cold ~400MB pull. Testcontainers times the first @@ -45,4 +57,4 @@ jobs: docker pull "cratis/chronicle:$VERSION-development" - name: Test - run: dotnet test --configuration Release + run: dotnet test --no-restore --configuration Release diff --git a/.github/workflows/publish-native.yml b/.github/workflows/publish-native.yml index dd3edd0..078f782 100644 --- a/.github/workflows/publish-native.yml +++ b/.github/workflows/publish-native.yml @@ -28,8 +28,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r osx-arm64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r osx-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-arm64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r osx-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-arm64 - name: Package run: | @@ -55,8 +65,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r osx-x64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r osx-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-x64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r osx-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/osx-x64 - name: Package run: | @@ -82,8 +102,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r linux-x64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r linux-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-x64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r linux-x64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-x64 - name: Package run: | @@ -109,8 +139,18 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore Source/Cli/Cli.csproj -r linux-arm64 --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Publish - run: dotnet publish Source/Cli/Cli.csproj -c Release -r linux-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-arm64 + run: dotnet publish Source/Cli/Cli.csproj --no-restore -c Release -r linux-arm64 -p:SelfContained=true -p:PublishSingleFile=true -p:Version=${{ inputs.version }} -o ./publish/linux-arm64 - name: Package run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e31d0f..354242e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -101,8 +101,19 @@ jobs: - name: Remove any existing artifacts run: rm -rf ${{ env.NUGET_OUTPUT }} + # Bootstrap until the new package IDs are available from nuget.org. + - name: Download Screenplay source-provider packages + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download v0.1.0 --repo Cratis/Screenplay.Generation --pattern '*.nupkg' --dir ./Artifacts/Dependencies + gh release download v0.1.0 --repo Cratis/Screenplay.CritterStack --pattern '*.nupkg' --dir ./Artifacts/Dependencies + + - name: Restore + run: dotnet restore --property:Configuration=Release --source ./Artifacts/Dependencies --source https://api.nuget.org/v3/index.json + - name: Build - run: dotnet build --configuration Release -p:Version=${{ needs.release.outputs.version }} + run: dotnet build --no-restore --configuration Release -p:Version=${{ needs.release.outputs.version }} - name: Create NuGet packages run: dotnet pack Source/Cli/Cli.csproj --no-build --configuration Release -o ${{ env.NUGET_OUTPUT }} -p:PackageVersion=${{ needs.release.outputs.version }} diff --git a/Directory.Packages.props b/Directory.Packages.props index 51aa1af..ae395db 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,9 @@ + + + diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md index 6be48c6..8c37bfe 100644 --- a/Documentation/reference/screenplay.md +++ b/Documentation/reference/screenplay.md @@ -1,6 +1,6 @@ # Screenplay -`cratis screenplay` works with Cratis Screenplay (`.play`) documents. It generates one from the source code of a Cratis Arc application — so the event model your team reads is derived from the code that actually runs rather than maintained alongside it — and it compiles the documents you already have. +`cratis screenplay` works with Cratis Screenplay (`.play`) documents. It generates one from Arc, Marten, or Marten + Wolverine application source — so the event model your team reads is derived from the code that actually runs rather than maintained alongside it — and it compiles the documents you already have. ```bash cratis screenplay generate [PATH] @@ -9,11 +9,11 @@ cratis screenplay validate [PATH] **Nothing needs to be running.** This is what separates `cratis screenplay` from [`cratis arc`](../arc/index.md): every `arc` command talks to a *running* application over HTTP, while `screenplay` only ever reads files. The result is reproducible from a checkout — commit it, diff it, and run it in CI, on a machine where the application was never started. -Fetching a `.play` document from a running Arc application over its introspection endpoint is a separate, complementary route: it trades the SDK requirement for the requirement that the application be running. That route does not exist yet — neither the Arc endpoint nor a CLI command for it — so generating from source is today the only way to derive a Screenplay from a Cratis Arc application. +Fetching a `.play` document from a running application over an introspection endpoint is a separate, complementary route: it trades the SDK requirement for the requirement that the application be running. Source generation remains reproducible from a restored checkout and does not execute application startup or connect to Chronicle/PostgreSQL. ## `cratis screenplay generate [PATH]` -Reads a solution or project, derives the event model from the Arc artifacts it finds — commands, events, read models, projections, reactors, constraints, and the concepts they are built from — and writes a Screenplay document. +Reads a solution or project, selects the Arc, Marten, or Critter Stack source provider, derives the event model from the framework artifacts and conventions it finds, and writes a Screenplay document. By default the document goes to standard output, so it composes with the shell: @@ -34,6 +34,7 @@ Pass `--file` to write it directly instead. The output is written as raw UTF-8, | Option | Description | |---|---| | `--file ` | File to write the generated Screenplay to. Writes to standard output when not given. | +| `--provider ` | Source provider: `auto`, `arc`, `marten`, or `critter-stack`. Defaults to auto detection. | | `--domain ` | Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project, and to the solution name when several projects are read. | | `--module ` | Name of the module every discovered feature is placed within. Defaults to the domain. | | `--skip-segments ` | Number of leading namespace segments to skip when inferring features and slices. | @@ -45,6 +46,8 @@ The output file uses `--file` rather than `-o`, because `-o/--output` is the glo cratis screenplay generate cratis screenplay generate ./MyApp.slnx --file MyApp.play cratis screenplay generate ./Source/MyApp/MyApp.csproj +cratis screenplay generate ./Banking.csproj --provider marten --file Banking.play +cratis screenplay generate ./Helpdesk.csproj --provider critter-stack --file Helpdesk.play cratis screenplay generate --domain Library --module Lending --file Library.play ``` @@ -76,7 +79,7 @@ A solution filter (`.slnf`) is read as the solution it filters, which is how a r A Screenplay describes one application, and an application is regularly split across several projects — an executable alongside the libraries holding its slices. Every project of a solution therefore takes part in the same document, except: -- **Projects that cannot declare anything the document is made of.** Every artifact is declared with an attribute the framework ships, so a project resolving neither the Arc nor the Chronicle one — a Roslyn analyzer, a build-time tool, a code-generation project — is left out. This is asked of what the project can *see*, not of what it is called. +- **With the Arc provider, projects that cannot declare an Arc/Chronicle artifact.** A Roslyn analyzer, build-time tool, or code-generation project resolving neither framework is left out. Marten/Wolverine contracts are frequently markerless and may live in referenced projects without a direct package reference, so Critter Stack analysis retains non-spec C# projects and lets the provider contribute only evidence it recognizes. - **Spec projects**, by name: the ones called, or ending in, `.Specs`, `.Specifications`, `.Tests`, `.Test`, `.IntegrationTests`, or `.Specs.AppHost`. Nothing about what a spec project can see tells it apart — it references the same framework the application does — so the name is what decides. `.Specs.AppHost` covers the host integration specs start the application in. A project that targets several frameworks is read once. The workspace opens it once per target framework and names the results `MyApp(net10.0)`, `MyApp(net9.0)`; they all hold the same application, so one of them takes part. @@ -131,7 +134,8 @@ The project does **not** have to have been built first. Sources MSBuild generate | No solution or project found in `PATH` or any parent folder | Not-found error. | | The solution holds no project that is not specs | Validation error (`CLI0001`). | | A project has not been restored | Validation error (`CLI0005`) naming it; nothing is generated. | -| No project of the solution can declare a command or an event type | Validation error (`CLI0006`). | +| No Arc project of the solution can declare a command or event type | Validation error (`CLI0006`). | +| `--provider` is not `auto`, `arc`, `marten`, or `critter-stack` | Validation error (`CLI0007`). | | A project cannot be read into a compilation | Validation error (`CLI0004`) naming it; the remaining projects are still described. | | Generation reports one or more errors, with `--file` | Validation error; the document is written anyway. | | Generation reports one or more errors, writing to standard output | Validation error; nothing is written. | @@ -181,7 +185,7 @@ With `-o json` or `-o json-compact` the same diagnostics are written to standard Generating from source is one of three ways to arrive at a `.play` file, and they meet in the same place: -- **From source** — `screenplay generate`, for an application that already exists in Cratis Arc. Needs the .NET SDK and a checkout; needs nothing running. +- **From source** — `screenplay generate`, for an Arc, Marten, or Critter Stack application. Needs the .NET SDK and a restored checkout; needs nothing running. - **From a running system** — [`cratis prologue`](prologue.md) captures what a system does and interprets it into a Screenplay, for systems built without Cratis. - **By hand** — write the `.play` file as the design, before any code exists. diff --git a/Source/Cli.Specs/for_CritterStackScreenplayGeneration/given/a_marten_application_built_from_source.cs b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/given/a_marten_application_built_from_source.cs new file mode 100644 index 0000000..fcdf88a --- /dev/null +++ b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/given/a_marten_application_built_from_source.cs @@ -0,0 +1,65 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_CritterStackScreenplayGeneration.given; + +public class a_marten_application_built_from_source : Specification +{ + protected const string ProjectName = "Banking"; + + static readonly string Source = string.Join( + '\n', + [ + "namespace Marten", + "{", + " public interface IDocumentStore;", + " public class StoreOptions", + " {", + " public Marten.Events.Projections.ProjectionOptions Projections { get; } = new();", + " }", + "}", + "namespace Marten.Events.Projections", + "{", + " public enum SnapshotLifecycle { Inline }", + " public class ProjectionOptions", + " {", + " public void Snapshot(SnapshotLifecycle lifecycle) { }", + " }", + "}", + "namespace Banking", + "{", + " public record AccountOpened(System.Guid AccountId);", + " public class Account", + " {", + " public System.Guid Id { get; set; }", + " public void Apply(AccountOpened opened) { }", + " }", + " public static class Configuration", + " {", + " public static void Configure(Marten.StoreOptions options) =>", + " options.Projections.Snapshot(Marten.Events.Projections.SnapshotLifecycle.Inline);", + " }", + "}" + ]); + + protected LoadedCompilation Loaded { get; private set; } = null!; + + void Establish() => Loaded = new( + [ + CSharpCompilation.Create( + ProjectName, + [CSharpSyntaxTree.ParseText(Source, path: "/workspace/Banking/Account.cs")], + References(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + ], + [ProjectName], + []); + + static IEnumerable References() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(_ => MetadataReference.CreateFromFile(_)); +} diff --git a/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/and_source_does_not_compile.cs b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/and_source_does_not_compile.cs new file mode 100644 index 0000000..b704b21 --- /dev/null +++ b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/and_source_does_not_compile.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_CritterStackScreenplayGeneration.when_generating; + +public class and_source_does_not_compile : given.a_marten_application_built_from_source +{ + GeneratedScreenplay _result = null!; + + void Because() + { + var broken = Loaded.Compilations[0].AddSyntaxTrees(CSharpSyntaxTree.ParseText("public class Broken { MissingType Value; }")); + var loaded = Loaded with { Compilations = [broken] }; + _result = CritterStackScreenplayGeneration.GenerateFrom( + loaded, + "/workspace/Banking/Banking.csproj", + ScreenplayGenerationOptions.Default with { Provider = ScreenplayProviders.Marten }); + } + + [Fact] void should_generate_no_source() => _result.Source.ShouldBeEmpty(); + [Fact] void should_report_the_compilation_error() => _result.Diagnostics.Select(_ => _.Code).ShouldContainOnly(ScreenplayDiagnosticCodes.SourceDidNotCompile); +} diff --git a/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/from_marten_source.cs b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/from_marten_source.cs new file mode 100644 index 0000000..cb600bd --- /dev/null +++ b/Source/Cli.Specs/for_CritterStackScreenplayGeneration/when_generating/from_marten_source.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_CritterStackScreenplayGeneration.when_generating; + +public class from_marten_source : given.a_marten_application_built_from_source +{ + GeneratedScreenplay _result = null!; + + void Because() => _result = CritterStackScreenplayGeneration.GenerateFrom( + Loaded, + "/workspace/Banking/Banking.csproj", + ScreenplayGenerationOptions.Default with { Provider = ScreenplayProviders.Marten }); + + [Fact] void should_report_the_project() => _result.Projects.ShouldContainOnly(ProjectName); + [Fact] void should_generate_the_read_model() => _result.Source.ShouldContain("readmodel Account"); + [Fact] void should_generate_the_event() => _result.Source.ShouldContain("event AccountOpened"); + [Fact] void should_generate_the_reducer() => _result.Source.ShouldContain("reducer AccountSnapshot => Account"); + [Fact] void should_report_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs index 135b77c..1aa747a 100644 --- a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs @@ -11,6 +11,7 @@ void Establish() _settings.Domain = "Library"; _settings.Module = "Lending"; _settings.SkipSegments = 2; + _settings.Provider = ScreenplayProviders.CritterStack; } async Task Because() => await Execute(); @@ -20,6 +21,11 @@ [Fact] void should_pass_them_to_the_generation() => _generation.Received(1).Gene Arg.Is(options => options.Domain == "Library" && options.Module == "Lending" && options.SegmentsToSkip == 2), Arg.Any()); + [Fact] void should_pass_the_provider_to_the_generation() => _generation.Received(1).Generate( + Arg.Any(), + Arg.Is(options => options.Provider == ScreenplayProviders.CritterStack), + Arg.Any()); + [Fact] void should_leave_the_modules_named_by_one_name() => _generation.Received(1).Generate( Arg.Any(), Arg.Is(options => !options.ModulesFromNamespaceRoots), diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/given/provider_compilations.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/given/provider_compilations.cs new file mode 100644 index 0000000..1137fb0 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/given/provider_compilations.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.given; + +public class provider_compilations : Specification +{ + protected static LoadedCompilation LoadedFrom(string source) => new( + [CSharpCompilation.Create( + "Application", + [CSharpSyntaxTree.ParseText(source)], + References(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))], + ["Application"], + []); + + static IEnumerable References() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(_ => MetadataReference.CreateFromFile(_)); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs new file mode 100644 index 0000000..fdb9ed9 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_a_solution_has_several_critter_stack_hosts.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_a_solution_has_several_critter_stack_hosts : Specification +{ + GeneratedScreenplay? _result; + + void Because() + { + var loaded = new LoadedCompilation( + [Host("Api"), Host("Worker")], + ["Api", "Worker"], + []); + _result = new ProviderScreenplayGeneration().AmbiguousHosts( + loaded, + "/workspace/Applications.slnx", + new CritterStackSourceProvider()); + } + + [Fact] void should_report_an_outcome() => _result.ShouldNotBeNull(); + [Fact] void should_report_both_hosts() => _result!.Diagnostics.Single().Message.ShouldContain("Api, Worker"); + [Fact] void should_report_the_ambiguity_code() => _result!.Diagnostics.Single().Code.ShouldEqual(ScreenplayDiagnosticCodes.AmbiguousApplicationHosts); + [Fact] void should_generate_no_source() => _result!.Source.ShouldBeEmpty(); + + static CSharpCompilation Host(string name) => CSharpCompilation.Create( + name, + [CSharpSyntaxTree.ParseText("public static class Program { public static void Main() { } }")], + References(), + new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + + static IEnumerable References() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(_ => MetadataReference.CreateFromFile(_)); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_arc_and_critter_stack_are_present.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_arc_and_critter_stack_are_present.cs new file mode 100644 index 0000000..9e1713d --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_arc_and_critter_stack_are_present.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_arc_and_critter_stack_are_present : given.provider_compilations +{ + ProviderSelection _selection = null!; + + void Because() => _selection = new ProviderScreenplayGeneration().Discover( + LoadedFrom( + "namespace Cratis.Arc.Commands.ModelBound { public class CommandAttribute : System.Attribute; } " + + "namespace Marten { public class StoreOptions; } " + + "namespace Wolverine { public class WolverineOptions; }"), + "/workspace/Application.csproj"); + + [Fact] void should_select_no_provider() => _selection.Provider.ShouldBeNull(); + [Fact] void should_report_ambiguity() => _selection.Error!.Diagnostics.Single().Code.ShouldEqual(ScreenplayDiagnosticCodes.AmbiguousProviders); + [Fact] void should_name_both_candidates() => _selection.Error!.Diagnostics.Single().Message.ShouldContain("arc, critter-stack"); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_marten_and_wolverine_are_present.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_marten_and_wolverine_are_present.cs new file mode 100644 index 0000000..72197b6 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_marten_and_wolverine_are_present.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_marten_and_wolverine_are_present : given.provider_compilations +{ + ProviderSelection _selection = null!; + + void Because() => _selection = new ProviderScreenplayGeneration().Discover( + LoadedFrom( + "namespace Marten { public class StoreOptions; } " + + "namespace Wolverine { public class WolverineOptions; }"), + "/workspace/Application.csproj"); + + [Fact] void should_select_the_more_specific_critter_stack_provider() => _selection.Provider!.Name.ShouldEqual(ScreenplayProviders.CritterStack); + [Fact] void should_report_no_error() => _selection.Error.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_no_provider_matches.cs b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_no_provider_matches.cs new file mode 100644 index 0000000..196aec2 --- /dev/null +++ b/Source/Cli.Specs/for_ProviderScreenplayGeneration/when_selecting/and_no_provider_matches.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_ProviderScreenplayGeneration.when_selecting; + +public class and_no_provider_matches : given.provider_compilations +{ + ProviderSelection _selection = null!; + + void Because() => _selection = new ProviderScreenplayGeneration().Discover( + LoadedFrom("public class Application;"), + "/workspace/Application.csproj"); + + [Fact] void should_select_no_provider() => _selection.Provider.ShouldBeNull(); + [Fact] void should_report_no_match() => _selection.Error!.Diagnostics.Single().Code.ShouldEqual(ScreenplayDiagnosticCodes.NoMatchingProvider); + [Fact] void should_list_available_providers() => _selection.Error!.Diagnostics.Single().Message.ShouldContain("arc, critter-stack, marten"); +} diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 19704da..29ac5f2 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -30,6 +30,9 @@ + + + diff --git a/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs new file mode 100644 index 0000000..516454a --- /dev/null +++ b/Source/Cli/Commands/Screenplay/CritterStackScreenplayGeneration.cs @@ -0,0 +1,104 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.CritterStack.Screenplay; +using Cratis.Screenplay.Generation; +using Cratis.Screenplay.Generation.DotNet; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Generates Screenplay documents from Marten and Wolverine source code. +/// +public sealed class CritterStackScreenplayGeneration : IScreenplayGeneration +{ + /// + public async Task Generate( + string targetPath, + ScreenplayGenerationOptions options, + CancellationToken cancellationToken) => + GenerateFrom( + await ScreenplayCompilationLoader.Load(targetPath, includeAllProjects: true, cancellationToken), + targetPath, + options); + + /// + /// Generates from compilations that have already been loaded. + /// + /// The loaded project compilations. + /// The solution or project path. + /// Generation options. + /// The generated Screenplay. + internal static GeneratedScreenplay GenerateFrom( + LoadedCompilation loaded, + string targetPath, + ScreenplayGenerationOptions options) + { + if (loaded.Compilations.Count == 0) + { + return new GeneratedScreenplay(string.Empty, loaded.Diagnostics); + } + + var sourceErrors = SourceErrors(loaded); + if (sourceErrors.Count > 0) + { + return new GeneratedScreenplay(string.Empty, [.. loaded.Diagnostics, .. sourceErrors]) + { + Projects = loaded.ProjectNames + }; + } + + var sourceRoot = Path.GetDirectoryName(targetPath); + var projects = loaded.Compilations + .Select((compilation, index) => new DotNetProjectCompilation + { + Name = loaded.ProjectNames[index], + ProjectPath = targetPath, + SourceRoot = sourceRoot, + Compilation = compilation + }) + .ToArray(); + var result = new CritterStackScreenplayGenerator().Generate( + projects, + new CritterStackScreenplayOptions + { + Domain = options.Domain ?? DomainFrom(targetPath, loaded), + Module = options.Module, + NamespaceSegmentsToSkip = options.SegmentsToSkip ?? 0 + }); + + return new GeneratedScreenplay( + result.Source, + [.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)]) + { + Projects = loaded.ProjectNames + }; + } + + static IReadOnlyList SourceErrors(LoadedCompilation loaded) => + [ + .. loaded.Compilations.SelectMany((compilation, index) => compilation.GetDiagnostics() + .Where(_ => _.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .Take(1) + .Select(_ => new ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity.Error, + ScreenplayDiagnosticCodes.SourceDidNotCompile, + $"Source project '{loaded.ProjectNames[index]}' did not compile: {_.Id} {_.GetMessage()}", + _.Location.GetLineSpan().Path))) + ]; + + static string? DomainFrom(string targetPath, LoadedCompilation loaded) => + loaded.Compilations.Count > 1 ? Path.GetFileNameWithoutExtension(targetPath) : loaded.ProjectNames[0]; + + static ScreenplayDiagnostic Map(GenerationDiagnostic diagnostic) => new( + diagnostic.Severity switch + { + GenerationDiagnosticSeverity.Information => ScreenplayDiagnosticSeverity.Information, + GenerationDiagnosticSeverity.Warning => ScreenplayDiagnosticSeverity.Warning, + GenerationDiagnosticSeverity.Error => ScreenplayDiagnosticSeverity.Error, + _ => ScreenplayDiagnosticSeverity.Error + }, + diagnostic.Code, + diagnostic.Message, + diagnostic.Source?.Path); +} diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs index 7b81c98..16f38c2 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs @@ -4,17 +4,18 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Generates a Cratis Screenplay (.play) file from the source code of a Cratis Arc application — reads the -/// solution or project with Roslyn, hands the compilation to the Screenplay generator, and writes the result. +/// Generates a Cratis Screenplay (.play) file from Arc, Marten, or Critter Stack application source — reads +/// the solution or project with Roslyn, hands the compilation to the selected generator, and writes the result. /// -[LlmDescription("Generates a Cratis Screenplay (.play) file from Cratis Arc SOURCE CODE. Reads a solution or project with Roslyn — it never connects to a running application, so nothing needs to be started first. Writes the .play source to standard output unless --file is given. Diagnostics for anything that could not be expressed go to standard error, grouped by severity; the command exits with a validation error when any of them is an error.")] -[CliCommand("generate", "Generate a Screenplay from Arc source code", Branch = typeof(ScreenplayBranch))] +[LlmDescription("Generates a Cratis Screenplay (.play) file from Arc, Marten, or Critter Stack SOURCE CODE. Reads a solution or project with Roslyn — it never connects to a running application, so nothing needs to be started first. Writes the .play source to standard output unless --file is given. Diagnostics for anything that could not be expressed go to standard error, grouped by severity; the command exits with a validation error when any of them is an error.")] +[CliCommand("generate", "Generate a Screenplay from application source code", Branch = typeof(ScreenplayBranch))] [CliExample("screenplay", "generate")] [CliExample("screenplay", "generate", "./MyApp.slnx", "--file", "MyApp.play")] [CliExample("screenplay", "generate", "./Source/MyApp/MyApp.csproj")] [CliExample("screenplay", "generate", "--modules-from-namespace-roots", "--skip-segments", "1")] [LlmOption("[PATH]", "string", "Solution (.slnx, .sln, .slnf), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] [LlmOption("--file", "string", "File to write the generated Screenplay to. Writes to standard output when not given.")] +[LlmOption("--provider", "string", "Source framework provider: auto, arc, marten, or critter-stack.")] [LlmOption("--domain", "string", "Name of the domain the generated document belongs to.")] [LlmOption("--module", "string", "Name of the module every discovered feature is placed within.")] [LlmOption("--skip-segments", "int", "Number of leading namespace segments to skip when inferring features and slices.")] diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs index cc3dd1c..1e2c148 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs @@ -25,6 +25,13 @@ public class GenerateScreenplaySettings : GlobalSettings [Description("File to write the generated Screenplay to. Writes to standard output when not given.")] public string? File { get; set; } + /// + /// Gets or sets the source framework provider used for generation. + /// + [CommandOption("--provider ")] + [Description("Source framework provider: auto, arc, marten, or critter-stack. Defaults to auto detection.")] + public string Provider { get; set; } = ScreenplayProviders.Auto; + /// /// Gets or sets the domain the generated document belongs to. /// @@ -67,5 +74,5 @@ public class GenerateScreenplaySettings : GlobalSettings /// Gets the generation options these settings describe. /// /// The . - public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments, ModulesFromNamespaceRoots); + public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments, ModulesFromNamespaceRoots, Provider); } diff --git a/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs index 40239f1..b43fdce 100644 --- a/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs @@ -4,12 +4,12 @@ namespace Cratis.Cli.Commands.Screenplay; /// -/// Defines a system that generates a Screenplay document from the source code of a Cratis Arc application. +/// Defines a system that generates a Screenplay document from application source code. /// /// -/// This is the seam between the CLI and the Cratis.Arc.Screenplay generator. Everything the CLI does around +/// This is the seam between the CLI and source framework generator packages. Everything the CLI does around /// generation — resolving the target, writing the document, reporting diagnostics — is expressed against this -/// interface so that it stays independent of how a compilation is obtained. +/// interface so that it stays independent of how framework semantics are recovered. /// public interface IScreenplayGeneration { diff --git a/Source/Cli/Commands/Screenplay/IScreenplaySourceProvider.cs b/Source/Cli/Commands/Screenplay/IScreenplaySourceProvider.cs new file mode 100644 index 0000000..611db8f --- /dev/null +++ b/Source/Cli/Commands/Screenplay/IScreenplaySourceProvider.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Defines one bundled source-framework provider for Screenplay generation. +/// +interface IScreenplaySourceProvider +{ + /// + /// Gets the stable provider name used by --provider. + /// + string Name { get; } + + /// + /// Gets provider names this more-specific provider replaces when both match. + /// + IReadOnlyList Supersedes { get; } + + /// + /// Gets whether a solution with several deployable hosts is ambiguous for this provider. + /// + bool RequiresSingleHost { get; } + + /// + /// Gets whether source evidence in the loaded compilations matches this provider. + /// + /// The loaded source compilations. + /// when the provider matches. + bool Matches(LoadedCompilation loaded); + + /// + /// Generates the Screenplay from already loaded source. + /// + /// The loaded source compilations. + /// The source target path. + /// Generation options. + /// The generated Screenplay. + GeneratedScreenplay GenerateFrom( + LoadedCompilation loaded, + string targetPath, + ScreenplayGenerationOptions options); +} diff --git a/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs new file mode 100644 index 0000000..a30f414 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ProviderScreenplayGeneration.cs @@ -0,0 +1,110 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Loads application source once, discovers bundled provider capabilities, and delegates generation. +/// +public sealed class ProviderScreenplayGeneration : IScreenplayGeneration +{ + readonly IReadOnlyList _providers; + + /// + /// Initializes generation with every allowlisted provider bundled into this CLI build. + /// + public ProviderScreenplayGeneration() + : this(ScreenplaySourceProviders.Default) + { + } + + internal ProviderScreenplayGeneration(IReadOnlyList providers) + { + _providers = providers; + } + + /// + public async Task Generate( + string targetPath, + ScreenplayGenerationOptions options, + CancellationToken cancellationToken) + { + var requested = options.Provider.ToLowerInvariant(); + var explicitProvider = string.Equals(requested, ScreenplayProviders.Auto, StringComparison.Ordinal) + ? null + : _providers.FirstOrDefault(_ => string.Equals(_.Name, requested, StringComparison.Ordinal)); + if (requested != ScreenplayProviders.Auto && explicitProvider is null) + { + return InvalidProvider(requested, targetPath); + } + + var loaded = await ScreenplayCompilationLoader.Load(targetPath, includeAllProjects: true, cancellationToken); + var selection = explicitProvider is null ? Discover(loaded, targetPath) : new ProviderSelection(explicitProvider, null); + if (selection.Error is not null) + { + return selection.Error; + } + + var provider = selection.Provider!; + return AmbiguousHosts(loaded, targetPath, provider) ?? provider.GenerateFrom(loaded, targetPath, options); + } + + internal ProviderSelection Discover(LoadedCompilation loaded, string targetPath) + { + var matches = _providers.Where(_ => _.Matches(loaded)).ToArray(); + var superseded = matches.SelectMany(_ => _.Supersedes).ToHashSet(StringComparer.Ordinal); + matches = [.. matches.Where(_ => !superseded.Contains(_.Name))]; + + return matches.Length switch + { + 1 => new(matches[0], null), + 0 => new(null, ProviderError( + ScreenplayDiagnosticCodes.NoMatchingProvider, + $"No bundled Screenplay provider recognizes the loaded source. Available providers: {ProviderNames()}", + targetPath)), + _ => new(null, ProviderError( + ScreenplayDiagnosticCodes.AmbiguousProviders, + $"Several Screenplay providers recognize the loaded source: {string.Join(", ", matches.Select(_ => _.Name).Order(StringComparer.Ordinal))}. Select one with --provider", + targetPath)) + }; + } + + internal GeneratedScreenplay? AmbiguousHosts( + LoadedCompilation loaded, + string targetPath, + IScreenplaySourceProvider provider) + { + if (!provider.RequiresSingleHost || !ScreenplayTargetResolver.IsSolution(targetPath)) + { + return null; + } + + var hosts = loaded.Compilations + .Select((compilation, index) => new { EntryPoint = compilation.GetEntryPoint(CancellationToken.None), Name = loaded.ProjectNames[index] }) + .Where(_ => _.EntryPoint is not null) + .Select(_ => _.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + return hosts.Length <= 1 + ? null + : ProviderError( + ScreenplayDiagnosticCodes.AmbiguousApplicationHosts, + $"Solution contains several deployable application hosts: {string.Join(", ", hosts)}. Target one .csproj explicitly", + targetPath); + } + + string ProviderNames() => string.Join(", ", _providers.Select(_ => _.Name).Order(StringComparer.Ordinal)); + + GeneratedScreenplay InvalidProvider(string provider, string targetPath) => ProviderError( + ScreenplayDiagnosticCodes.InvalidProvider, + $"Unknown Screenplay provider '{provider}'. Available providers: {ProviderNames()}", + targetPath); + + GeneratedScreenplay ProviderError(string code, string message, string targetPath) => new( + string.Empty, + [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, code, message, targetPath)]); +} + +internal sealed record ProviderSelection( + IScreenplaySourceProvider? Provider, + GeneratedScreenplay? Error); diff --git a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs index dce1456..db8939c 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -46,14 +46,31 @@ public static void RegisterMSBuild() /// Cancellation token. /// The describing the outcome. [MethodImpl(MethodImplOptions.NoInlining)] - public static async Task Load(string targetPath, CancellationToken cancellationToken) + public static Task Load(string targetPath, CancellationToken cancellationToken) => + Load(targetPath, includeAllProjects: false, cancellationToken); + + /// + /// Loads the given solution or project and optionally retains every non-spec C# project for provider analysis. + /// + /// The full path of the solution or project file. + /// Whether solution projects should bypass Arc-specific artifact filtering. + /// Cancellation token. + /// The describing the outcome. + [MethodImpl(MethodImplOptions.NoInlining)] + public static async Task Load( + string targetPath, + bool includeAllProjects, + CancellationToken cancellationToken) { RegisterMSBuild(); - return await LoadWithWorkspace(targetPath, cancellationToken); + return await LoadWithWorkspace(targetPath, includeAllProjects, cancellationToken); } [MethodImpl(MethodImplOptions.NoInlining)] - static async Task LoadWithWorkspace(string targetPath, CancellationToken cancellationToken) + static async Task LoadWithWorkspace( + string targetPath, + bool includeAllProjects, + CancellationToken cancellationToken) { var failures = new List(); var failureLock = new Lock(); @@ -112,7 +129,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel failures); } - return await CompilationsOf(selected, isSolution, targetPath, failures, cancellationToken); + return await CompilationsOf(selected, isSolution, includeAllProjects, targetPath, failures, cancellationToken); } /// @@ -120,6 +137,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel /// /// The projects that take part, ordered by name. /// Whether a solution was opened rather than a single project. + /// Whether all selected projects should bypass Arc-specific artifact filtering. /// The full path of the solution or project file. /// Everything the workspace reported while loading. /// Cancellation token. @@ -134,6 +152,7 @@ static async Task LoadWithWorkspace(string targetPath, Cancel static async Task CompilationsOf( IReadOnlyList selected, bool isSolution, + bool includeAllProjects, string targetPath, IReadOnlyList failures, CancellationToken cancellationToken) @@ -161,7 +180,9 @@ static async Task CompilationsOf( continue; } - if (isSolution && !ScreenplayProjectSelection.CanDeclareAnArtifact(compilation)) + compilation = ScreenplayFrameworkReferences.AddMissingTo(project, compilation); + + if (isSolution && !includeAllProjects && !ScreenplayProjectSelection.CanDeclareAnArtifact(compilation)) { continue; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs index 93c9850..9866b1e 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -40,4 +40,29 @@ public static class ScreenplayDiagnosticCodes /// Every project loaded, and none of them can declare anything a Screenplay document is made of. /// public const string NoArtifacts = "CLI0006"; + + /// + /// The requested source framework provider is not recognized. + /// + public const string InvalidProvider = "CLI0007"; + + /// + /// Source compilation errors make the recovered semantic model untrustworthy. + /// + public const string SourceDidNotCompile = "CLI0008"; + + /// + /// A solution contains several deployable hosts and therefore does not identify one application. + /// + public const string AmbiguousApplicationHosts = "CLI0009"; + + /// + /// No bundled source provider recognizes the loaded application. + /// + public const string NoMatchingProvider = "CLI0010"; + + /// + /// More than one unrelated source provider recognizes the loaded application. + /// + public const string AmbiguousProviders = "CLI0011"; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayFrameworkReferences.cs b/Source/Cli/Commands/Screenplay/ScreenplayFrameworkReferences.cs new file mode 100644 index 0000000..9eb27f2 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayFrameworkReferences.cs @@ -0,0 +1,90 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.InteropServices; +using Microsoft.CodeAnalysis; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Restores target-framework reference assemblies that an MSBuild workspace can omit for projects targeting an +/// earlier installed .NET version than the CLI process. +/// +static class ScreenplayFrameworkReferences +{ + static readonly string[] _packs = ["Microsoft.NETCore.App.Ref", "Microsoft.AspNetCore.App.Ref"]; + + /// + /// Adds missing target-framework references to a project compilation. + /// + /// The workspace project. + /// The compilation produced by the workspace. + /// The original compilation when its framework is complete; otherwise, a compilation with references. + public static Compilation AddMissingTo(Project project, Compilation compilation) + { + if (compilation.GetSpecialType(SpecialType.System_Object).TypeKind != TypeKind.Error) + { + return compilation; + } + + var targetFramework = TargetFrameworkOf(project); + if (targetFramework is null) + { + return compilation; + } + + var existing = compilation.References + .Select(_ => Path.GetFileNameWithoutExtension(_.Display)) + .Where(_ => !string.IsNullOrEmpty(_)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var references = PackRoots() + .SelectMany(root => _packs.Select(pack => Path.Combine(root, pack))) + .Where(Directory.Exists) + .Select(pack => ReferenceDirectory(pack, targetFramework)) + .Where(_ => _ is not null) + .SelectMany(_ => Directory.EnumerateFiles(_!, "*.dll", SearchOption.TopDirectoryOnly)) + .Where(_ => existing.Add(Path.GetFileNameWithoutExtension(_))) + .Select(_ => MetadataReference.CreateFromFile(_)) + .ToArray(); + + return references.Length == 0 ? compilation : compilation.AddReferences(references); + } + + static string? TargetFrameworkOf(Project project) + { + var assemblyPath = project.CompilationOutputInfo.AssemblyPath; + if (!string.IsNullOrWhiteSpace(assemblyPath)) + { + var framework = new DirectoryInfo(Path.GetDirectoryName(assemblyPath)!).Name; + if (framework.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + { + return framework; + } + } + + var start = project.Name.LastIndexOf('('); + return start > 0 && project.Name.EndsWith(')') ? project.Name[(start + 1)..^1] : null; + } + + static IEnumerable PackRoots() + { + var runtime = new DirectoryInfo(RuntimeEnvironment.GetRuntimeDirectory()); + var dotnetRoot = runtime.Parent?.Parent?.Parent; + if (dotnetRoot is not null) + { + yield return Path.Combine(dotnetRoot.FullName, "packs"); + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(home)) + { + yield return Path.Combine(home, ".nuget", "packages"); + } + } + + static string? ReferenceDirectory(string pack, string targetFramework) => Directory.EnumerateDirectories(pack) + .Select(version => Path.Combine(version, "ref", targetFramework)) + .Where(Directory.Exists) + .OrderDescending(StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs index 272ece6..8b0880b 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs @@ -10,10 +10,16 @@ namespace Cratis.Cli.Commands.Screenplay; /// The module every discovered feature is placed within; falls back to the domain. /// The number of leading namespace segments to skip when inferring features and slices; uses the generator default. /// Whether each feature is placed in a module named after the outermost segment of its namespace rather than all of them in one module. -public record ScreenplayGenerationOptions(string? Domain, string? Module, int? SegmentsToSkip, bool ModulesFromNamespaceRoots = false) +/// The source framework provider to use or auto-detect. +public record ScreenplayGenerationOptions( + string? Domain, + string? Module, + int? SegmentsToSkip, + bool ModulesFromNamespaceRoots = false, + string Provider = ScreenplayProviders.Auto) { /// /// Gets the options that leave every choice to the generator. /// - public static ScreenplayGenerationOptions Default { get; } = new(null, null, null); + public static ScreenplayGenerationOptions Default { get; } = new(null, null, null, Provider: ScreenplayProviders.Auto); } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs index c698c6b..9fd4eb0 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs @@ -12,5 +12,5 @@ public static class ScreenplayGenerations /// Creates the generation to use. /// /// The to generate with. - public static IScreenplayGeneration Create() => new ArcScreenplayGeneration(); + public static IScreenplayGeneration Create() => new ProviderScreenplayGeneration(); } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProviders.cs b/Source/Cli/Commands/Screenplay/ScreenplayProviders.cs new file mode 100644 index 0000000..db6dc84 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayProviders.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Defines the source-framework providers available for Screenplay generation. +/// +public static class ScreenplayProviders +{ + /// + /// Detect the provider from loaded project compilations. + /// + public const string Auto = "auto"; + + /// + /// Generate from Arc and Chronicle conventions. + /// + public const string Arc = "arc"; + + /// + /// Generate from Marten conventions without requiring Wolverine. + /// + public const string Marten = "marten"; + + /// + /// Generate from combined Marten and Wolverine conventions. + /// + public const string CritterStack = "critter-stack"; + + static readonly HashSet _known = [Auto, Arc, Marten, CritterStack]; + + /// + /// Gets whether a provider name is recognized. + /// + /// The provider name. + /// when recognized; otherwise, . + public static bool IsKnown(string provider) => _known.Contains(provider); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplaySourceProviders.cs b/Source/Cli/Commands/Screenplay/ScreenplaySourceProviders.cs new file mode 100644 index 0000000..af30a52 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplaySourceProviders.cs @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Provides the allowlisted source-framework providers bundled with this CLI build. +/// +static class ScreenplaySourceProviders +{ + public static readonly IReadOnlyList Default = + [ + new ArcSourceProvider(), + new MartenSourceProvider(), + new CritterStackSourceProvider() + ]; +} + +static class ProviderEvidence +{ + public static bool HasMarten(Microsoft.CodeAnalysis.Compilation compilation) => + compilation.GetTypeByMetadataName("Marten.StoreOptions") is not null || + compilation.GetTypeByMetadataName("Marten.IDocumentStore") is not null; + + public static bool HasWolverine(Microsoft.CodeAnalysis.Compilation compilation) => + compilation.GetTypeByMetadataName("Wolverine.WolverineOptions") is not null; +} + +sealed class ArcSourceProvider : IScreenplaySourceProvider +{ + public string Name => ScreenplayProviders.Arc; + public IReadOnlyList Supersedes => []; + public bool RequiresSingleHost => false; + public bool Matches(LoadedCompilation loaded) => loaded.Compilations.Any(ScreenplayProjectSelection.CanDeclareAnArtifact); + public GeneratedScreenplay GenerateFrom(LoadedCompilation loaded, string targetPath, ScreenplayGenerationOptions options) => + ArcScreenplayGeneration.GenerateFrom(Narrow(loaded), targetPath, options); + + static LoadedCompilation Narrow(LoadedCompilation loaded) + { + var selected = loaded.Compilations + .Select((compilation, index) => new { Compilation = compilation, Name = loaded.ProjectNames[index] }) + .Where(_ => ScreenplayProjectSelection.CanDeclareAnArtifact(_.Compilation)) + .ToArray(); + return selected.Length == loaded.Compilations.Count + ? loaded + : new LoadedCompilation( + [.. selected.Select(_ => _.Compilation)], + [.. selected.Select(_ => _.Name)], + loaded.Diagnostics); + } +} + +sealed class MartenSourceProvider : IScreenplaySourceProvider +{ + public string Name => ScreenplayProviders.Marten; + public IReadOnlyList Supersedes => []; + public bool RequiresSingleHost => true; + public bool Matches(LoadedCompilation loaded) => loaded.Compilations.Any(ProviderEvidence.HasMarten); + public GeneratedScreenplay GenerateFrom(LoadedCompilation loaded, string targetPath, ScreenplayGenerationOptions options) => + CritterStackScreenplayGeneration.GenerateFrom(loaded, targetPath, options); +} + +sealed class CritterStackSourceProvider : IScreenplaySourceProvider +{ + public string Name => ScreenplayProviders.CritterStack; + public IReadOnlyList Supersedes => [ScreenplayProviders.Marten]; + public bool RequiresSingleHost => true; + public bool Matches(LoadedCompilation loaded) => loaded.Compilations.Any(_ => + ProviderEvidence.HasMarten(_) && ProviderEvidence.HasWolverine(_)); + public GeneratedScreenplay GenerateFrom(LoadedCompilation loaded, string targetPath, ScreenplayGenerationOptions options) => + CritterStackScreenplayGeneration.GenerateFrom(loaded, targetPath, options); +}