Skip to content
Merged
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
44 changes: 41 additions & 3 deletions Documentation/screenplay/grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ QualifiedName = Ident, { ".", Ident } ;
ConceptDecl = "concept", Ident, ":", PrimitiveType, { Attribute }, NL,
[ INDENT, { ConceptValidate }, DEDENT ]
| "concept", Ident, ":", "Enum", NL,
INDENT, { Ident, NL }, { ConceptValidate }, DEDENT ;
INDENT, { [ "@" ], Ident, NL }, { ConceptValidate }, DEDENT ;

ConceptValidate = "validate", NL,
INDENT, { ConceptRule }, DEDENT
Expand Down Expand Up @@ -148,7 +148,7 @@ TagValue = Ident

Path = Ident, { ".", Ident } ;

PropertyLine = Ident, TypeRef, NL ;
PropertyLine = [ "@" ], Ident, TypeRef, NL ;

TypeRef = Ident, [ "[]" ], [ "?" ] ;

Expand Down Expand Up @@ -219,7 +219,7 @@ ConditionExpr = Ident, CompOp, Value

CompOp = "==" | "!=" | ">" | ">=" | "<" | "<=" ;

PropertyMapping = Ident, "=", MappingSource, NL ;
PropertyMapping = [ "@" ], Ident, "=", MappingSource, NL ;

MappingSource = Ident (* command property *)
| "$context.occurred"
Expand Down Expand Up @@ -415,6 +415,44 @@ DEDENT = ? decrease in indentation level ? ;
AnyLine = ? any text until newline ? ;
```

## Keyword escape

Screenplay is line based: a block decides what a line is from its first word. That makes a handful of words reserved inside each block, and `description` or `tag` is an ordinary name for a domain field.

Most of the time shape settles it. The directives that take no operand cannot be confused with a property, so a line with property shape is a property:

```play
command RegisterInvoice
description String // a property called description
description "Registers a new invoice" // the directive
```

The same holds for `validate`, `handler` and `concurrency`.

Where shape cannot settle it - `authorize CanManageInvoice` and `tag Audit` are legitimate directives *and* legitimate property lines - prefix the name with `@`:

```play
command RegisterInvoice
@authorize AuthorizationCode // a property called authorize
authorize CanManageInvoice // the directive

event InvoiceRegistered
@tag TagType // a property called tag
tag audit // a static tag
```

The escape works wherever a name of your choosing meets a reserved first word - property lines, property mappings, enumeration values, and projection `from` mappings (`@key`, `@parent`). The `@` is not part of the name, and the printer puts it back when it is needed.

| Block | Reserved first words |
|---|---|
| `command` body | `authorize`, `produces` (`description`, `validate`, `handler` and `concurrency` resolve by shape) |
| `event` body | `tag` |
| mapping block | `tag` |
| projection `from` block | `key`, `parent` |
| enumeration `concept` body | `validate` |

An unescaped `tag Audit` or a bare `validate` enumeration value keeps the meaning it has always had - the directive - and the compiler warns that the line does not declare what it looks like.

## String escapes

A string literal carries `"` and `\` through the backslash escapes above, so a value survives the trip out to text and back:
Expand Down
4 changes: 2 additions & 2 deletions Source/DotNET/Screenplay/Parsing/CaptureParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ static bool TryParseMapping(ParserContext context, SourceLine line, List<Propert
return false;
}

mappings.Add(new(match.Groups[1].Value, ExpressionParser.ParseMappingSource(match.Groups[2].Value, line.Location), line.Location));
mappings.Add(new(LineText.Unescape(match.Groups[1].Value), ExpressionParser.ParseMappingSource(match.Groups[2].Value, line.Location), line.Location));
return true;
}

