From 2b11577a74525f74c29608ad90e96b61094a35ee Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 09:48:50 +0200 Subject: [PATCH 1/6] Add the table of directive keywords each block reserves The printer needs to know which names collide with a directive keyword so it can put the @ escape back. Keeping the sets in one place also documents the collision surface in a single readable spot. --- .../DotNET/Screenplay/Text/ReservedWords.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Source/DotNET/Screenplay/Text/ReservedWords.cs diff --git a/Source/DotNET/Screenplay/Text/ReservedWords.cs b/Source/DotNET/Screenplay/Text/ReservedWords.cs new file mode 100644 index 0000000..85ff902 --- /dev/null +++ b/Source/DotNET/Screenplay/Text/ReservedWords.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Screenplay.Text; + +/// +/// Holds the directive keywords each block reserves as the first word of a line, and the @ escape +/// that lets a name be used anyway. +/// +/// +/// Screenplay is line based - a block decides what a line is from its first word. Where a name of the +/// author's choosing can collide with a directive keyword, the name is written with an @ prefix and +/// the printer puts it back on the way out. +/// +internal static class ReservedWords +{ + /// + /// The empty set - for a block that reserves no first word. + /// + public static readonly IReadOnlySet None = new HashSet(StringComparer.Ordinal); + + /// + /// The keywords a command body reserves, and so the property names that need escaping. + /// + public static readonly IReadOnlySet CommandBody = + new HashSet(StringComparer.Ordinal) { "authorize", "produces" }; + + /// + /// The keywords an event body reserves, and so the property names that need escaping. + /// + public static readonly IReadOnlySet EventBody = + new HashSet(StringComparer.Ordinal) { "tag" }; + + /// + /// The keywords a mapping block reserves, and so the mapping targets that need escaping. + /// + public static readonly IReadOnlySet MappingBlock = + new HashSet(StringComparer.Ordinal) { "tag" }; + + /// + /// The keywords a projection from block reserves, and so the mapping targets that need escaping. + /// + public static readonly IReadOnlySet ProjectionFromBlock = + new HashSet(StringComparer.Ordinal) { "key", "parent" }; + + /// + /// The keywords an enumeration concept body reserves, and so the values that need escaping. + /// + public static readonly IReadOnlySet ConceptBody = + new HashSet(StringComparer.Ordinal) { "validate" }; + + /// + /// Prefixes a name with the @ escape when the enclosing block reserves it as a directive keyword. + /// + /// The name to escape. + /// The keywords the enclosing block reserves. + /// The escaped name, or when no escape is needed. + public static string Escape(string name, IReadOnlySet reserved) => + reserved.Contains(name) ? $"@{name}" : name; +} From ce7c048919c15a8d5b6255cd3ccbe09904642d75 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 09:48:50 +0200 Subject: [PATCH 2/6] Resolve directive-keyword collisions in property and value names Every block decided what a line was from its first word alone, so a command property named description, validate, handler or concurrency was a parse error, and an event property named tag or an enumeration value named validate was swallowed with no diagnostic at all. Where shape decides, shape now wins: the bare directives take no operand, so a line with property shape is a property. 'description String' declares a property; 'description "..."' is still the directive, and 'validate csharp' still opens a code block. Where shape cannot decide - 'authorize X', 'produces X', 'tag X', a mapping target, an enumeration value - the '@' escape that ProjectionParser already accepted is now accepted everywhere, so '@tag TagType' declares the property. The two collisions that changed meaning in silence now warn: 'tag TagType' says it declares a static tag rather than a property, and a bare 'validate' in an enumeration says it opens an empty validate block rather than declaring a value. Both keep the meaning they have always had. --- .../Screenplay/Parsing/CaptureParser.cs | 4 +-- .../Screenplay/Parsing/CommandParser.cs | 14 ++++++++-- .../DotNET/Screenplay/Parsing/EventParser.cs | 27 +++++++++++++++++++ .../Screenplay/Parsing/PropertyLineParser.cs | 8 ++++-- .../Screenplay/Parsing/ScreenplayParser.cs | 16 ++++++++--- 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/Source/DotNET/Screenplay/Parsing/CaptureParser.cs b/Source/DotNET/Screenplay/Parsing/CaptureParser.cs index cc09aab..fc75385 100644 --- a/Source/DotNET/Screenplay/Parsing/CaptureParser.cs +++ b/Source/DotNET/Screenplay/Parsing/CaptureParser.cs @@ -389,7 +389,7 @@ static bool TryParseMapping(ParserContext context, SourceLine line, List)\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)] diff --git a/Source/DotNET/Screenplay/Parsing/CommandParser.cs b/Source/DotNET/Screenplay/Parsing/CommandParser.cs index 9f6aa16..2a932c2 100644 --- a/Source/DotNET/Screenplay/Parsing/CommandParser.cs +++ b/Source/DotNET/Screenplay/Parsing/CommandParser.cs @@ -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; @@ -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); @@ -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(); } diff --git a/Source/DotNET/Screenplay/Parsing/EventParser.cs b/Source/DotNET/Screenplay/Parsing/EventParser.cs index 71fa353..e0adc95 100644 --- a/Source/DotNET/Screenplay/Parsing/EventParser.cs +++ b/Source/DotNET/Screenplay/Parsing/EventParser.cs @@ -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); @@ -50,6 +51,32 @@ public static EventSyntax Parse(ParserContext context, SourceLine header) return new(name.Groups[1].Value, properties, header.Location, tags); } + /// + /// Warns when a tag line reads as a property declaration - tag TagType is a static tag with + /// the value TagType, but it has the exact shape of a property named tag. + /// + /// The to report the diagnostic to. + /// The holding the tag line. + /// + /// The tag wins, because that is what the line has always meant. A lowercase value such as + /// tag audit does not read as a type reference and is left alone. + /// + 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(); } diff --git a/Source/DotNET/Screenplay/Parsing/PropertyLineParser.cs b/Source/DotNET/Screenplay/Parsing/PropertyLineParser.cs index 5a7daa2..91e42b8 100644 --- a/Source/DotNET/Screenplay/Parsing/PropertyLineParser.cs +++ b/Source/DotNET/Screenplay/Parsing/PropertyLineParser.cs @@ -10,6 +10,10 @@ namespace Cratis.Screenplay.Parsing; /// /// Parses property lines - a lowercase name followed by a type reference, such as lines InvoiceLine[]. /// +/// +/// The name accepts the @ escape, so a property can be named after a directive keyword the enclosing +/// block reserves - @tag TagType declares a property called tag. +/// internal static partial class PropertyLineParser { /// @@ -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); } /// @@ -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(); } diff --git a/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs b/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs index f623f82..d6ed598 100644 --- a/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ScreenplayParser.cs @@ -148,6 +148,13 @@ 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); @@ -155,7 +162,7 @@ static ConceptSyntax ParseConcept(ParserContext context, SourceLine line) } else if (type == "Enum" && EnumValueRegex().IsMatch(child.Content)) { - values.Add(child.Content); + values.Add(LineText.Unescape(child.Content)); } else if (type == "Enum") { @@ -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); } @@ -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(); From 1e17970e5ec014e817ccbbaddca8ff343a7e7568 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 09:48:51 +0200 Subject: [PATCH 3/6] Re-escape names that collide with directive keywords when printing --- .../Printing/ScreenplayPrinter.Captures.cs | 4 ++-- .../Printing/ScreenplayPrinter.Commands.cs | 17 +++++++++-------- .../Printing/ScreenplayPrinter.Projections.cs | 13 +++++++------ .../Screenplay/Printing/ScreenplayPrinter.cs | 4 ++-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.cs index 6215e67..78ef6e1 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.cs @@ -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); } } } diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs index dbefcfb..aa24357 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Commands.cs @@ -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; @@ -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) { @@ -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); } } @@ -154,11 +155,11 @@ void WriteReactor(ScreenplayWriter writer, ReactorSyntax reactor) } } - void WriteProperties(ScreenplayWriter writer, IEnumerable properties) + void WriteProperties(ScreenplayWriter writer, IEnumerable properties, IReadOnlySet 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)}"); } } @@ -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; @@ -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); } } } @@ -249,11 +250,11 @@ void WriteHandler(ScreenplayWriter writer, HandlerSyntax handler) } } - void WriteMappings(ScreenplayWriter writer, IEnumerable mappings) + void WriteMappings(ScreenplayWriter writer, IEnumerable mappings, IReadOnlySet 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)}"); } } diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Projections.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Projections.cs index dfe380c..22df556 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Projections.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Projections.cs @@ -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; @@ -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; @@ -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); } } @@ -132,7 +133,7 @@ void WriteEvery(ScreenplayWriter writer, EverySyntax every) writer.Line("exclude children"); } - WriteMappings(writer, every.Mappings); + WriteMappings(writer, every.Mappings, ReservedWords.None); } } @@ -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); } } } @@ -204,13 +205,13 @@ void WriteAutoMap(ScreenplayWriter writer, AutoMapMode autoMap) } } - void WriteMappings(ScreenplayWriter writer, IEnumerable mappings) + void WriteMappings(ScreenplayWriter writer, IEnumerable mappings, IReadOnlySet 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}", diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs index 6a48dd4..248435a 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs @@ -153,7 +153,7 @@ void WriteSeed(ScreenplayWriter writer, SeedSyntax seed) writer.Line(@event.Event); using (writer.Indent()) { - WriteMappings(writer, @event.Properties); + WriteMappings(writer, @event.Properties, ReservedWords.None); } } } @@ -177,7 +177,7 @@ void WriteConcept(ScreenplayWriter writer, ConceptSyntax concept) { foreach (var value in concept.Values) { - writer.Line(value); + writer.Line(ReservedWords.Escape(value, ReservedWords.ConceptBody)); } } From d5d5378c97d6fc7f74f9150bab4c0675b4193945 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 09:48:51 +0200 Subject: [PATCH 4/6] Add specs for every directive-keyword collision --- ...mand_declares_bare_directive_properties.cs | 45 +++++++++++++++ ..._command_escapes_authorize_and_produces.cs | 39 +++++++++++++ .../and_a_command_keeps_its_directives.cs | 49 ++++++++++++++++ ...and_a_concept_declares_a_validate_value.cs | 37 ++++++++++++ .../and_a_produces_mapping_targets_tag.cs | 43 ++++++++++++++ .../and_a_projection_maps_key_and_parent.cs | 35 ++++++++++++ .../and_an_event_declares_a_tag_property.cs | 39 +++++++++++++ ...ting_names_that_collide_with_directives.cs | 57 +++++++++++++++++++ 8 files changed, 344 insertions(+) create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_declares_bare_directive_properties.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_escapes_authorize_and_produces.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_keeps_its_directives.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_concept_declares_a_validate_value.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_produces_mapping_targets_tag.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_projection_maps_key_and_parent.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_an_event_declares_a_tag_property.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_names_that_collide_with_directives.cs diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_declares_bare_directive_properties.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_declares_bare_directive_properties.cs new file mode 100644 index 0000000..6d4249c --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_declares_bare_directive_properties.cs @@ -0,0 +1,45 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler.when_compiling_reserved_keywords_as_names; + +public class and_a_command_declares_bare_directive_properties : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + invoiceId Uuid + description String + validate Bool + handler String + concurrency Int + """; + + CompilationResult _result; + CommandSyntax _command; + + void Because() + { + _result = _compiler.Compile(Source); + _command = _result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_declare_every_property() => _command.Properties.Count().ShouldEqual(5); + [Fact] void should_declare_the_description_property() => Property("description").Type.Name.ShouldEqual("String"); + [Fact] void should_declare_the_validate_property() => Property("validate").Type.Name.ShouldEqual("Bool"); + [Fact] void should_declare_the_handler_property() => Property("handler").Type.Name.ShouldEqual("String"); + [Fact] void should_declare_the_concurrency_property() => Property("concurrency").Type.Name.ShouldEqual("Int"); + [Fact] void should_not_have_a_description() => _command.Description.ShouldBeNull(); + [Fact] void should_not_have_a_validate_block() => _command.Validations.ShouldBeEmpty(); + [Fact] void should_not_have_a_handler() => _command.Handler.ShouldBeNull(); + [Fact] void should_not_have_a_concurrency_block() => _command.Concurrency.ShouldBeNull(); + + PropertySyntax Property(string name) => _command.Properties.Single(_ => _.Name == name); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_escapes_authorize_and_produces.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_escapes_authorize_and_produces.cs new file mode 100644 index 0000000..88e1878 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_escapes_authorize_and_produces.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Syntax; + +namespace Cratis.Screenplay.for_ScreenplayCompiler.when_compiling_reserved_keywords_as_names; + +public class and_a_command_escapes_authorize_and_produces : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + invoiceId Uuid + @authorize AuthorizationCode + @produces ProductionLine + """; + + CompilationResult _result; + CommandSyntax _command; + + void Because() + { + _result = _compiler.Compile(Source); + _command = _result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_declare_every_property() => _command.Properties.Count().ShouldEqual(3); + [Fact] void should_strip_the_escape_from_the_authorize_property() => Property("authorize").Type.Name.ShouldEqual("AuthorizationCode"); + [Fact] void should_strip_the_escape_from_the_produces_property() => Property("produces").Type.Name.ShouldEqual("ProductionLine"); + [Fact] void should_not_have_an_authorize_declaration() => _command.Authorize.ShouldBeNull(); + [Fact] void should_not_produce_any_event() => _command.Produces.ShouldBeEmpty(); + + PropertySyntax Property(string name) => _command.Properties.Single(_ => _.Name == name); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_keeps_its_directives.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_keeps_its_directives.cs new file mode 100644 index 0000000..b9fd035 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_command_keeps_its_directives.cs @@ -0,0 +1,49 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler.when_compiling_reserved_keywords_as_names; + +public class and_a_command_keeps_its_directives : given.a_compiler +{ + const string Source = + """ + policy CanManageInvoice + require authenticated + + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + description "Registers a new invoice" + invoiceId Uuid + authorize CanManageInvoice + validate + invoiceId not empty + concurrency + eventSource + produces InvoiceRegistered + + event InvoiceRegistered + invoiceId Uuid + """; + + CompilationResult _result; + CommandSyntax _command; + + void Because() + { + _result = _compiler.Compile(Source); + _command = _result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_have_only_the_declared_property() => _command.Properties.Single().Name.ShouldEqual("invoiceId"); + [Fact] void should_have_the_description() => _command.Description.ShouldEqual("Registers a new invoice"); + [Fact] void should_have_the_authorize_declaration() => _command.Authorize!.Policies.Single().Name.ShouldEqual("CanManageInvoice"); + [Fact] void should_have_the_validate_block() => _command.Validations.Single().ShouldBeOfExactType(); + [Fact] void should_have_the_concurrency_block() => _command.Concurrency!.EventSource.ShouldBeTrue(); + [Fact] void should_have_the_production() => _command.Produces.Single().Event.ShouldEqual("InvoiceRegistered"); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_concept_declares_a_validate_value.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_concept_declares_a_validate_value.cs new file mode 100644 index 0000000..803fd45 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_concept_declares_a_validate_value.cs @@ -0,0 +1,37 @@ +// 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.Diagnostics; +using Cratis.Screenplay.Syntax; + +namespace Cratis.Screenplay.for_ScreenplayCompiler.when_compiling_reserved_keywords_as_names; + +public class and_a_concept_declares_a_validate_value : given.a_compiler +{ + const string Source = + """ + concept InvoiceStatus : Enum + draft + @validate + sent + + concept SwallowedStatus : Enum + draft + validate + sent + """; + + CompilationResult _result; + + void Because() => _result = _compiler.Compile(Source); + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_declare_the_escaped_value() => Concept("InvoiceStatus").Values.ShouldContain("validate"); + [Fact] void should_declare_every_escaped_value() => Concept("InvoiceStatus").Values.Count().ShouldEqual(3); + [Fact] void should_not_declare_a_validate_block_for_the_escaped_value() => Concept("InvoiceStatus").Validations!.ShouldBeEmpty(); + [Fact] void should_warn_about_the_unescaped_value() => _result.Diagnostics.Single().Severity.ShouldEqual(DiagnosticSeverity.Warning); + [Fact] void should_point_at_the_unescaped_value() => _result.Diagnostics.Single().Location.Line.ShouldEqual(8); + [Fact] void should_keep_the_block_meaning_for_the_unescaped_value() => Concept("SwallowedStatus").Values.Count().ShouldEqual(2); + + ConceptSyntax Concept(string name) => _result.Value!.Concepts.Single(_ => _.Name == name); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_produces_mapping_targets_tag.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_produces_mapping_targets_tag.cs new file mode 100644 index 0000000..d8a723d --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_produces_mapping_targets_tag.cs @@ -0,0 +1,43 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayCompiler.when_compiling_reserved_keywords_as_names; + +public class and_a_produces_mapping_targets_tag : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + invoiceId Uuid + @tag TagType + + produces InvoiceRegistered + tag audit + invoiceId = invoiceId + @tag = tag + + event InvoiceRegistered + @tag TagType + invoiceId Uuid + """; + + CompilationResult _result; + ProducesSyntax _produces; + + void Because() + { + _result = _compiler.Compile(Source); + _produces = _result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single().Produces.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_keep_the_static_tag() => _produces.Tags!.Single().ShouldNotBeNull(); + [Fact] void should_map_both_properties() => _produces.Mappings.Count().ShouldEqual(2); + [Fact] void should_strip_the_escape_from_the_mapping_target() => _produces.Mappings.Single(_ => _.Property == "tag").ShouldNotBeNull(); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_projection_maps_key_and_parent.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_projection_maps_key_and_parent.cs new file mode 100644 index 0000000..9022edc --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_a_projection_maps_key_and_parent.cs @@ -0,0 +1,35 @@ +// 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.Projections; + +namespace Cratis.Screenplay.for_ScreenplayCompiler.when_compiling_reserved_keywords_as_names; + +public class and_a_projection_maps_key_and_parent : given.a_compiler +{ + const string Source = + """ + projection Order => OrderReadModel + from OrderPlaced + key orderId + @key = externalKey + @parent = parentReference + """; + + CompilationResult _result; + FromSyntax _from; + + void Because() + { + _result = _compiler.CompileProjection(Source); + _from = _result.Value!.Blocks.OfType().Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_keep_the_key_directive() => _from.Key.ShouldNotBeNull(); + [Fact] void should_not_have_a_parent_key() => _from.ParentKey.ShouldBeNull(); + [Fact] void should_map_both_escaped_targets() => _from.Mappings.Count().ShouldEqual(2); + [Fact] void should_strip_the_escape_from_the_key_mapping() => _from.Mappings.Any(_ => _.Property == "key").ShouldBeTrue(); + [Fact] void should_strip_the_escape_from_the_parent_mapping() => _from.Mappings.Any(_ => _.Property == "parent").ShouldBeTrue(); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_an_event_declares_a_tag_property.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_an_event_declares_a_tag_property.cs new file mode 100644 index 0000000..dc4db1f --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_reserved_keywords_as_names/and_an_event_declares_a_tag_property.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; + +namespace Cratis.Screenplay.for_ScreenplayCompiler.when_compiling_reserved_keywords_as_names; + +public class and_an_event_declares_a_tag_property : given.a_compiler +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + event InvoiceRegistered + tag audit + tag TagType + @tag TagType + invoiceId Uuid + """; + + CompilationResult _result; + EventSyntax _event; + + void Because() + { + _result = _compiler.Compile(Source); + _event = _result.Value!.Modules.Single().Features.Single().Slices.Single().Events.Single(); + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_warn_that_the_tag_line_is_not_a_property() => _result.Diagnostics.Single().Severity.ShouldEqual(DiagnosticSeverity.Warning); + [Fact] void should_point_at_the_ambiguous_line() => _result.Diagnostics.Single().Location.Line.ShouldEqual(6); + [Fact] void should_keep_both_tags() => _event.Tags!.Count().ShouldEqual(2); + [Fact] void should_declare_the_escaped_property() => _event.Properties.Single(_ => _.Name == "tag").Type.Name.ShouldEqual("TagType"); + [Fact] void should_declare_the_other_property() => _event.Properties.Single(_ => _.Name == "invoiceId").Type.Name.ShouldEqual("Uuid"); + [Fact] void should_not_warn_about_a_lowercase_tag_value() => _result.Diagnostics.Count().ShouldEqual(1); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_names_that_collide_with_directives.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_names_that_collide_with_directives.cs new file mode 100644 index 0000000..500411b --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_names_that_collide_with_directives.cs @@ -0,0 +1,57 @@ +// 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; + +namespace Cratis.Screenplay.for_ScreenplayPrinter; + +public class when_printing_names_that_collide_with_directives : given.a_printer +{ + const string Source = + """ + concept InvoiceStatus : Enum + draft + @validate + + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + invoiceId Uuid + description String + @authorize AuthorizationCode + @produces ProductionLine + + produces InvoiceRegistered + invoiceId = invoiceId + @tag = invoiceId + + event InvoiceRegistered + @tag TagType + invoiceId Uuid + """; + + given.a_printer.RoundTripResult _roundtrip; + + void Because() => _roundtrip = RoundTrip(Source); + + [Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty(); + [Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty(); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed); + [Fact] void should_escape_the_enum_value() => _roundtrip.Printed.ShouldContain("@validate"); + [Fact] void should_escape_the_authorize_property() => _roundtrip.Printed.ShouldContain("@authorize AuthorizationCode"); + [Fact] void should_escape_the_produces_property() => _roundtrip.Printed.ShouldContain("@produces ProductionLine"); + [Fact] void should_escape_the_tag_property() => _roundtrip.Printed.ShouldContain("@tag TagType"); + [Fact] void should_escape_the_tag_mapping() => _roundtrip.Printed.ShouldContain("@tag = invoiceId"); + [Fact] void should_leave_the_description_property_unescaped() => _roundtrip.Printed.ShouldContain("description String"); + [Fact] void should_preserve_the_command_properties() => Command(_roundtrip.Reparsed).Properties.Count().ShouldEqual(4); + [Fact] void should_preserve_the_authorize_property() => Command(_roundtrip.Reparsed).Properties.Any(_ => _.Name == "authorize").ShouldBeTrue(); + [Fact] void should_preserve_the_event_tag_property() => Event(_roundtrip.Reparsed).Properties.Any(_ => _.Name == "tag").ShouldBeTrue(); + [Fact] void should_preserve_the_enum_value() => _roundtrip.Reparsed.Value!.Concepts.Single().Values.ShouldContain("validate"); + + static CommandSyntax Command(CompilationResult result) => + result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single(); + + static EventSyntax Event(CompilationResult result) => + result.Value!.Modules.Single().Features.Single().Slices.Single().Events.Single(); +} From 5f7e7f3c64667999d4443d836110f9c2767e6e58 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 09:48:51 +0200 Subject: [PATCH 5/6] Highlight the keyword escape in the Monaco and VS Code grammars --- Source/Screenplay/Monaco/screenplay-language/tokens.ts | 2 ++ .../VSCodeExtension/syntaxes/screenplay.tmLanguage.json | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/Source/Screenplay/Monaco/screenplay-language/tokens.ts b/Source/Screenplay/Monaco/screenplay-language/tokens.ts index abc28da..bda6e22 100644 --- a/Source/Screenplay/Monaco/screenplay-language/tokens.ts +++ b/Source/Screenplay/Monaco/screenplay-language/tokens.ts @@ -52,6 +52,8 @@ export function createTokensProvider(subLanguages: SubLanguage[]): languages.IMo // A bare description keyword at end of line opens a fenced plain-text block. [/\bdescription\b(?=\s*$)/, { token: 'keyword', next: '@descriptionBlockPending' }], [/\brow-click\b/, 'keyword'], + // A leading @ escapes a name that collides with a directive keyword - it is a name, not an attribute. + [/^\s*@[a-z_]\w*/, 'identifier'], [/@\w+/, 'annotation'], [ /[A-Z]\w*/, diff --git a/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json b/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json index eb30585..d870026 100644 --- a/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json +++ b/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json @@ -43,6 +43,11 @@ "common": { "patterns": [ { "include": "#comment" }, + { + "comment": "A leading @ escapes a name that collides with a directive keyword.", + "match": "^\\s*@[a-z_]\\w*", + "name": "variable.other.screenplay" + }, { "match": "@\\w+", "name": "entity.other.attribute-name.screenplay" From 0fa56546e7e2aa08fa320a64138dfca87032fb98 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 09:48:51 +0200 Subject: [PATCH 6/6] Document the keyword escape in the grammar --- Documentation/screenplay/grammar.md | 44 +++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/Documentation/screenplay/grammar.md b/Documentation/screenplay/grammar.md index 4354db7..281a354 100644 --- a/Documentation/screenplay/grammar.md +++ b/Documentation/screenplay/grammar.md @@ -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 @@ -148,7 +148,7 @@ TagValue = Ident Path = Ident, { ".", Ident } ; -PropertyLine = Ident, TypeRef, NL ; +PropertyLine = [ "@" ], Ident, TypeRef, NL ; TypeRef = Ident, [ "[]" ], [ "?" ] ; @@ -219,7 +219,7 @@ ConditionExpr = Ident, CompOp, Value CompOp = "==" | "!=" | ">" | ">=" | "<" | "<=" ; -PropertyMapping = Ident, "=", MappingSource, NL ; +PropertyMapping = [ "@" ], Ident, "=", MappingSource, NL ; MappingSource = Ident (* command property *) | "$context.occurred" @@ -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: