Skip to content
Closed
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: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
<ItemGroup>
<!-- Cratis -->
<PackageVersion Include="Cratis" Version="21.13.0" />
<PackageVersion Include="Cratis.Arc.Chronicle.Testing" Version="21.13.0" />
<PackageVersion Include="Cratis.Arc.MongoDB" Version="21.13.0" />
<PackageVersion Include="Cratis.Arc.Testing" Version="21.13.0" />
<PackageVersion Include="Cratis.Chronicle.Contracts" Version="16.28.0" />
<PackageVersion Include="Cratis.Screenplay" Version="2.1.0" />
<PackageVersion Include="Cratis.Templates" Version="1.1.1" />
Expand Down
30 changes: 30 additions & 0 deletions Source/Rendering.Cratis/CratisRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Cratis.Stage.Rendering.Cratis.Naming;
using Cratis.Stage.Rendering.Cratis.Renderers;
using Cratis.Stage.Rendering.Cratis.Scaffolding;
using Cratis.Stage.Rendering.Cratis.Specifications;

namespace Cratis.Stage.Rendering.Cratis;

Expand Down Expand Up @@ -196,13 +197,42 @@ async Task RenderSlice(LocatedSlice slice, ApplicationSet applicationSet, string
{
await output.WriteLineAsync($"Rendering slice '{slicePath}'...");
await WriteFile(renderer.Render(slice, applicationSet, rootNamespace), targetDirectory, output, error);
await RenderSpecifications(slice, applicationSet, rootNamespace, targetDirectory, output, error);
}
catch (Exception exception)
{
await error.WriteLineAsync($"Failed to render slice '{slicePath}': {exception.Message}");
}
}

/// <summary>
/// Renders the slice's specifications, one file each. A specification that cannot be rendered faithfully is
/// reported rather than emitted — a spec asserting something the document did not state is worse than none.
/// </summary>
/// <param name="slice">The located slice whose specifications to render.</param>
/// <param name="applicationSet">The <see cref="ApplicationSet"/> to resolve against.</param>
/// <param name="rootNamespace">The root namespace of the target application.</param>
/// <param name="targetDirectory">The directory to render into.</param>
/// <param name="output">The <see cref="TextWriter"/> progress is reported to.</param>
/// <param name="error">The <see cref="TextWriter"/> rendering problems are reported to.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
async Task RenderSpecifications(
LocatedSlice slice, ApplicationSet applicationSet, string rootNamespace, DirectoryInfo targetDirectory, TextWriter output, TextWriter error)
{
var command = slice.Slice.Commands.FirstOrDefault();

foreach (var specification in slice.Slice.Specifications)
{
if (SpecificationRenderer.Unrenderable(specification, command) is { } reason)
{
await error.WriteLineAsync($"Specification '{specification.Name}' is not rendered — {reason}.");
continue;
}

await WriteFile(SpecificationRenderer.Render(specification, command!, slice, applicationSet, rootNamespace), targetDirectory, output, error);
}
}

async Task WriteFile(RenderedFile file, DirectoryInfo targetDirectory, TextWriter output, TextWriter error)
{
try
Expand Down
8 changes: 8 additions & 0 deletions Source/Rendering.Cratis/Naming/Identifiers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ public static string ToCamelCase(string name)
/// <returns>The lowercase, space-separated words.</returns>
public static string ToWords(string name) => string.Join(' ', SplitWords(name).SelectMany(SplitOnCaseBoundary)).ToLowerInvariant();

/// <summary>
/// Converts a name into snake_case — used for the spec folder and class names the repository conventions
/// use, where <c>RegisteringADraftInvoice</c> reads as <c>registering_a_draft_invoice</c>.
/// </summary>
/// <param name="name">The name to convert.</param>
/// <returns>The snake_case name.</returns>
public static string ToSnakeCase(string name) => ToWords(name).Replace(' ', '_');

/// <summary>
/// Escapes an identifier with <c>@</c> when it is a reserved C# keyword.
/// </summary>
Expand Down
7 changes: 3 additions & 4 deletions Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,9 @@ public static void Report(CSharpCodeBuilder builder, SliceSyntax slice, Rendered
slice.Captures.Count(),
"capture",
"no ingestion of the captured source is rendered.");
yield return (
slice.Specifications.Count(),
"specification",
"no specs are rendered for the generated application.");

// Specifications are rendered separately, one file each, and each one that cannot be says so on its own
// — so counting them here would report the same thing twice and count the rendered ones as dropped.
}