Expand Down Expand Up @@ -471,7 +471,7 @@ static bool TryParseMapping(ParserContext context, SourceLine line, List<Propert
[GeneratedRegex("\"" + StringLiteral.BodyPattern + "\"|[\\w.]+", RegexOptions.None, 1000)]
private static partial Regex WhenTokenRegex();

[GeneratedRegex(@"^([\w.]+)\s*=(?!=|>)\s*(.+)$", RegexOptions.None, 1000)]
[GeneratedRegex(@"^(@?[\w.]+)\s*=(?!=|>)\s*(.+)$", RegexOptions.None, 1000)]
private static partial Regex MappingRegex();

[GeneratedRegex(@"^children\s+([a-z_]\w*)\s+identified\s+by\s+([\w.]+)$", RegexOptions.None, 1000)]
Expand Down
14 changes: 12 additions & 2 deletions Source/DotNET/Screenplay/Parsing/CommandParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ public static CommandSyntax Parse(ParserContext context, SourceLine header)
context.Reader.TakeSignificant();
switch (LineText.FirstWord(line.Content))
{
// The bare directives below cannot take a type reference, so a line that has property shape
// is a property no matter which keyword it starts with - 'description String' declares a
// property called description. Only the directives that do take an identifier operand
// ('authorize', 'produces') stay ambiguous, and those use the '@' escape.
case "description" or "handler" or "concurrency" when PropertyLineParser.TryParse(line) is { } named:
properties.Add(named);
break;
case "validate" when line.Content != "validate csharp" && PropertyLineParser.TryParse(line) is { } validated:
properties.Add(validated);
break;
case "description":
description = DescriptionParser.Parse(context, line, description, $"Command '{name.Groups[1].Value}'");
break;
Expand Down Expand Up @@ -246,7 +256,7 @@ static bool ParseEventSourceDimension(ParserContext context, SourceLine line, bo
continue;
}

mappings.Add(new(match.Groups[1].Value, ExpressionParser.ParseMappingSource(match.Groups[2].Value, child.Location), child.Location));
mappings.Add(new(LineText.Unescape(match.Groups[1].Value), ExpressionParser.ParseMappingSource(match.Groups[2].Value, child.Location), child.Location));
}

return (mappings, tags);
Expand Down Expand Up @@ -292,6 +302,6 @@ static bool ParseEventSourceDimension(ParserContext context, SourceLine line, bo
[GeneratedRegex(@"^(sourceType|streamType|streamId)\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)]
private static partial Regex ConcurrencyDimensionRegex();

[GeneratedRegex(@"^([\w.]+)\s*=(?!=|>)\s*(.+)$", RegexOptions.None, 1000)]
[GeneratedRegex(@"^(@?[\w.]+)\s*=(?!=|>)\s*(.+)$", RegexOptions.None, 1000)]
private static partial Regex MappingRegex();
}
27 changes: 27 additions & 0 deletions Source/DotNET/Screenplay/Parsing/EventParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static EventSyntax Parse(ParserContext context, SourceLine header)
context.Reader.TakeSignificant();
if (LineText.FirstWord(line.Content) == "tag")
{
WarnOnAmbiguousTag(context, line);
if (TagParser.Parse(context, line) is { } tag)
{
tags.Add(tag);
Expand All @@ -50,6 +51,32 @@ public static EventSyntax Parse(ParserContext context, SourceLine header)
return new(name.Groups[1].Value, properties, header.Location, tags);
}

/// <summary>
/// Warns when a <c>tag</c> line reads as a property declaration - <c>tag TagType</c> is a static tag with
/// the value <c>TagType</c>, but it has the exact shape of a property named <c>tag</c>.
/// </summary>
/// <param name="context">The <see cref="ParserContext"/> to report the diagnostic to.</param>
/// <param name="line">The <see cref="SourceLine"/> holding the <c>tag</c> line.</param>
/// <remarks>
/// The tag wins, because that is what the line has always meant. A lowercase value such as
/// <c>tag audit</c> does not read as a type reference and is left alone.
/// </remarks>
static void WarnOnAmbiguousTag(ParserContext context, SourceLine line)
{
var value = line.Content["tag".Length..].Trim();
if (!TypeShapedRegex().IsMatch(value))
{
return;
}

context.Warning(
$"'{line.Content}' declares a static tag with the value '{value}', not a property named 'tag' - write 'tag \"{value}\"' for the tag, or '@{line.Content}' for the property",
line.Location);
}

[GeneratedRegex(@"^event\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)]
private static partial Regex HeaderRegex();

[GeneratedRegex(@"^[A-Z]\w*(?:\[\])?\??$", RegexOptions.None, 1000)]
private static partial Regex TypeShapedRegex();
}
8 changes: 6 additions & 2 deletions Source/DotNET/Screenplay/Parsing/PropertyLineParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ namespace Cratis.Screenplay.Parsing;
/// <summary>
/// Parses property lines - a lowercase name followed by a type reference, such as <c>lines InvoiceLine[]</c>.
/// </summary>
/// <remarks>
/// The name accepts the <c>@</c> escape, so a property can be named after a directive keyword the enclosing
/// block reserves - <c>@tag TagType</c> declares a property called <c>tag</c>.
/// </remarks>
internal static partial class PropertyLineParser
{
/// <summary>
Expand All @@ -25,7 +29,7 @@ internal static partial class PropertyLineParser
return null;
}

return new(match.Groups[1].Value, ParseTypeRef(match.Groups[2].Value, line.Location), line.Location);
return new(LineText.Unescape(match.Groups[1].Value), ParseTypeRef(match.Groups[2].Value, line.Location), line.Location);
}

/// <summary>
Expand All @@ -51,6 +55,6 @@ public static TypeRefSyntax ParseTypeRef(string text, SourceLocation location)
return new(text, isCollection, isOptional, location);
}

[GeneratedRegex(@"^([a-z_]\w*)\s+([\w.]+(?:\[\])?\??)$", RegexOptions.None, 1000)]
[GeneratedRegex(@"^(@?[a-z_]\w*)\s+([\w.]+(?:\[\])?\??)$", RegexOptions.None, 1000)]
private static partial Regex PropertyRegex();
}
16 changes: 13 additions & 3 deletions Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,21 @@ static ConceptSyntax ParseConcept(ParserContext context, SourceLine line)
context.Reader.TakeSignificant();
if (LineText.FirstWord(child.Content) == "validate")
{
if (type == "Enum" && child.Content == "validate" && !context.TryPeekChild(child.Indent, out _))
{
context.Warning(
$"'validate' in enumeration concept '{name}' declares an empty validate block, not a value named 'validate' - write '@validate' for the value",
child.Location);
}

if (ValidateParser.Parse(context, child, impliedSubject: true) is { } validate)
{
validations.Add(validate);
}
}
else if (type == "Enum" && EnumValueRegex().IsMatch(child.Content))
{
values.Add(child.Content);
values.Add(LineText.Unescape(child.Content));
}
else if (type == "Enum")
{
Expand Down Expand Up @@ -262,7 +269,7 @@ static LayoutSyntax ParseLayout(ParserContext context, SourceLine line)
while (context.TryPeekChild(child.Indent, out var slot))
{
context.Reader.TakeSignificant();
if (EnumValueRegex().IsMatch(slot.Content))
if (SlotNameRegex().IsMatch(slot.Content))
{
slots.Add(slot.Content);
}
Expand Down Expand Up @@ -328,9 +335,12 @@ static FeatureSyntax ParseFeature(ParserContext context, SourceLine line)
[GeneratedRegex(@"^concept\s+(\w+)\s*:\s*(\w+)((?:\s+@\w+)*)$", RegexOptions.None, 1000)]
private static partial Regex ConceptRegex();

[GeneratedRegex(@"^[a-z_]\w*$", RegexOptions.None, 1000)]
[GeneratedRegex(@"^@?[a-z_]\w*$", RegexOptions.None, 1000)]
private static partial Regex EnumValueRegex();

[GeneratedRegex(@"^[a-z_]\w*$", RegexOptions.None, 1000)]
private static partial Regex SlotNameRegex();

[GeneratedRegex(@"^persona\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)]
private static partial Regex PersonaRegex();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,14 @@ void WriteCaptureAppend(ScreenplayWriter writer, CaptureAppendSyntax append)

if (append.When is null)
{
WriteMappings(writer, append.Mappings);
WriteMappings(writer, append.Mappings, ReservedWords.MappingBlock);
return;
}

writer.Line($"when {ScreenplaySyntaxText.CaptureWhen(append.When)}");
using (writer.Indent())
{
WriteMappings(writer, append.Mappings);
WriteMappings(writer, append.Mappings, ReservedWords.MappingBlock);
}
}
}
Expand Down
17 changes: 9 additions & 8 deletions Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Printing;

Expand All @@ -16,7 +17,7 @@ void WriteCommand(ScreenplayWriter writer, CommandSyntax command)
using (writer.Indent())
{
WriteDescription(writer, command.Description);
WriteProperties(writer, command.Properties);
WriteProperties(writer, command.Properties, ReservedWords.CommandBody);

if (command.Authorize is not null)
{
Expand Down Expand Up @@ -84,7 +85,7 @@ void WriteEvent(ScreenplayWriter writer, EventSyntax @event)
using (writer.Indent())
{
WriteTags(writer, @event.Tags);
WriteProperties(writer, @event.Properties);
WriteProperties(writer, @event.Properties, ReservedWords.EventBody);
}
}

Expand Down Expand Up @@ -154,11 +155,11 @@ void WriteReactor(ScreenplayWriter writer, ReactorSyntax reactor)
}
}

void WriteProperties(ScreenplayWriter writer, IEnumerable<PropertySyntax> properties)
void WriteProperties(ScreenplayWriter writer, IEnumerable<PropertySyntax> properties, IReadOnlySet<string> reserved)
{
foreach (var property in properties)
{
writer.Line($"{property.Name} {ScreenplaySyntaxText.TypeRef(property.Type)}");
writer.Line($"{ReservedWords.Escape(property.Name, reserved)} {ScreenplaySyntaxText.TypeRef(property.Type)}");
}
}

Expand Down Expand Up @@ -214,7 +215,7 @@ void WriteProduces(ScreenplayWriter writer, ProducesSyntax produces)
using (writer.Indent())
{
WriteTags(writer, produces.Tags);
WriteMappings(writer, produces.Mappings);
WriteMappings(writer, produces.Mappings, ReservedWords.MappingBlock);
}

return;
Expand All @@ -227,7 +228,7 @@ void WriteProduces(ScreenplayWriter writer, ProducesSyntax produces)
using (writer.Indent())
{
WriteTags(writer, produces.Tags);
WriteMappings(writer, produces.Mappings);
WriteMappings(writer, produces.Mappings, ReservedWords.MappingBlock);
}
}
}
Expand All @@ -249,11 +250,11 @@ void WriteHandler(ScreenplayWriter writer, HandlerSyntax handler)
}
}

