diff --git a/Directory.Packages.props b/Directory.Packages.props index d72a5c2..2ffc34f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,10 +5,10 @@ - - - - + + + + @@ -47,7 +47,7 @@ - + diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md index 947d634..f7bf74b 100644 --- a/Documentation/reference/screenplay.md +++ b/Documentation/reference/screenplay.md @@ -27,7 +27,7 @@ Pass `--file` to write it directly instead. The output is written as raw UTF-8, | Argument | Description | |---|---| -| `PATH` | Solution (`.slnx`, `.sln`), project (`.csproj`), or folder to read. Defaults to the current directory. | +| `PATH` | Solution (`.slnx`, `.sln`, `.slnf`), project (`.csproj`), or folder to read. Defaults to the current directory. | ### Options @@ -37,6 +37,7 @@ Pass `--file` to write it directly instead. The output is written as raw UTF-8, | `--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. | +| `--modules-from-namespace-roots` | Name the module of each feature after the outermost segment of its namespace, instead of placing every feature in one module. | The output file uses `--file` rather than `-o`, because `-o/--output` is the global output *format* flag — see [Global Options](global-options.md). @@ -47,11 +48,40 @@ cratis screenplay generate ./Source/MyApp/MyApp.csproj cratis screenplay generate --domain Library --module Lending --file Library.play ``` +### Naming the modules + +A document places every discovered feature in one module, named after the domain. That is right for an application that *is* one module, and wrong for one whose namespaces already say what its modules are — `Library.Authors`, `Library.Inventory`, `Library.Lending` come back as a single `module Library` holding three features. + +`--modules-from-namespace-roots` takes the module of each feature from the outermost segment of its namespace instead. When every slice shares a root namespace — as they do above — that outermost segment is the root, which names one module again, so pair it with `--skip-segments` to move the modules down to the segment that tells them apart: + +```bash +cratis screenplay generate --modules-from-namespace-roots --skip-segments 1 +``` + +```text +module Authors +module Inventory +module Lending +``` + +Naming a module with `--module` still collapses the document into that one, whichever of these is passed. + ### Finding the solution or project -When `PATH` is a solution or project file, that file is read. When it is a folder — or is omitted entirely — the CLI looks in that folder and then in each parent folder in turn, stopping at the first one that holds a match. Within a folder it prefers `.slnx`, then `.sln`, then `.csproj`. Two candidates of the same kind in one folder is reported rather than guessed at. +When `PATH` is a solution or project file, that file is read. When it is a folder — or is omitted entirely — the CLI looks in that folder and then in each parent folder in turn, stopping at the first one that holds a match. Within a folder it prefers `.slnx`, then `.sln`, then `.slnf`, then `.csproj`. Two candidates of the same kind in one folder is reported rather than guessed at. + +A solution filter (`.slnf`) is read as the solution it filters, which is how a repository holding more than one application points at the one to describe. -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 the ones whose name ends in `.Specs`, `.Specifications`, `.Tests`, `.Test`, or `.IntegrationTests`. +### Which projects take part + +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. +- **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. + +Pass a `.csproj` instead of the solution to describe a single project — pointing at a project is the instruction to read it, so it is read whatever it can see. The projects that were read are named in the result, so you can see what the document covers: @@ -59,8 +89,6 @@ The projects that were read are named in the result, so you can see what the doc Projects: Library.Api, Library.Domain, Library.ReadModels ``` -Pass a `.csproj` instead of the solution to describe a single project. - ### Diagnostics Anything the generator cannot express in Screenplay is reported rather than silently dropped — a projection operator with no counterpart, a validator rule that has no equivalent, a construct only available as compiled metadata because it lives in a referenced package. @@ -82,7 +110,15 @@ With `-o json` or `-o json-compact` the same diagnostics are written to standard ### Prerequisites -The command loads the project through MSBuild, so the **.NET SDK** must be installed — the same SDK you build the project with. Packages must be restorable; a project that cannot be restored cannot be read. +The command loads the project through MSBuild, so the **.NET SDK** must be installed — the same SDK you build the project with. + +**Packages must already be restored.** An unrestored project still loads, and yields a compilation in which every framework type reads as missing — which would be reported as a page of unrecognizable artifacts and a document describing nobody's application. It is reported as the one thing that is actually wrong instead: + +```text +errors (1): + error CLI0005: 'Library.Domain' has not been restored, so every type the application + references reads as missing — run 'dotnet restore' and generate again +``` The project does **not** have to have been built first. Sources MSBuild generates as part of a build — such as the strongly typed classes a `.resx` file declares with `MSBuild:Compile` — are produced while the project is read, so the model is derived from exactly what a real build compiles. @@ -93,8 +129,10 @@ The project does **not** have to have been built first. Sources MSBuild generate | `PATH` does not exist | Not-found error. | | `PATH` is a file that is not a solution or project | Not-found error. | | 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. | -| A project cannot be read into a compilation | Validation error naming it; the remaining projects are still described. | +| 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`). | +| 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. | 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 60f5568..135b77c 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 @@ -19,4 +19,9 @@ [Fact] void should_pass_them_to_the_generation() => _generation.Received(1).Gene Arg.Any(), Arg.Is(options => options.Domain == "Library" && options.Module == "Lending" && options.SegmentsToSkip == 2), Arg.Any()); + + [Fact] void should_leave_the_modules_named_by_one_name() => _generation.Received(1).Generate( + Arg.Any(), + Arg.Is(options => !options.ModulesFromNamespaceRoots), + Arg.Any()); } diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_modules_are_asked_of_the_namespace_roots.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_modules_are_asked_of_the_namespace_roots.cs new file mode 100644 index 0000000..118ba10 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_modules_are_asked_of_the_namespace_roots.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_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_the_modules_are_asked_of_the_namespace_roots : given.a_generate_screenplay_command +{ + void Establish() => _settings.ModulesFromNamespaceRoots = true; + + async Task Because() => await Execute(); + + [Fact] void should_pass_it_to_the_generation() => _generation.Received(1).Generate( + Arg.Any(), + Arg.Is(options => options.ModulesFromNamespaceRoots), + Arg.Any()); +} diff --git a/Source/Cli.Specs/for_ProjectRestoreState/given/a_project_folder.cs b/Source/Cli.Specs/for_ProjectRestoreState/given/a_project_folder.cs new file mode 100644 index 0000000..20812d7 --- /dev/null +++ b/Source/Cli.Specs/for_ProjectRestoreState/given/a_project_folder.cs @@ -0,0 +1,41 @@ +// 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_ProjectRestoreState.given; + +/// +/// A folder holding a project file, with nothing restored into it yet. +/// +public class a_project_folder : Specification +{ + protected string _folder; + protected string _project; + + void Establish() + { + _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _project = Path.Combine(_folder, "MyApp.csproj"); + File.WriteAllText(_project, ""); + } + + /// + /// Writes an assets file into the given folder, as a restore does. + /// + /// The intermediate output folder to restore into. + /// The full path of the assets file. + protected static string Restore(string folder) + { + Directory.CreateDirectory(folder); + var assets = Path.Combine(folder, ProjectRestoreState.AssetsFileName); + File.WriteAllText(assets, "{}"); + return assets; + } + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_neither_path_is_known.cs b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_neither_path_is_known.cs new file mode 100644 index 0000000..701847f --- /dev/null +++ b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_neither_path_is_known.cs @@ -0,0 +1,13 @@ +// 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_ProjectRestoreState.when_deciding_whether_a_project_is_restored; + +public class and_neither_path_is_known : Specification +{ + bool _result; + + void Because() => _result = ProjectRestoreState.IsRestored(null, null); + + [Fact] void should_take_the_project_for_restored_rather_than_invent_a_failure() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_nothing_has_been_restored.cs b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_nothing_has_been_restored.cs new file mode 100644 index 0000000..0519839 --- /dev/null +++ b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_nothing_has_been_restored.cs @@ -0,0 +1,13 @@ +// 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_ProjectRestoreState.when_deciding_whether_a_project_is_restored; + +public class and_nothing_has_been_restored : given.a_project_folder +{ + bool _result; + + void Because() => _result = ProjectRestoreState.IsRestored(_project, Path.Combine(_folder, "obj", "Debug", "net10.0", "MyApp.dll")); + + [Fact] void should_report_the_project_as_unrestored() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_the_assets_file_sits_beside_the_project.cs b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_the_assets_file_sits_beside_the_project.cs new file mode 100644 index 0000000..0219697 --- /dev/null +++ b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_the_assets_file_sits_beside_the_project.cs @@ -0,0 +1,15 @@ +// 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_ProjectRestoreState.when_deciding_whether_a_project_is_restored; + +public class and_the_assets_file_sits_beside_the_project : given.a_project_folder +{ + bool _result; + + void Establish() => Restore(Path.Combine(_folder, "obj")); + + void Because() => _result = ProjectRestoreState.IsRestored(_project, Path.Combine(_folder, "obj", "Debug", "net10.0", "MyApp.dll")); + + [Fact] void should_take_the_project_for_restored() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_the_intermediate_output_folder_was_moved.cs b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_the_intermediate_output_folder_was_moved.cs new file mode 100644 index 0000000..ed36cd3 --- /dev/null +++ b/Source/Cli.Specs/for_ProjectRestoreState/when_deciding_whether_a_project_is_restored/and_the_intermediate_output_folder_was_moved.cs @@ -0,0 +1,25 @@ +// 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_ProjectRestoreState.when_deciding_whether_a_project_is_restored; + +/// +/// The artifacts output layout of the SDK moves the intermediate output folder out of the project folder, so the +/// assets file is nowhere near the obj beside the project and only the assembly says where it went. +/// +public class and_the_intermediate_output_folder_was_moved : given.a_project_folder +{ + string _assembly; + bool _result; + + void Establish() + { + var intermediate = Path.Combine(_folder, "artifacts", "obj", "MyApp"); + Restore(intermediate); + _assembly = Path.Combine(intermediate, "debug", "MyApp.dll"); + } + + void Because() => _result = ProjectRestoreState.IsRestored(_project, _assembly); + + [Fact] void should_take_the_project_for_restored() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ProjectRestoreState/when_describing_what_was_not_restored/and_one_project_was_not.cs b/Source/Cli.Specs/for_ProjectRestoreState/when_describing_what_was_not_restored/and_one_project_was_not.cs new file mode 100644 index 0000000..7e5985d --- /dev/null +++ b/Source/Cli.Specs/for_ProjectRestoreState/when_describing_what_was_not_restored/and_one_project_was_not.cs @@ -0,0 +1,14 @@ +// 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_ProjectRestoreState.when_describing_what_was_not_restored; + +public class and_one_project_was_not : Specification +{ + string _result; + + void Because() => _result = ProjectRestoreState.MessageFor(["MyApp"]); + + [Fact] void should_name_the_project() => _result.ShouldContain("'MyApp' has not been restored"); + [Fact] void should_say_what_to_do_about_it() => _result.ShouldContain("dotnet restore"); +} diff --git a/Source/Cli.Specs/for_ProjectRestoreState/when_describing_what_was_not_restored/and_several_projects_were_not.cs b/Source/Cli.Specs/for_ProjectRestoreState/when_describing_what_was_not_restored/and_several_projects_were_not.cs new file mode 100644 index 0000000..435a66a --- /dev/null +++ b/Source/Cli.Specs/for_ProjectRestoreState/when_describing_what_was_not_restored/and_several_projects_were_not.cs @@ -0,0 +1,15 @@ +// 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_ProjectRestoreState.when_describing_what_was_not_restored; + +public class and_several_projects_were_not : Specification +{ + string _result; + + void Because() => _result = ProjectRestoreState.MessageFor(["MyApp", "MyApp.Domain", "MyApp.Read"]); + + [Fact] void should_name_the_first_project() => _result.ShouldContain("'MyApp'"); + [Fact] void should_count_the_rest_rather_than_list_them() => _result.ShouldContain("and 2 more have not been restored"); + [Fact] void should_say_what_to_do_about_it() => _result.ShouldContain("dotnet restore"); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_neither_attribute.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_neither_attribute.cs new file mode 100644 index 0000000..809e185 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_neither_attribute.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare; + +public class and_it_resolves_neither_attribute : given.a_compilation_built_from_source +{ + bool _result; + + void Because() => _result = ScreenplayProjectSelection.CanDeclareAnArtifact( + Holding("namespace MyApp.Analyzers { public class Rule { } }")); + + [Fact] void should_leave_it_out_of_the_application() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_the_command_attribute.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_the_command_attribute.cs new file mode 100644 index 0000000..42496c8 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_the_command_attribute.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare; + +public class and_it_resolves_the_command_attribute : given.a_compilation_built_from_source +{ + bool _result; + + void Because() => _result = ScreenplayProjectSelection.CanDeclareAnArtifact( + Holding("namespace Cratis.Arc.Commands.ModelBound { public class CommandAttribute { } }")); + + [Fact] void should_take_it_for_part_of_the_application() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_the_event_type_attribute.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_the_event_type_attribute.cs new file mode 100644 index 0000000..eae5331 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/and_it_resolves_the_event_type_attribute.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare; + +public class and_it_resolves_the_event_type_attribute : given.a_compilation_built_from_source +{ + bool _result; + + void Because() => _result = ScreenplayProjectSelection.CanDeclareAnArtifact( + Holding("namespace Cratis.Chronicle.Events { public class EventTypeAttribute { } }")); + + [Fact] void should_take_it_for_part_of_the_application() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/given/a_compilation_built_from_source.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/given/a_compilation_built_from_source.cs new file mode 100644 index 0000000..8ca1819 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_asking_what_a_compilation_can_declare/given/a_compilation_built_from_source.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_ScreenplayProjectSelection.when_asking_what_a_compilation_can_declare.given; + +/// +/// Builds a compilation from source, so that what it can resolve is stated rather than restored. +/// +public class a_compilation_built_from_source : Specification +{ + /// + /// Builds a compilation holding the given source. + /// + /// The source the compilation is built from. + /// The . + protected static Compilation Holding(string source) => + CSharpCompilation.Create( + "Project", + [CSharpSyntaxTree.ParseText(source)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_a_multi_targeted_application_project.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_a_multi_targeted_application_project.cs new file mode 100644 index 0000000..87ed7ef --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_a_multi_targeted_application_project.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayProjectSelection.when_deciding_whether_a_project_holds_specs; + +public class and_it_is_a_multi_targeted_application_project : Specification +{ + bool _result; + + void Because() => _result = ScreenplayProjectSelection.IsSpecProject("MyApp.Domain(netstandard2.0)"); + + [Fact] void should_take_it_for_part_of_the_application() => _result.ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_a_multi_targeted_specs_project.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_a_multi_targeted_specs_project.cs new file mode 100644 index 0000000..cf73024 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_a_multi_targeted_specs_project.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayProjectSelection.when_deciding_whether_a_project_holds_specs; + +public class and_it_is_a_multi_targeted_specs_project : Specification +{ + bool _result; + + void Because() => _result = ScreenplayProjectSelection.IsSpecProject("MyApp.Specs(net10.0)"); + + [Fact] void should_recognize_it_as_specs() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_the_host_the_specs_start_the_application_in.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_the_host_the_specs_start_the_application_in.cs new file mode 100644 index 0000000..9e32f13 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_deciding_whether_a_project_holds_specs/and_it_is_the_host_the_specs_start_the_application_in.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayProjectSelection.when_deciding_whether_a_project_holds_specs; + +public class and_it_is_the_host_the_specs_start_the_application_in : Specification +{ + bool _result; + + void Because() => _result = ScreenplayProjectSelection.IsSpecProject("MyApp.Specs.AppHost"); + + [Fact] void should_recognize_it_as_specs() => _result.ShouldBeTrue(); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_a_multi_targeted_specs_project_is_present.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_a_multi_targeted_specs_project_is_present.cs new file mode 100644 index 0000000..954e117 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_a_multi_targeted_specs_project_is_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_ScreenplayProjectSelection.when_narrowing; + +public class and_a_multi_targeted_specs_project_is_present : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayProjectSelection.Narrow( + [ + "MyApp(net10.0)", + "MyApp.Specs(net10.0)", + "MyApp.Specs(net9.0)" + ]); + + [Fact] void should_keep_only_the_project_that_is_not_specs() => _result.ShouldContainOnly(["MyApp"]); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_a_project_is_multi_targeted.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_a_project_is_multi_targeted.cs new file mode 100644 index 0000000..32dd63d --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_a_project_is_multi_targeted.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_ScreenplayProjectSelection.when_narrowing; + +public class and_a_project_is_multi_targeted : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayProjectSelection.Narrow( + [ + "MyApp(net10.0)", + "MyApp(netstandard2.0)" + ]); + + [Fact] void should_take_the_target_frameworks_for_one_project() => _result.ShouldContainOnly(["MyApp"]); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_specs_start_the_application_in_a_host.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_specs_start_the_application_in_a_host.cs new file mode 100644 index 0000000..d15088b --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_the_specs_start_the_application_in_a_host.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_ScreenplayProjectSelection.when_narrowing; + +public class and_the_specs_start_the_application_in_a_host : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayProjectSelection.Narrow( + [ + "MyApp", + "MyApp.Specs", + "MyApp.Specs.AppHost" + ]); + + [Fact] void should_leave_the_host_out_along_with_the_specs() => _result.ShouldContainOnly(["MyApp"]); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_name_is_nothing_but_a_framework.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_name_is_nothing_but_a_framework.cs new file mode 100644 index 0000000..f7dfbf2 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_name_is_nothing_but_a_framework.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayProjectSelection.when_removing_the_target_framework; + +public class and_the_name_is_nothing_but_a_framework : Specification +{ + string _result; + + void Because() => _result = ScreenplayProjectSelection.WithoutTargetFramework("(net10.0)"); + + [Fact] void should_leave_the_name_untouched_rather_than_leave_nothing() => _result.ShouldEqual("(net10.0)"); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_project_is_multi_targeted.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_project_is_multi_targeted.cs new file mode 100644 index 0000000..3892542 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_project_is_multi_targeted.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayProjectSelection.when_removing_the_target_framework; + +public class and_the_project_is_multi_targeted : Specification +{ + string _result; + + void Because() => _result = ScreenplayProjectSelection.WithoutTargetFramework("MyApp.Specs(net10.0)"); + + [Fact] void should_leave_the_name_of_the_project() => _result.ShouldEqual("MyApp.Specs"); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_project_targets_one_framework.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_project_targets_one_framework.cs new file mode 100644 index 0000000..5e98ca4 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_removing_the_target_framework/and_the_project_targets_one_framework.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayProjectSelection.when_removing_the_target_framework; + +public class and_the_project_targets_one_framework : Specification +{ + string _result; + + void Because() => _result = ScreenplayProjectSelection.WithoutTargetFramework("MyApp"); + + [Fact] void should_leave_the_name_untouched() => _result.ShouldEqual("MyApp"); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution_and_a_filter.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution_and_a_filter.cs new file mode 100644 index 0000000..214cb18 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution_and_a_filter.cs @@ -0,0 +1,21 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_the_folder_holds_a_solution_and_a_filter : given.a_temporary_folder +{ + string _solution; + ScreenplayTarget _result; + + void Establish() + { + _solution = Path.Combine(_folder, "MyApp.slnx"); + File.WriteAllText(_solution, ""); + File.WriteAllText(Path.Combine(_folder, "MyApp.slnf"), "{}"); + } + + void Because() => _result = ScreenplayTargetResolver.Resolve(null, _folder); + + [Fact] void should_prefer_the_whole_solution_over_one_view_of_it() => _result.Path.ShouldEqual(_solution); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_solution_filter.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_solution_filter.cs new file mode 100644 index 0000000..0bb447d --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_solution_filter.cs @@ -0,0 +1,22 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_the_path_is_a_solution_filter : given.a_temporary_folder +{ + string _filter; + ScreenplayTarget _result; + + void Establish() + { + _filter = Path.Combine(_folder, "MyApp.slnf"); + File.WriteAllText(_filter, "{}"); + } + + void Because() => _result = ScreenplayTargetResolver.Resolve("MyApp.slnf", _folder); + + [Fact] void should_resolve() => _result.IsResolved.ShouldBeTrue(); + [Fact] void should_resolve_the_filter() => _result.Path.ShouldEqual(_filter); + [Fact] void should_read_it_as_the_solution_it_filters() => ScreenplayTargetResolver.IsSolution(_filter).ShouldBeTrue(); +} diff --git a/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs index 95399ab..f3c187c 100644 --- a/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs +++ b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs @@ -30,7 +30,8 @@ public async Task Generate(string targetPath, ScreenplayGen { Domain = options.Domain ?? DomainFrom(targetPath, loaded), Module = options.Module, - SegmentsToSkip = options.SegmentsToSkip + SegmentsToSkip = options.SegmentsToSkip, + ModulesFromNamespaceRoots = options.ModulesFromNamespaceRoots }); return new GeneratedScreenplay( diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs index cfcd779..7b81c98 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs @@ -12,11 +12,13 @@ namespace Cratis.Cli.Commands.Screenplay; [CliExample("screenplay", "generate")] [CliExample("screenplay", "generate", "./MyApp.slnx", "--file", "MyApp.play")] [CliExample("screenplay", "generate", "./Source/MyApp/MyApp.csproj")] -[LlmOption("[PATH]", "string", "Solution (.slnx, .sln), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] +[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("--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.")] +[LlmOption("--modules-from-namespace-roots", "bool", "Name the module of each feature after the outermost segment of its namespace, instead of placing every feature in one module. Combine with --skip-segments when every slice shares a root namespace.")] [LlmOutputAdvice("json-compact", "The .play document always goes to standard output verbatim; the format only shapes the summary and the diagnostics, and json-compact makes the diagnostics machine-readable on standard error.")] public class GenerateScreenplayCommand : AsyncCommand { diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs index 7ee3c8d..cc3dd1c 100644 --- a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs @@ -12,7 +12,7 @@ public class GenerateScreenplaySettings : GlobalSettings /// Gets or sets the solution, project, or folder to generate from. /// [CommandArgument(0, "[PATH]")] - [Description("Solution (.slnx, .sln), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] + [Description("Solution (.slnx, .sln, .slnf), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] public string? Path { get; set; } /// @@ -46,9 +46,26 @@ public class GenerateScreenplaySettings : GlobalSettings [Description("Number of leading namespace segments to skip when inferring features and slices.")] public int? SkipSegments { get; set; } + /// + /// Gets or sets a value indicating whether each feature is placed in a module named after the outermost segment + /// of its namespace. + /// + /// + /// An application whose namespaces already name its modules otherwise comes back as one module holding every + /// feature, because a module is only taken from the namespaces when nothing else could name one. This asks for + /// it. Naming a module with --module still collapses the document into that one. + /// + /// The outermost segment is regularly the root namespace every slice shares, which names one module again — + /// --skip-segments 1 then moves the modules down to the segment that tells them apart. + /// + /// + [CommandOption("--modules-from-namespace-roots")] + [Description("Name the module of each feature after the outermost segment of its namespace, instead of placing every feature in one module. Combine with --skip-segments when every slice shares a root namespace.")] + public bool ModulesFromNamespaceRoots { get; set; } + /// /// Gets the generation options these settings describe. /// /// The . - public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments); + public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments, ModulesFromNamespaceRoots); } diff --git a/Source/Cli/Commands/Screenplay/ProjectRestoreState.cs b/Source/Cli/Commands/Screenplay/ProjectRestoreState.cs new file mode 100644 index 0000000..ae055aa --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ProjectRestoreState.cs @@ -0,0 +1,96 @@ +// 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; + +/// +/// Tells whether the projects a Screenplay is generated from have been restored. +/// +/// +/// An unrestored project still opens and still yields a compilation — one with no package reference resolved, in +/// which every framework type the application uses reads as missing. The generator then reports a page of artifacts +/// it cannot recognize and writes a document describing nobody's application, and none of it says the one thing that +/// is actually wrong. Asking for the assets file up front turns the consequences back into the cause. +/// +public static class ProjectRestoreState +{ + /// + /// The file a restore writes into the intermediate output folder of a project. + /// + public const string AssetsFileName = "project.assets.json"; + + const string DefaultIntermediateFolderName = "obj"; + const int LevelsAboveIntermediateAssembly = 4; + + /// + /// Determines whether the given project has been restored. + /// + /// The full path of the project file. + /// The full path of the assembly the project compiles into its intermediate output folder. + /// when the project has been restored, and when it cannot be told. + /// + /// A project neither path says anything about is taken for restored — this exists to explain a failure and must + /// never invent one. + /// + public static bool IsRestored(string? projectFilePath, string? intermediateAssemblyPath) + { + var folders = FoldersHoldingTheAssetsFile(projectFilePath, intermediateAssemblyPath); + return folders.Count == 0 || folders.Exists(folder => File.Exists(Path.Combine(folder, AssetsFileName))); + } + + /// + /// Builds the message reported for projects that have not been restored. + /// + /// The names of the projects that have not been restored, at least one, ordered. + /// The message. + public static string MessageFor(IReadOnlyList projectNames) => + $"{Describe(projectNames)} not been restored, so every type the application references reads as missing — run 'dotnet restore' and generate again"; + + /// + /// Gets the folders the assets file of a project could sit in. + /// + /// The full path of the project file. + /// The full path of the assembly the project compiles into its intermediate output folder. + /// The folders to look in, empty when neither path says anything. + /// + /// The assets file sits at the root of the intermediate output folder, which is obj beside the project + /// until something moves it — the artifacts output layout of the SDK moves it out of the project folder + /// altogether. Where it went is only knowable from the assembly the project compiles into it, and the assets + /// file sits above the configuration and target framework folders that assembly is in, so every folder on the + /// way up from it is a place the assets file could be. + /// + static List FoldersHoldingTheAssetsFile(string? projectFilePath, string? intermediateAssemblyPath) + { + var folders = new List(); + + if (FolderOf(projectFilePath) is { } project) + { + folders.Add(Path.Combine(project, DefaultIntermediateFolderName)); + } + + var current = FolderOf(intermediateAssemblyPath); + for (var level = 0; current is not null && level < LevelsAboveIntermediateAssembly; level++) + { + folders.Add(current); + current = Path.GetDirectoryName(current); + } + + return folders; + } + + static string? FolderOf(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var folder = Path.GetDirectoryName(path); + return string.IsNullOrEmpty(folder) ? null : folder; + } + + static string Describe(IReadOnlyList projectNames) => + projectNames.Count == 1 + ? $"'{projectNames[0]}' has" + : $"'{projectNames[0]}' and {projectNames.Count - 1} more have"; +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs index ca5e95b..dce1456 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -71,14 +71,20 @@ static async Task LoadWithWorkspace(string targetPath, Cancel } }); - var projects = ScreenplayTargetResolver.IsSolution(targetPath) + var isSolution = ScreenplayTargetResolver.IsSolution(targetPath); + var projects = isSolution ? (await workspace.OpenSolutionAsync(targetPath, cancellationToken: cancellationToken)).Projects : [await workspace.OpenProjectAsync(targetPath, cancellationToken: cancellationToken)]; + // A multi-targeted project is opened once per target framework and every result holds the same application, + // so the names are grouped without the framework each one carries and one of them is read. var byName = projects .Where(project => project.Language == LanguageNames.CSharp) - .GroupBy(project => project.Name, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + .GroupBy(project => ScreenplayProjectSelection.WithoutTargetFramework(project.Name), StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.OrderBy(project => project.Name, StringComparer.Ordinal).First(), + StringComparer.Ordinal); var narrowed = ScreenplayProjectSelection.Narrow(byName.Keys); if (narrowed.Count == 0) @@ -90,6 +96,48 @@ static async Task LoadWithWorkspace(string targetPath, Cancel failures); } + var selected = narrowed.Select(name => byName[name]).ToArray(); + + var unrestored = selected + .Where(project => !ProjectRestoreState.IsRestored(project.FilePath, project.CompilationOutputInfo.AssemblyPath)) + .Select(project => ScreenplayProjectSelection.WithoutTargetFramework(project.Name)) + .ToArray(); + + if (unrestored.Length > 0) + { + return LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.RestoreRequired, + ProjectRestoreState.MessageFor(unrestored), + targetPath, + failures); + } + + return await CompilationsOf(selected, isSolution, targetPath, failures, cancellationToken); + } + + /// + /// Turns the selected projects into the compilations to generate from. + /// + /// The projects that take part, ordered by name. + /// Whether a solution was opened rather than a single project. + /// The full path of the solution or project file. + /// Everything the workspace reported while loading. + /// Cancellation token. + /// The describing the outcome. + /// + /// A project of a solution that cannot declare a single artifact is left out silently — a solution regularly + /// holds an analyzer, a build-time tool or a code-generation project beside the application, and none of them + /// is anything the reader has to be told about. A project the command was pointed at directly is read whatever + /// it can see, because pointing at it is the instruction to read it. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task CompilationsOf( + IReadOnlyList selected, + bool isSolution, + string targetPath, + IReadOnlyList failures, + CancellationToken cancellationToken) + { var compilations = new List(); var names = new List(); @@ -98,31 +146,43 @@ static async Task LoadWithWorkspace(string targetPath, Cancel // missing part of the application it names is exactly what nobody notices on their own. var unloadable = new List(); - foreach (var name in narrowed) + foreach (var loaded in selected) { - var project = GeneratedResourceSources.AddMissingTo(byName[name]); + var project = GeneratedResourceSources.AddMissingTo(loaded); + var name = ScreenplayProjectSelection.WithoutTargetFramework(project.Name); var compilation = await project.GetCompilationAsync(cancellationToken); if (compilation is null) { unloadable.Add(new ScreenplayDiagnostic( ScreenplayDiagnosticSeverity.Error, ScreenplayDiagnosticCodes.NoCompilation, - $"No compilation could be created for '{project.Name}', which is therefore not part of the document", + $"No compilation could be created for '{name}', which is therefore not part of the document", project.FilePath ?? targetPath)); continue; } + if (isSolution && !ScreenplayProjectSelection.CanDeclareAnArtifact(compilation)) + { + continue; + } + compilations.Add(compilation); - names.Add(project.Name); + names.Add(name); } if (compilations.Count == 0) { - return LoadedCompilation.Failed( - ScreenplayDiagnosticCodes.NoCompilation, - $"No compilation could be created for any project in '{targetPath}'", - targetPath, - failures); + return unloadable.Count == 0 + ? LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.NoArtifacts, + $"No project in '{targetPath}' can declare a command or an event type, so there is nothing to generate a Screenplay from", + targetPath, + failures) + : LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.NoCompilation, + $"No compilation could be created for any project in '{targetPath}'", + targetPath, + failures); } return new LoadedCompilation(compilations, names, [.. failures, .. unloadable]); diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs index 1b202a7..93c9850 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -30,4 +30,14 @@ public static class ScreenplayDiagnosticCodes /// A project loaded but Roslyn could not produce a compilation for it, so it is not part of the document. /// public const string NoCompilation = "CLI0004"; + + /// + /// A project has not been restored, so nothing it references can be resolved. + /// + public const string RestoreRequired = "CLI0005"; + + /// + /// Every project loaded, and none of them can declare anything a Screenplay document is made of. + /// + public const string NoArtifacts = "CLI0006"; } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs index c061c56..272ece6 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs @@ -9,7 +9,8 @@ namespace Cratis.Cli.Commands.Screenplay; /// The domain the document belongs to; lets the generator derive it from the compilation. /// 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. -public record ScreenplayGenerationOptions(string? Domain, string? Module, int? SegmentsToSkip) +/// 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) { /// /// Gets the options that leave every choice to the generator. diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs b/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs index 43d6227..6a92a75 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs @@ -1,6 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Arc.Screenplay.Analysis; +using Microsoft.CodeAnalysis; + namespace Cratis.Cli.Commands.Screenplay; /// @@ -8,21 +11,30 @@ namespace Cratis.Cli.Commands.Screenplay; /// /// /// A Screenplay describes one application, and an application is regularly split across several projects, so every -/// project that remains takes part in the same document. Spec projects are dropped — they describe the application -/// rather than being part of it. +/// project that remains takes part in the same document. Whether a project could hold part of it is asked of what it +/// can see rather than of what it is called: every artifact is declared with an attribute the framework ships, so a +/// project resolving neither the Arc nor the Chronicle one cannot declare a single thing the document is made of. +/// +/// Spec projects are turned away separately and by name, because nothing about what a spec project can see tells it +/// apart — it references the same framework the application does, which is the whole point of it. They describe the +/// application rather than being part of it. +/// /// public static class ScreenplayProjectSelection { - static readonly string[] _specNames = ["Specs", "Specifications", "Tests", "Test", "IntegrationTests"]; + static readonly string[] _specNames = ["Specs", "Specifications", "Tests", "Test", "IntegrationTests", "Specs.AppHost"]; + static readonly string[] _artifacts = [WellKnownTypeNames.CommandAttribute, WellKnownTypeNames.EventTypeAttribute]; /// /// Narrows the projects down to the ones the Screenplay is generated from. /// /// The names of the projects found in the solution. - /// The remaining project names, ordered by name. + /// The remaining project names without their target framework, each one once, ordered by name. public static IReadOnlyList Narrow(IEnumerable projectNames) => [.. projectNames + .Select(WithoutTargetFramework) .Where(name => !IsSpecProject(name)) + .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal)]; /// @@ -33,12 +45,55 @@ [.. projectNames /// /// The last segment of the name decides, so a project called Specs is recognized as readily as /// MyApp.Specs — a solution that groups its integration specs in a folder regularly names the project - /// just that, and taking it for part of the application puts test-only artifacts in the document. + /// just that, and taking it for part of the application puts test-only artifacts in the document. The host an + /// integration spec starts the application in is named for the specs it serves and is turned away with them. /// - public static bool IsSpecProject(string name) => - Array.Exists( + public static bool IsSpecProject(string name) + { + var project = WithoutTargetFramework(name); + return Array.Exists( _specNames, spec => - name.Equals(spec, StringComparison.OrdinalIgnoreCase) || - name.EndsWith($".{spec}", StringComparison.OrdinalIgnoreCase)); + project.Equals(spec, StringComparison.OrdinalIgnoreCase) || + project.EndsWith($".{spec}", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Removes the target framework that a multi-targeted project carries in the name a workspace gives it. + /// + /// The project name as the workspace reports it. + /// The name of the project itself. + /// + /// A workspace opens a multi-targeted project once per target framework and tells the results apart by appending + /// the framework to the name — MyApp.Specs(net10.0). Every question asked here is about the project rather + /// than about one of its target frameworks, and asking them of the decorated name gets both answers wrong: the + /// spec project is no longer recognized as one, and the several compilations of a single project are taken for + /// several projects and every one of them is read into the same document. + /// + public static string WithoutTargetFramework(string name) + { + if (!name.EndsWith(')')) + { + return name; + } + + var framework = name.LastIndexOf('('); + return framework > 0 ? name[..framework] : name; + } + + /// + /// Determines whether a compilation could declare any of what a Screenplay document is made of. + /// + /// The compilation to ask. + /// when it could. + /// + /// Every artifact is declared with an attribute the framework ships, so resolving one of those attributes is the + /// least a project has to be able to do to hold part of the application. A Roslyn analyzer, a build-time tool or + /// a code-generation project sitting beside the application resolves neither, and reading one in as though it + /// were part of the application says something about the solution that is not true. What this misses is a + /// project that can see the framework and declares nothing — a host wiring the application up — which is read + /// and contributes nothing. + /// + public static bool CanDeclareAnArtifact(Compilation compilation) => + Array.Exists(_artifacts, artifact => compilation.GetTypeByMetadataName(artifact) is not null); } diff --git a/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs b/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs index d9639bc..826d164 100644 --- a/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs +++ b/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs @@ -6,9 +6,15 @@ namespace Cratis.Cli.Commands.Screenplay; /// /// Resolves the solution or project file a Screenplay is generated from. /// +/// +/// A solution filter is read as the solution it filters — naming a subset of a large repository is exactly how one +/// application within it is pointed at. It is discovered after the solutions themselves, because a folder holding +/// both is holding the whole application and one view of it. +/// public static class ScreenplayTargetResolver { - static readonly string[] _extensions = [".slnx", ".sln", ".csproj"]; + static readonly string[] _extensions = [".slnx", ".sln", ".slnf", ".csproj"]; + static readonly string[] _solutionExtensions = [".slnx", ".sln", ".slnf"]; /// /// Gets the file extensions that identify a solution or project the generator can read, in discovery order. @@ -59,12 +65,8 @@ public static bool IsSupportedFile(string path) => /// /// The file path to check. /// when the file is a solution. - public static bool IsSolution(string path) - { - var extension = Path.GetExtension(path); - return string.Equals(extension, ".sln", StringComparison.OrdinalIgnoreCase) || - string.Equals(extension, ".slnx", StringComparison.OrdinalIgnoreCase); - } + public static bool IsSolution(string path) => + _solutionExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); static ScreenplayTarget Discover(string directory) {