// Both collections are trailing optionals on SliceSyntax and are null on a slice that declares neither.
Expand Down
2 changes: 2 additions & 0 deletions Source/Rendering.Cratis/Rendering.Cratis.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
has no reason to do.
-->
<PackageReference Include="Cratis" ExcludeAssets="analyzers" />
<PackageReference Include="Cratis.Arc.Chronicle.Testing" ExcludeAssets="analyzers" />
<PackageReference Include="Cratis.Arc.Testing" ExcludeAssets="analyzers" />
<PackageReference Include="Cratis.Arc.MongoDB" ExcludeAssets="analyzers" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="All" />
</ItemGroup>
Expand Down
95 changes: 95 additions & 0 deletions Source/Rendering.Cratis/Specifications/SpecificationAssertions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Syntax.Specifications;
using Cratis.Stage.Rendering.Cratis.Naming;

namespace Cratis.Stage.Rendering.Cratis.Specifications;

/// <summary>
/// Renders what a specification asserts about the events its command appended.
/// </summary>
public static class SpecificationAssertions
{
/// <summary>
/// Renders the event source the appended events are asserted against — the value the specification states
/// for the command's own identifier.
/// </summary>
/// <param name="when">The command the specification exercises.</param>
/// <param name="command">The declared command.</param>
/// <param name="applicationSet">The <see cref="ApplicationSet"/> to resolve the identifier type against.</param>
/// <param name="diagnostics">Collects anything that could not be rendered faithfully.</param>
/// <returns>The rendered event source id.</returns>
/// <remarks>
/// A command appends to the event source its identifier names, so the value the specification states for that
/// property is the one the assertion filters on. A specification that states no value for it has not said
/// which event source it means; the assertion is rendered against the empty one, which fails rather than
/// passing on the wrong stream.
/// </remarks>
public static string Of(
SpecificationCommandSyntax when, CommandSyntax command, ApplicationSet applicationSet, ICollection<string> diagnostics)
{
var identifier = command.Properties.FirstOrDefault(property => property.IsIdentifier);
if (identifier is null)
{
diagnostics.Add($"Command '{command.Name}' declares no identifier, so the appended events are asserted against no event source.");
return "EventSourceId.Unspecified";
}

var stated = when.Values.FirstOrDefault(value => string.Equals(value.Property, identifier.Name, StringComparison.OrdinalIgnoreCase));
if (stated?.Source is not LiteralExpressionSyntax { Value: string text })
{
diagnostics.Add(
$"The specification states no value for '{identifier.Name}', which is what says which event source " +
"the appended events belong to.");
return "EventSourceId.Unspecified";
}

return $"new EventSourceId({CodeGeneration.CSharpCodeBuilder.StringLiteral(text)})";
}

/// <summary>
/// Renders the predicate narrowing an appended-event assertion to the values the specification states, or an
/// empty string when it states none beyond the event type.
/// </summary>
/// <param name="event">The expected event.</param>
/// <param name="applicationSet">The <see cref="ApplicationSet"/> to resolve the event's property types against.</param>
/// <param name="diagnostics">Collects anything that could not be rendered faithfully.</param>
/// <returns>The rendered predicate, prefixed with a comma, or an empty string.</returns>
public static string Predicate(SpecificationEventSyntax @event, ApplicationSet applicationSet, ICollection<string> diagnostics)
{
var declared = applicationSet.Events.GetValueOrDefault(@event.EventType);
if (declared is null)
{
diagnostics.Add($"Event '{@event.EventType}' is not declared in this application, so only its type is asserted.");
return string.Empty;
}

var comparisons = @event.Values
.Select(value => (Value: value, Property: declared.Properties.FirstOrDefault(
property => string.Equals(property.Name, value.Property, StringComparison.OrdinalIgnoreCase))))
.Where(pair => pair.Property is not null)
.Select(pair => Comparison(pair.Value, pair.Property!, @event.EventType, applicationSet, diagnostics))
.Where(comparison => comparison is not null)
.ToArray();

return comparisons.Length == 0 ? string.Empty : $", @event => {string.Join(" && ", comparisons)}";
}

static string? Comparison(
PropertyMappingSyntax value,
PropertySyntax property,
string eventType,
ApplicationSet applicationSet,
ICollection<string> diagnostics)
{
if (value.Source is not LiteralExpressionSyntax literal)
{
return null;
}

var rendered = SpecificationValues.Literal(literal.Value, property.Type, property.Name, eventType, applicationSet, diagnostics);
return rendered == "default!" ? null : $"@event.{Identifiers.ToPascalCase(property.Name)} == {rendered}";
}
}
Loading
Loading