void WriteMappings(ScreenplayWriter writer, IEnumerable<PropertyMappingSyntax> mappings)
void WriteMappings(ScreenplayWriter writer, IEnumerable<PropertyMappingSyntax> mappings, IReadOnlySet<string> reserved)
{
foreach (var mapping in mappings)
{
writer.Line($"{mapping.Property} = {ScreenplaySyntaxText.Expression(mapping.Source)}");
writer.Line($"{ReservedWords.Escape(mapping.Property, reserved)} = {ScreenplaySyntaxText.Expression(mapping.Source)}");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Syntax.Projections;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Printing;

Expand Down Expand Up @@ -53,7 +54,7 @@ void WriteProjectionBlock(ScreenplayWriter writer, ProjectionBlockSyntax block)
using (writer.Indent())
{
WriteAutoMap(writer, all.AutoMap);
WriteMappings(writer, all.Mappings);
WriteMappings(writer, all.Mappings, ReservedWords.None);
}

break;
Expand Down Expand Up @@ -117,7 +118,7 @@ void WriteFrom(ScreenplayWriter writer, FromSyntax from)
writer.Line($"parent {ScreenplaySyntaxText.Expression(from.ParentKey)}");
}

WriteMappings(writer, from.Mappings);
WriteMappings(writer, from.Mappings, ReservedWords.ProjectionFromBlock);
}
}

