Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Cratis -->
<PackageVersion Include="Cratis.Arc.Screenplay" Version="20.65.0" />
<PackageVersion Include="Cratis.Arc.Screenplay" Version="20.65.3" />
<PackageVersion Include="Cratis.Chronicle.Connections" Version="16.0.6" />
<PackageVersion Include="Cratis.Chronicle.Contracts" Version="16.0.6" />
<PackageVersion Include="Cratis.Fundamentals" Version="7.16.6" />
Expand Down
17 changes: 11 additions & 6 deletions Documentation/reference/screenplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Pass `--file` to write it directly instead. The output is written as raw UTF-8,
| Option | Description |
|---|---|
| `--file <FILE>` | File to write the generated Screenplay to. Writes to standard output when not given. |
| `--domain <NAME>` | Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project. |
| `--domain <NAME>` | 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>` | Name of the module every discovered feature is placed within. Defaults to the domain. |
| `--skip-segments <COUNT>` | Number of leading namespace segments to skip when inferring features and slices. |

Expand All @@ -51,11 +51,15 @@ cratis screenplay generate --domain Library --module Lending --file Library.play

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.

A Screenplay describes one application, so a solution has to narrow down to a single project:
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`.

1. Projects whose name ends in `.Specs`, `.Specifications`, `.Tests`, `.Test`, or `.IntegrationTests` are dropped.
2. If any project that remains produces an executable, the libraries are dropped — an Arc application is the executable.
3. Exactly one project must remain. Anything else is reported, listing the candidates, so you can pass the project you meant.
The projects that were read are named in the result, so you can see what the document covers:

```text
Projects: Library.Api, Library.Domain, Library.ReadModels
```

Pass a `.csproj` instead of the solution to describe a single project.

### Diagnostics

Expand Down Expand Up @@ -89,7 +93,8 @@ 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 more than one candidate project | Validation error listing the candidates. |
| 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. |
| 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. |

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ namespace Cratis.Cli.for_ScreenplayProjectSelection.when_narrowing;