Expand All @@ -132,7 +133,7 @@ void WriteEvery(ScreenplayWriter writer, EverySyntax every)
writer.Line("exclude children");
}

WriteMappings(writer, every.Mappings);
WriteMappings(writer, every.Mappings, ReservedWords.None);
}
}

Expand All @@ -147,7 +148,7 @@ void WriteJoin(ScreenplayWriter writer, JoinSyntax join)
using (writer.Indent())
{
WriteAutoMap(writer, joined.AutoMap);
WriteMappings(writer, joined.Mappings);
WriteMappings(writer, joined.Mappings, ReservedWords.None);
}
}
}
Expand Down Expand Up @@ -204,13 +205,13 @@ void WriteAutoMap(ScreenplayWriter writer, AutoMapMode autoMap)
}
}

void WriteMappings(ScreenplayWriter writer, IEnumerable<MappingSyntax> mappings)
void WriteMappings(ScreenplayWriter writer, IEnumerable<MappingSyntax> mappings, IReadOnlySet<string> reserved)
{
foreach (var mapping in mappings)
{
writer.Line(mapping switch
{
SetMappingSyntax set => $"{set.Property} = {ScreenplaySyntaxText.Expression(set.Source)}",
SetMappingSyntax set => $"{ReservedWords.Escape(set.Property, reserved)} = {ScreenplaySyntaxText.Expression(set.Source)}",
IncrementMappingSyntax increment => $"increment {increment.Property}",
DecrementMappingSyntax decrement => $"decrement {decrement.Property}",
CountMappingSyntax count => $"count {count.Property}",
Expand Down
Loading
Loading