public class and_spec_projects_are_present : Specification
{
string? _result;
IReadOnlyList<string> _result;

void Because() => _result = ScreenplayProjectSelection.Select(
void Because() => _result = ScreenplayProjectSelection.Narrow(
[
new ScreenplayProjectCandidate("MyApp", false),
new ScreenplayProjectCandidate("MyApp.Specs", false),
new ScreenplayProjectCandidate("MyApp.Tests", false)
"MyApp",
"MyApp.Specs",
"MyApp.Tests"
]);

[Fact] void should_select_the_only_project_that_is_not_specs() => _result.ShouldEqual("MyApp");
[Fact] void should_keep_only_the_project_that_is_not_specs() => _result.ShouldContainOnly(["MyApp"]);
}
Original file line number Diff line number Diff line change
@@ -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_ScreenplayProjectSelection.when_narrowing;

public class and_the_application_is_split_across_projects : Specification
{
IReadOnlyList<string> _result;

void Because() => _result = ScreenplayProjectSelection.Narrow(
[
"MyApp.Domain",
"MyApp.Api",
"MyApp.Read"
]);

[Fact] void should_keep_every_project() => _result.Count.ShouldEqual(3);
[Fact] void should_order_the_projects_by_name() => _result[0].ShouldEqual("MyApp.Api");
[Fact] void should_keep_the_libraries_alongside_the_executable() => _result[1].ShouldEqual("MyApp.Domain");
[Fact] void should_keep_the_last_project_too() => _result[2].ShouldEqual("MyApp.Read");
}
Original file line number Diff line number Diff line change
@@ -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_project_is_named_without_a_prefix : Specification
{
IReadOnlyList<string> _result;

void Because() => _result = ScreenplayProjectSelection.Narrow(
[
"Core",
"Specs",
"Tests"
]);

[Fact] void should_keep_only_the_project_that_is_not_specs() => _result.ShouldContainOnly(["Core"]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ namespace Cratis.Cli.for_ScreenplayProjectSelection.when_narrowing;

public class and_there_are_only_spec_projects : Specification
{
IReadOnlyList<ScreenplayProjectCandidate> _result;
IReadOnlyList<string> _result;

void Because() => _result = ScreenplayProjectSelection.Narrow(
[
new ScreenplayProjectCandidate("MyApp.Specs", false),
new ScreenplayProjectCandidate("MyApp.IntegrationTests", true)
"MyApp.Specs",
"MyApp.IntegrationTests"
]);

[Fact] void should_leave_nothing_to_generate_from() => _result.ShouldBeEmpty();
Expand Down

This file was deleted.

27 changes: 22 additions & 5 deletions Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace Cratis.Cli.Commands.Screenplay;

/// <summary>
/// Generates a Screenplay document by loading the target into a Roslyn compilation and handing it to the
/// Generates a Screenplay document by loading the target into Roslyn compilations and handing them to the
/// <c>Cratis.Arc.Screenplay</c> generator.
/// </summary>
/// <remarks>
Expand All @@ -19,25 +19,42 @@ public sealed class ArcScreenplayGeneration : IScreenplayGeneration
public async Task<GeneratedScreenplay> Generate(string targetPath, ScreenplayGenerationOptions options, CancellationToken cancellationToken)
{
var loaded = await ScreenplayCompilationLoader.Load(targetPath, cancellationToken);
if (loaded.Compilation is null)
if (loaded.Compilations.Count == 0)
{
return new GeneratedScreenplay(string.Empty, loaded.Diagnostics);
}

var result = new ScreenplayGenerator().Generate(
loaded.Compilation,
loaded.Compilations,
new ScreenplayOptions
{
Domain = options.Domain,
Domain = options.Domain ?? DomainFrom(targetPath, loaded),
Module = options.Module,
SegmentsToSkip = options.SegmentsToSkip
});

return new GeneratedScreenplay(
result.Source,
[.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)]);
[.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)])
{
Projects = loaded.ProjectNames
};
}

/// <summary>
/// Gets the domain to use when none was given.
/// </summary>
/// <param name="targetPath">The solution or project that was read.</param>
/// <param name="loaded">What was loaded from it.</param>
/// <returns>The domain name, or <see langword="null"/> to leave the choice to the generator.</returns>
/// <remarks>
/// The generator names the domain after the assembly, which it can only do when it read exactly one — several
/// projects have no single assembly to name, and the fallback name describes nobody's application. The solution
/// is the name the application already goes by, and <c>--domain</c> still overrides it.
/// </remarks>
static string? DomainFrom(string targetPath, LoadedCompilation loaded) =>
loaded.Compilations.Count > 1 ? Path.GetFileNameWithoutExtension(targetPath) : null;

static ScreenplayDiagnostic Map(Cratis.Arc.Screenplay.ScreenplayDiagnostic diagnostic) =>
new(
(ScreenplayDiagnosticSeverity)(int)diagnostic.Severity,
Expand Down
4 changes: 4 additions & 0 deletions Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,18 @@ static void WriteResult(string format, string outputPath, string targetPath, Gen
{
Path = outputPath,
Source = targetPath,
generated.Projects,
Lines = CountLines(generated.Source),
Diagnostics = generated.Diagnostics.Count
},
result =>
{
// Which projects took part is the difference between a document describing the whole application
// and one describing part of it, so the panel says so rather than only naming what was read.
var content = new Markup(
$"[bold]{result.Path.EscapeMarkup()}[/]\n" +
$"Source: {result.Source.EscapeMarkup()}\n" +
$"Projects: {string.Join(", ", result.Projects).EscapeMarkup()}\n" +
$"Lines: {result.Lines}\n" +
$"Diagnostics: {result.Diagnostics}");
var panel = new Panel(content)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class GenerateScreenplaySettings : GlobalSettings
/// Gets or sets the domain the generated document belongs to.
/// </summary>
[CommandOption("--domain <NAME>")]
[Description("Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project.")]
[Description("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.")]
public string? Domain { get; set; }

/// <summary>
Expand Down
9 changes: 9 additions & 0 deletions Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ namespace Cratis.Cli.Commands.Screenplay;
/// <param name="Diagnostics">Everything the generator could not fully express, in the order it was reported.</param>
public record GeneratedScreenplay(string Source, IReadOnlyList<ScreenplayDiagnostic> Diagnostics)
{
/// <summary>
/// Gets the names of the projects the document was generated from.
/// </summary>
/// <remarks>
/// An application is regularly split across several projects, so which ones took part is the difference between
/// a document that describes all of it and one that describes half of it.
/// </remarks>
public IReadOnlyList<string> Projects { get; init; } = [];

/// <summary>
/// Gets an outcome carrying no source and a single error diagnostic.
/// </summary>
Expand Down
12 changes: 6 additions & 6 deletions Source/Cli/Commands/Screenplay/LoadedCompilation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
namespace Cratis.Cli.Commands.Screenplay;

/// <summary>
/// Represents the outcome of loading a solution or project into a Roslyn compilation.
/// Represents the outcome of loading a solution or project into the Roslyn compilations to generate from.
/// </summary>
/// <param name="Compilation">The compilation to generate from; <see langword="null"/> when loading failed.</param>
/// <param name="ProjectName">The name of the project the compilation came from; empty when loading failed.</param>
/// <param name="Compilations">The compilations to generate from; empty when nothing could be loaded.</param>
/// <param name="ProjectNames">The names of the projects the compilations came from, in the same order.</param>
/// <param name="Diagnostics">Anything worth reporting about the load itself.</param>
public record LoadedCompilation(Compilation? Compilation, string ProjectName, IReadOnlyList<ScreenplayDiagnostic> Diagnostics)
public record LoadedCompilation(IReadOnlyList<Compilation> Compilations, IReadOnlyList<string> ProjectNames, IReadOnlyList<ScreenplayDiagnostic> Diagnostics)
{
/// <summary>
/// Gets a failed outcome carrying a single error diagnostic.
Expand All @@ -23,7 +23,7 @@ public record LoadedCompilation(Compilation? Compilation, string ProjectName, IR
/// <returns>The failed <see cref="LoadedCompilation"/>.</returns>
public static LoadedCompilation Failed(string code, string message, string? location, IEnumerable<ScreenplayDiagnostic>? warnings = null) =>
new(
null,
string.Empty,
[],
[],
[.. warnings ?? [], new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, code, message, location)]);
}
60 changes: 36 additions & 24 deletions Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
namespace Cratis.Cli.Commands.Screenplay;

/// <summary>
/// Loads a solution or project into the Roslyn compilation the Screenplay generator reads.
/// Loads a solution or project into the Roslyn compilations the Screenplay generator reads.
/// </summary>
/// <remarks>
/// The <c>Cratis.Arc.Screenplay</c> generator deliberately never loads an MSBuild workspace — it takes a
/// <see cref="Compilation"/> and nothing else. Doing the workspace work here keeps that seam intact and makes the
/// generator equally usable from an MSBuild task, an analyzer, or a spec that builds a compilation from strings.
/// The <c>Cratis.Arc.Screenplay</c> generator deliberately never loads an MSBuild workspace — it takes
/// <see cref="Compilation"/> instances and nothing else. Doing the workspace work here keeps that seam intact and
/// makes the generator equally usable from an MSBuild task, an analyzer, or a spec that builds a compilation from
/// strings.
/// </remarks>
public static class ScreenplayCompilationLoader
{
Expand Down Expand Up @@ -79,11 +80,7 @@ static async Task<LoadedCompilation> LoadWithWorkspace(string targetPath, Cancel
.GroupBy(project => project.Name, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);

var candidates = byName.Values
.Select(project => new ScreenplayProjectCandidate(project.Name, IsExecutable(project)))
.ToArray();

var narrowed = ScreenplayProjectSelection.Narrow(candidates);
var narrowed = ScreenplayProjectSelection.Narrow(byName.Keys);
if (narrowed.Count == 0)
{
return LoadedCompilation.Failed(
Expand All @@ -93,26 +90,41 @@ static async Task<LoadedCompilation> LoadWithWorkspace(string targetPath, Cancel
failures);
}

if (narrowed.Count > 1)
var compilations = new List<Compilation>();
var names = new List<string>();

// A project that yields no compilation is left out of the document rather than ending the run, so that a
// solution still describes the projects that did load - and is reported as an error, because a document
// missing part of the application it names is exactly what nobody notices on their own.
var unloadable = new List<ScreenplayDiagnostic>();

foreach (var name in narrowed)
{
var project = GeneratedResourceSources.AddMissingTo(byName[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",
project.FilePath ?? targetPath));
continue;
}

compilations.Add(compilation);
names.Add(project.Name);
}

if (compilations.Count == 0)
{
return LoadedCompilation.Failed(
ScreenplayDiagnosticCodes.AmbiguousProject,
$"'{targetPath}' holds {narrowed.Count} candidate projects ({string.Join(", ", narrowed.Select(candidate => candidate.Name))}) — pass the project to generate from",
ScreenplayDiagnosticCodes.NoCompilation,
$"No compilation could be created for any project in '{targetPath}'",
targetPath,
failures);
}

var selected = GeneratedResourceSources.AddMissingTo(byName[narrowed[0].Name]);
var compilation = await selected.GetCompilationAsync(cancellationToken);
return compilation is null
? LoadedCompilation.Failed(
ScreenplayDiagnosticCodes.NoCompilation,
$"No compilation could be created for '{selected.Name}'",
selected.FilePath ?? targetPath,
failures)
: new LoadedCompilation(compilation, selected.Name, failures);
return new LoadedCompilation(compilations, names, [.. failures, .. unloadable]);
}

static bool IsExecutable(Project project) =>
project.CompilationOptions?.OutputKind is OutputKind.ConsoleApplication or OutputKind.WindowsApplication;
}
Loading
Loading