From 113c952e2a2fd21b7c216d485c79957b49873014 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:41:54 +0200 Subject: [PATCH 1/5] Recognize backslash escapes in string literals Introduce a shared StringLiteral helper holding the escape rules and the regular expression fragment for a literal body, and teach every parser that reads a quoted operand to resolve the escapes: descriptions, validation messages, screen labels and titles, seed group ids, specification errors, capture translations and separators, policy roles and claims, condition operands and tags. Only \", \\, \n, \r and \t are recognized. Any other backslash sequence is kept verbatim so regular expression operands such as matches "^\d+$" keep their meaning. The line scanners that track string state - SplitTopLevel and the comment stripper - skip the character after a backslash so an escaped quote no longer ends the string. --- .../Screenplay/Parsing/CaptureParser.cs | 13 +- .../Screenplay/Parsing/ConditionParser.cs | 3 +- .../Screenplay/Parsing/DescriptionParser.cs | 5 +- .../Screenplay/Parsing/ExpressionParser.cs | 5 +- Source/DotNET/Screenplay/Parsing/LineText.cs | 6 +- .../DotNET/Screenplay/Parsing/PolicyParser.cs | 5 +- .../DotNET/Screenplay/Parsing/ScreenParser.cs | 11 +- .../DotNET/Screenplay/Parsing/SeedParser.cs | 5 +- .../Screenplay/Parsing/SourceLineSplitter.cs | 6 +- .../Screenplay/Parsing/SpecificationParser.cs | 5 +- .../Parsing/ValidationRuleParser.cs | 5 +- .../DotNET/Screenplay/Text/StringLiteral.cs | 130 ++++++++++++++++++ 12 files changed, 173 insertions(+), 26 deletions(-) create mode 100644 Source/DotNET/Screenplay/Text/StringLiteral.cs diff --git a/Source/DotNET/Screenplay/Parsing/CaptureParser.cs b/Source/DotNET/Screenplay/Parsing/CaptureParser.cs index 611137e..cc09aab 100644 --- a/Source/DotNET/Screenplay/Parsing/CaptureParser.cs +++ b/Source/DotNET/Screenplay/Parsing/CaptureParser.cs @@ -5,6 +5,7 @@ using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; using Cratis.Screenplay.Syntax.Captures; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -175,7 +176,7 @@ static List ParseMap(ParserContext context, SourceLin continue; } - translations.Add(new(translationMatch.Groups[1].Value, translationMatch.Groups[2].Value, translation.Location)); + translations.Add(new(StringLiteral.Unescape(translationMatch.Groups[1].Value), translationMatch.Groups[2].Value, translation.Location)); } } @@ -225,7 +226,7 @@ static List ParseMap(ParserContext context, SourceLin targets.Add(child.Content); } - return new(source, match.Groups[2].Value, targets, line.Location); + return new(source, StringLiteral.Unescape(match.Groups[2].Value), targets, line.Location); } static CaptureAppendSyntax? ParseAppend(ParserContext context, SourceLine line) @@ -359,7 +360,7 @@ static List ParseMap(ParserContext context, SourceLin } static string Unquote(string token) => - token.Length >= 2 && token.StartsWith('"') && token.EndsWith('"') ? token[1..^1] : token; + token.Length >= 2 && token.StartsWith('"') && token.EndsWith('"') ? StringLiteral.Unescape(token[1..^1]) : token; static void ParseMappings(ParserContext context, SourceLine parent, List mappings, List tags) { @@ -455,10 +456,10 @@ static bool TryParseMapping(ParserContext context, SourceLine line, List\\s*(\\w+)$", RegexOptions.None, 1000)] + [GeneratedRegex("^\"(" + StringLiteral.BodyPattern + ")\"\\s*=>\\s*(\\w+)$", RegexOptions.None, 1000)] private static partial Regex TranslationRegex(); - [GeneratedRegex("^split\\s+(\\S+)\\s+by\\s+\"([^\"]*)\"$", RegexOptions.None, 1000)] + [GeneratedRegex("^split\\s+(\\S+)\\s+by\\s+\"(" + StringLiteral.BodyPattern + ")\"$", RegexOptions.None, 1000)] private static partial Regex SplitRegex(); [GeneratedRegex(@"^[\w.]+$", RegexOptions.None, 1000)] @@ -467,7 +468,7 @@ static bool TryParseMapping(ParserContext context, SourceLine line, List)\s*(.+)$", RegexOptions.None, 1000)] diff --git a/Source/DotNET/Screenplay/Parsing/ConditionParser.cs b/Source/DotNET/Screenplay/Parsing/ConditionParser.cs index 01cdcb4..b255e74 100644 --- a/Source/DotNET/Screenplay/Parsing/ConditionParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ConditionParser.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -124,6 +125,6 @@ internal static partial class ConditionParser static List Tokenize(string text) => [.. TokenRegex().Matches(text).Select(_ => _.Value)]; - [GeneratedRegex("\"[^\"]*\"|==|!=|>=|<=|>|<|\\(|\\)|[\\w.$-]+", RegexOptions.None, 1000)] + [GeneratedRegex("\"" + StringLiteral.BodyPattern + "\"|==|!=|>=|<=|>|<|\\(|\\)|[\\w.$-]+", RegexOptions.None, 1000)] private static partial Regex TokenRegex(); } diff --git a/Source/DotNET/Screenplay/Parsing/DescriptionParser.cs b/Source/DotNET/Screenplay/Parsing/DescriptionParser.cs index f86697a..61e7161 100644 --- a/Source/DotNET/Screenplay/Parsing/DescriptionParser.cs +++ b/Source/DotNET/Screenplay/Parsing/DescriptionParser.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Text.RegularExpressions; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -33,7 +34,7 @@ internal static partial class DescriptionParser return existing; } - return Keep(context, line, existing, owner, match.Groups[1].Value); + return Keep(context, line, existing, owner, StringLiteral.Unescape(match.Groups[1].Value)); } static string? ParseFenced(ParserContext context, SourceLine line, string? existing, string owner) @@ -64,6 +65,6 @@ internal static partial class DescriptionParser return description; } - [GeneratedRegex(@"^description\s+""([^""]*)""$", RegexOptions.None, 1000)] + [GeneratedRegex(@"^description\s+""(" + StringLiteral.BodyPattern + @")""$", RegexOptions.None, 1000)] private static partial Regex DescriptionRegex(); } diff --git a/Source/DotNET/Screenplay/Parsing/ExpressionParser.cs b/Source/DotNET/Screenplay/Parsing/ExpressionParser.cs index baaa2b7..283b7f6 100644 --- a/Source/DotNET/Screenplay/Parsing/ExpressionParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ExpressionParser.cs @@ -6,6 +6,7 @@ using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; using Cratis.Screenplay.Syntax.Projections; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -141,8 +142,8 @@ public static ExpressionSyntax ParseMappingSource(string text, SourceLocation lo "true" => new(true, location), "false" => new(false, location), "null" => new(null, location), - _ when text.Length >= 2 && text.StartsWith('"') && text.EndsWith('"') => new(text[1..^1], location), - _ when text.Length >= 2 && text.StartsWith('\'') && text.EndsWith('\'') => new(text[1..^1], location), + _ when text.Length >= 2 && text.StartsWith('"') && text.EndsWith('"') => new(StringLiteral.Unescape(text[1..^1]), location), + _ when text.Length >= 2 && text.StartsWith('\'') && text.EndsWith('\'') => new(StringLiteral.Unescape(text[1..^1]), location), _ when NumberRegex().IsMatch(text) => new(double.Parse(text, CultureInfo.InvariantCulture), location), _ => null }; diff --git a/Source/DotNET/Screenplay/Parsing/LineText.cs b/Source/DotNET/Screenplay/Parsing/LineText.cs index 2643f3c..c634330 100644 --- a/Source/DotNET/Screenplay/Parsing/LineText.cs +++ b/Source/DotNET/Screenplay/Parsing/LineText.cs @@ -42,7 +42,11 @@ public static IEnumerable SplitTopLevel(string text, char separator) for (var i = 0; i < text.Length; i++) { var current = text[i]; - if (current == '"' && !inTemplate) + if (current == '\\' && inString && i + 1 < text.Length) + { + i++; + } + else if (current == '"' && !inTemplate) { inString = !inString; } diff --git a/Source/DotNET/Screenplay/Parsing/PolicyParser.cs b/Source/DotNET/Screenplay/Parsing/PolicyParser.cs index d0f303d..30cae01 100644 --- a/Source/DotNET/Screenplay/Parsing/PolicyParser.cs +++ b/Source/DotNET/Screenplay/Parsing/PolicyParser.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -166,7 +167,7 @@ public static PolicySyntax Parse(ParserContext context, SourceLine header) static bool IsString(string token) => token.Length >= 2 && token.StartsWith('"') && token.EndsWith('"'); - static string Unquote(string token) => token[1..^1]; + static string Unquote(string token) => StringLiteral.Unescape(token[1..^1]); static List Tokenize(string text) => [.. TokenRegex().Matches(text).Select(_ => _.Value)]; @@ -174,6 +175,6 @@ static List Tokenize(string text) => [GeneratedRegex(@"^policy\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)] private static partial Regex HeaderRegex(); - [GeneratedRegex("\"[^\"]*\"|\\(|\\)|[\\w.]+", RegexOptions.None, 1000)] + [GeneratedRegex("\"" + StringLiteral.BodyPattern + "\"|\\(|\\)|[\\w.]+", RegexOptions.None, 1000)] private static partial Regex TokenRegex(); } diff --git a/Source/DotNET/Screenplay/Parsing/ScreenParser.cs b/Source/DotNET/Screenplay/Parsing/ScreenParser.cs index 0ee70b2..a1f2702 100644 --- a/Source/DotNET/Screenplay/Parsing/ScreenParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ScreenParser.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -247,7 +248,7 @@ static ScreenSummarySyntax ParseSummary(ParserContext context, SourceLine line) } static string OperandText(Match match, int quotedGroup) => - match.Groups[quotedGroup].Success ? match.Groups[quotedGroup].Value : match.Groups[quotedGroup + 1].Value; + match.Groups[quotedGroup].Success ? StringLiteral.Unescape(match.Groups[quotedGroup].Value) : match.Groups[quotedGroup + 1].Value; [GeneratedRegex(@"^screen\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)] private static partial Regex HeaderRegex(); @@ -258,7 +259,7 @@ static string OperandText(Match match, int quotedGroup) => [GeneratedRegex(@"^action\s+([A-Za-z_]\w*)$", RegexOptions.None, 1000)] private static partial Regex ActionRegex(); - [GeneratedRegex("^label\\s+(?:\"([^\"]*)\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] + [GeneratedRegex("^label\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] private static partial Regex LabelRegex(); [GeneratedRegex(@"^navigate\s+to\s+(\w+)(?:\s+by\s+(\w+))?$", RegexOptions.None, 1000)] @@ -267,15 +268,15 @@ static string OperandText(Match match, int quotedGroup) => [GeneratedRegex(@"^[a-z_]\w*$", RegexOptions.None, 1000)] private static partial Regex SlotRegex(); - [GeneratedRegex("^title\\s+(?:\"([^\"]*)\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] + [GeneratedRegex("^title\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] private static partial Regex TitleRegex(); - [GeneratedRegex("^column\\s+([\\w.]+)(?:\\s+label\\s+(?:\"([^\"]*)\"|(\\$strings\\.\\w+(?:\\.\\w+)*)))?$", RegexOptions.None, 1000)] + [GeneratedRegex("^column\\s+([\\w.]+)(?:\\s+label\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*)))?$", RegexOptions.None, 1000)] private static partial Regex ColumnRegex(); [GeneratedRegex(@"^on\s+row-click\s+(navigate\s+to\s+.+)$", RegexOptions.None, 1000)] private static partial Regex RowClickRegex(); - [GeneratedRegex("^field\\s+([\\w.]+)\\s+label\\s+(?:\"([^\"]*)\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] + [GeneratedRegex("^field\\s+([\\w.]+)\\s+label\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] private static partial Regex FieldRegex(); } diff --git a/Source/DotNET/Screenplay/Parsing/SeedParser.cs b/Source/DotNET/Screenplay/Parsing/SeedParser.cs index a00f722..d8923f4 100644 --- a/Source/DotNET/Screenplay/Parsing/SeedParser.cs +++ b/Source/DotNET/Screenplay/Parsing/SeedParser.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -38,7 +39,7 @@ public static SeedSyntax Parse(ParserContext context, SourceLine header) continue; } - groups.Add(new(match.Groups[1].Value, ParseEvents(context, line), line.Location)); + groups.Add(new(StringLiteral.Unescape(match.Groups[1].Value), ParseEvents(context, line), line.Location)); } return new(groups, header.Location); @@ -82,7 +83,7 @@ static List ParseProperties(ParserContext context, Source return properties; } - [GeneratedRegex("^for\\s+\"([^\"]*)\"$", RegexOptions.None, 1000)] + [GeneratedRegex("^for\\s+\"(" + StringLiteral.BodyPattern + ")\"$", RegexOptions.None, 1000)] private static partial Regex ForRegex(); [GeneratedRegex(@"^[A-Z]\w*$", RegexOptions.None, 1000)] diff --git a/Source/DotNET/Screenplay/Parsing/SourceLineSplitter.cs b/Source/DotNET/Screenplay/Parsing/SourceLineSplitter.cs index ba92a08..823c812 100644 --- a/Source/DotNET/Screenplay/Parsing/SourceLineSplitter.cs +++ b/Source/DotNET/Screenplay/Parsing/SourceLineSplitter.cs @@ -39,7 +39,11 @@ static string StripComments(string text, bool hashComments) for (var i = 0; i < text.Length; i++) { var current = text[i]; - if (current == '"' && !inTemplate) + if (current == '\\' && inString && i + 1 < text.Length) + { + i++; + } + else if (current == '"' && !inTemplate) { inString = !inString; } diff --git a/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs b/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs index 09c4760..7d2bd5f 100644 --- a/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs +++ b/Source/DotNET/Screenplay/Parsing/SpecificationParser.cs @@ -5,6 +5,7 @@ using Cratis.Screenplay.Diagnostics; using Cratis.Screenplay.Syntax; using Cratis.Screenplay.Syntax.Specifications; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -136,7 +137,7 @@ static void ParseThen( var errorMatch = ThenErrorRegex().Match(line.Content); if (errorMatch.Success) { - thenErrors.Add(new(errorMatch.Groups[1].Value, line.Location)); + thenErrors.Add(new(StringLiteral.Unescape(errorMatch.Groups[1].Value), line.Location)); return; } @@ -222,7 +223,7 @@ static List ParseValues(ParserContext context, SourceLine [GeneratedRegex(@"^then\s+readmodel\s+([A-Z]\w*)$", RegexOptions.None, 1000)] private static partial Regex ThenReadModelRegex(); - [GeneratedRegex("^then\\s+error\\s+\"([^\"]*)\"$", RegexOptions.None, 1000)] + [GeneratedRegex("^then\\s+error\\s+\"(" + StringLiteral.BodyPattern + ")\"$", RegexOptions.None, 1000)] private static partial Regex ThenErrorRegex(); [GeneratedRegex(@"^([\w.]+)\s*=(?!=|>)\s*(.+)$", RegexOptions.None, 1000)] diff --git a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs index b7190a7..d3bf693 100644 --- a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Parsing; @@ -65,7 +66,7 @@ internal static partial class ValidationRuleParser return (content, null); } - var message = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + var message = match.Groups[1].Success ? StringLiteral.Unescape(match.Groups[1].Value) : match.Groups[2].Value; return (content[..match.Index].TrimEnd(), message); } @@ -109,7 +110,7 @@ internal static partial class ValidationRuleParser return (kind, value); } - [GeneratedRegex("\\bmessage\\s+(?:\"([^\"]*)\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] + [GeneratedRegex("\\bmessage\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] private static partial Regex MessageRegex(); [GeneratedRegex(@"^([\w.]+)\s+(.+)$", RegexOptions.None, 1000)] diff --git a/Source/DotNET/Screenplay/Text/StringLiteral.cs b/Source/DotNET/Screenplay/Text/StringLiteral.cs new file mode 100644 index 0000000..ab5025f --- /dev/null +++ b/Source/DotNET/Screenplay/Text/StringLiteral.cs @@ -0,0 +1,130 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; +using System.Text; + +namespace Cratis.Screenplay.Text; + +/// +/// Provides the escaping rules for the text carried by a Screenplay string literal. +/// +/// +/// Escaping is what keeps printing and compiling inverses of each other - a value holding a ", +/// a \ or a line break has to survive the trip out to .play text and back. The escape set is +/// deliberately small: \\, \", \n, \r and \t. Any other backslash sequence +/// is left verbatim by so regular expression operands such as matches "^\d+$" +/// keep their meaning. +/// +internal static class StringLiteral +{ + /// + /// The regular expression fragment matching the body of a string literal - everything between the + /// quotes, where an escaped quote does not terminate the literal. + /// + public const string BodyPattern = @"(?:[^""\\]|\\.)*"; + + static readonly SearchValues _needsEscaping = SearchValues.Create("\\\"\n\r\t"); + + /// + /// Renders a value as a quoted string literal, escaping the characters that would otherwise break it. + /// + /// The value to render. + /// The quoted and escaped literal text. + public static string Quote(string value) => $"\"{Escape(value)}\""; + + /// + /// Escapes the characters that cannot appear verbatim inside a string literal. + /// + /// The value to escape. + /// The escaped text. + public static string Escape(string value) + { + if (!NeedsEscaping(value)) + { + return value; + } + + var builder = new StringBuilder(value.Length + 8); + foreach (var character in value) + { + switch (character) + { + case '\\': + builder.Append(@"\\"); + break; + case '"': + builder.Append("\\\""); + break; + case '\n': + builder.Append(@"\n"); + break; + case '\r': + builder.Append(@"\r"); + break; + case '\t': + builder.Append(@"\t"); + break; + default: + builder.Append(character); + break; + } + } + + return builder.ToString(); + } + + /// + /// Resolves the escape sequences of a string literal body back to the value they represent. + /// + /// The literal body to unescape. + /// The unescaped value. + public static string Unescape(string value) + { + if (!value.Contains('\\', StringComparison.Ordinal)) + { + return value; + } + + var builder = new StringBuilder(value.Length); + for (var index = 0; index < value.Length; index++) + { + if (value[index] != '\\' || index + 1 == value.Length) + { + builder.Append(value[index]); + continue; + } + + switch (value[index + 1]) + { + case '\\': + builder.Append('\\'); + index++; + break; + case '"': + builder.Append('"'); + index++; + break; + case 'n': + builder.Append('\n'); + index++; + break; + case 'r': + builder.Append('\r'); + index++; + break; + case 't': + builder.Append('\t'); + index++; + break; + default: + builder.Append('\\'); + break; + } + } + + return builder.ToString(); + } + + static bool NeedsEscaping(string value) => value.AsSpan().ContainsAny(_needsEscaping); +} From bc52fcac71d7699dbe25feaaa4654a0ec35b5e08 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:43:50 +0200 Subject: [PATCH 2/5] Escape strings and format numerics invariantly when printing Printing a tree could produce text that does not compile back, breaking the inverse guarantee the printer documents. Non-double numeric literals fell through to a bare ToString(), so a decimal or float printed with the current culture's decimal separator - 5,5 on a comma-decimal machine, which then fails to parse. Route every IFormattable value through the invariant culture. String literals were interpolated between quotes with no escaping, so any value holding a " terminated the literal early. Every emission site now goes through StringLiteral.Quote: descriptions, validation messages, screen labels and titles, seed group ids, specification errors, capture translations and separators, policy roles and claims, and tags. --- .../Printing/ScreenplayPrinter.Captures.cs | 5 +++-- .../Printing/ScreenplayPrinter.Specifications.cs | 3 ++- .../Screenplay/Printing/ScreenplayPrinter.cs | 5 +++-- .../Screenplay/Printing/ScreenplaySyntaxText.cs | 16 +++++++++------- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.cs index 8d7710a..6215e67 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Captures.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.Captures; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Printing; @@ -86,13 +87,13 @@ void WriteCaptureMapOperation(ScreenplayWriter writer, CaptureMapOperationSyntax { foreach (var translation in translations) { - writer.Line($"\"{translation.From}\" => {translation.To}"); + writer.Line($"{StringLiteral.Quote(translation.From)} => {translation.To}"); } } break; case CaptureSplitSyntax split: - writer.Line($"split {ScreenplaySyntaxText.Expression(split.Source)} by \"{split.Separator}\""); + writer.Line($"split {ScreenplaySyntaxText.Expression(split.Source)} by {StringLiteral.Quote(split.Separator)}"); using (writer.Indent()) { foreach (var target in split.Targets) diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs index f0a2354..c3b4152 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.Specifications.cs @@ -3,6 +3,7 @@ using Cratis.Screenplay.Syntax; using Cratis.Screenplay.Syntax.Specifications; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Printing; @@ -47,7 +48,7 @@ void WriteSpecification(ScreenplayWriter writer, SpecificationSyntax specificati foreach (var error in specification.ThenErrors) { - writer.Line($"then error \"{error.Name}\""); + writer.Line($"then error {StringLiteral.Quote(error.Name)}"); } } } diff --git a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs index 921b87b..6a48dd4 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplayPrinter.cs @@ -5,6 +5,7 @@ using Cratis.Screenplay.Syntax.Captures; using Cratis.Screenplay.Syntax.Projections; using Cratis.Screenplay.Syntax.Specifications; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Printing; @@ -144,7 +145,7 @@ void WriteSeed(ScreenplayWriter writer, SeedSyntax seed) { foreach (var group in seed.Groups) { - writer.Line($"for \"{group.EventSourceId}\""); + writer.Line($"for {StringLiteral.Quote(group.EventSourceId)}"); using (writer.Indent()) { foreach (var @event in group.Events) @@ -348,7 +349,7 @@ void WriteDescription(ScreenplayWriter writer, string? description) if (!description.Contains('\n')) { - writer.Line($"description \"{description}\""); + writer.Line($"description {StringLiteral.Quote(description)}"); return; } diff --git a/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs b/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs index b08260b..650b404 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs @@ -7,6 +7,7 @@ using Cratis.Screenplay.Syntax; using Cratis.Screenplay.Syntax.Captures; using Cratis.Screenplay.Syntax.Projections; +using Cratis.Screenplay.Text; namespace Cratis.Screenplay.Printing; @@ -70,7 +71,7 @@ public static string TypeRef(TypeRefSyntax type) => public static string PolicyCondition(PolicyConditionSyntax condition) => condition switch { AuthenticatedConditionSyntax => "authenticated", - RoleConditionSyntax role => $"role \"{role.Role}\"", + RoleConditionSyntax role => $"role {StringLiteral.Quote(role.Role)}", ClaimConditionSyntax claim => ClaimCondition(claim), LogicalPolicyConditionSyntax logical => $"{PolicyCondition(logical.Left)} {Logical(logical.Operator)} {PolicyCondition(logical.Right)}", _ => string.Empty @@ -106,7 +107,7 @@ public static string ImpliedSubjectValidationRule(ValidationRuleSyntax rule) /// The value to render. /// The rendered operand text. public static string LocalizableString(string value) => - value.StartsWith("$strings.", StringComparison.Ordinal) ? value : $"\"{value}\""; + value.StartsWith("$strings.", StringComparison.Ordinal) ? value : StringLiteral.Quote(value); /// /// Renders the value of a to its surface form - bare for identifier-like @@ -133,7 +134,7 @@ public static string CaptureWhen(CaptureWhenSyntax when) CaptureWhenKind.Removed => "removed", CaptureWhenKind.PropertyChanged => properties[0], CaptureWhenKind.Changed => properties.Count > 0 ? properties[0] : string.Empty, - CaptureWhenKind.ValueTransition => $"{properties[0]} from \"{when.FromValue}\" to \"{when.ToValue}\"", + CaptureWhenKind.ValueTransition => $"{properties[0]} from {StringLiteral.Quote(when.FromValue ?? string.Empty)} to {StringLiteral.Quote(when.ToValue ?? string.Empty)}", CaptureWhenKind.LogicalOr => string.Join(" or ", properties), CaptureWhenKind.LogicalAnd => string.Join(" and ", properties), CaptureWhenKind.Expression => when.Expression ?? string.Empty, @@ -145,8 +146,9 @@ public static string CaptureWhen(CaptureWhenSyntax when) { null => "null", bool boolean => boolean ? "true" : "false", - string text => $"\"{text}\"", + string text => StringLiteral.Quote(text), double number => Number(number), + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? string.Empty }; @@ -188,12 +190,12 @@ static string ClaimCondition(ClaimConditionSyntax claim) { if (claim.MatchesSubject) { - return $"claim \"{claim.Claim}\" matches subject"; + return $"claim {StringLiteral.Quote(claim.Claim)} matches subject"; } var target = claim.Matches ?? string.Empty; - var value = BareWordRegex().IsMatch(target) ? target : $"\"{target}\""; - return $"claim \"{claim.Claim}\" matches {value}"; + var value = BareWordRegex().IsMatch(target) ? target : StringLiteral.Quote(target); + return $"claim {StringLiteral.Quote(claim.Claim)} matches {value}"; } static string ValidationRuleBody(ValidationRuleSyntax rule) From c1cc343c30539e16bf42906a41696beb85106a09 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:43:50 +0200 Subject: [PATCH 3/5] Add round-trip specs for escaped strings and invariant numerics Cover the two defects the existing invoicing round trip cannot catch: it has no quote in any value and runs under the invariant culture. The numeric spec prints decimal, float, int and long literals with CurrentCulture set to nb-NO and asserts both the invariant output and a clean print, compile, reprint cycle. The quoted-text specs cover description, validation message, screen label and tag, and a regular expression operand proves an unknown escape survives verbatim. --- .../for_ScreenplayPrinter/given/a_printer.cs | 22 ++++++ ...n_printing_a_regular_expression_operand.cs | 38 ++++++++++ ..._culture_uses_a_comma_decimal_separator.cs | 71 +++++++++++++++++++ .../and_it_is_a_description.cs | 36 ++++++++++ .../and_it_is_a_screen_label.cs | 37 ++++++++++ .../and_it_is_a_tag.cs | 37 ++++++++++ .../and_it_is_a_validation_message.cs | 38 ++++++++++ 7 files changed, 279 insertions(+) create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_regular_expression_operand.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_numeric_literals/and_the_culture_uses_a_comma_decimal_separator.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_description.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_screen_label.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_tag.cs create mode 100644 Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_validation_message.cs diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/given/a_printer.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/given/a_printer.cs index 1a97db0..ce4689c 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayPrinter/given/a_printer.cs +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/given/a_printer.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Screenplay.Printing; +using Cratis.Screenplay.Syntax; namespace Cratis.Screenplay.for_ScreenplayPrinter.given; @@ -15,4 +16,25 @@ void Establish() _compiler = new(); _printer = new(); } + + protected RoundTripResult RoundTrip(string source) + { + var original = _compiler.Compile(source); + var printed = _printer.Print(original.Value!); + var reparsed = _compiler.Compile(printed); + return new(original, printed, reparsed, _printer.Print(reparsed.Value!)); + } + + protected RoundTripResult RoundTrip(ApplicationSyntax application) + { + var printed = _printer.Print(application); + var reparsed = _compiler.Compile(printed); + return new(null, printed, reparsed, _printer.Print(reparsed.Value!)); + } + + public record RoundTripResult( + CompilationResult? Original, + string Printed, + CompilationResult Reparsed, + string PrintedAgain); } diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_regular_expression_operand.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_regular_expression_operand.cs new file mode 100644 index 0000000..8228d7d --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_regular_expression_operand.cs @@ -0,0 +1,38 @@ +// 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_a_regular_expression_operand : given.a_printer +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + invoiceNumber String + validate + invoiceNumber matches "^INV-\d{6}$" + """; + + const string Expected = @"^INV-\d{6}$"; + + given.a_printer.RoundTripResult _roundtrip; + + void Because() => _roundtrip = RoundTrip(Source); + + [Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty(); + [Fact] void should_keep_an_unknown_escape_verbatim() => Pattern(_roundtrip.Original!).ShouldEqual(Expected); + [Fact] void should_escape_the_backslash_when_printing() => _roundtrip.Printed.ShouldContain(@"matches ""^INV-\\d{6}$"""); + [Fact] void should_reparse_successfully() => _roundtrip.Reparsed.Success.ShouldBeTrue(); + [Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty(); + [Fact] void should_preserve_the_pattern() => Pattern(_roundtrip.Reparsed).ShouldEqual(Expected); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed); + + static object? Pattern(CompilationResult result) => + ((LiteralExpressionSyntax)result.Value!.Modules.Single().Features.Single().Slices.Single() + .Commands.Single().Validations.OfType().Single().Rules.Single().Value!).Value; +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_numeric_literals/and_the_culture_uses_a_comma_decimal_separator.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_numeric_literals/and_the_culture_uses_a_comma_decimal_separator.cs new file mode 100644 index 0000000..d5d3c52 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_numeric_literals/and_the_culture_uses_a_comma_decimal_separator.cs @@ -0,0 +1,71 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Globalization; +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; + +namespace Cratis.Screenplay.for_ScreenplayPrinter.when_printing_numeric_literals; + +public class and_the_culture_uses_a_comma_decimal_separator : given.a_printer +{ + given.a_printer.RoundTripResult _roundtrip; + string _separator; + + void Because() + { + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("nb-NO"); + try + { + _separator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; + _roundtrip = RoundTrip(Application()); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Fact] void should_have_printed_under_a_comma_decimal_culture() => _separator.ShouldEqual(","); + [Fact] void should_print_a_decimal_with_an_invariant_separator() => _roundtrip.Printed.ShouldContain("max 5.5"); + [Fact] void should_print_a_float_with_an_invariant_separator() => _roundtrip.Printed.ShouldContain("max 2.25"); + [Fact] void should_print_an_int_verbatim() => _roundtrip.Printed.ShouldContain("max 7"); + [Fact] void should_print_a_long_verbatim() => _roundtrip.Printed.ShouldContain("max 9"); + [Fact] void should_reparse_successfully() => _roundtrip.Reparsed.Success.ShouldBeTrue(); + [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); + + static ApplicationSyntax Application() => + new( + [], + [ + Concept("Amount", "Decimal", 5.5m), + Concept("Ratio", "Decimal", 2.25f), + Concept("Count", "Int", 7), + Concept("Total", "Int", 9L) + ], + [], + [], + SourceLocation.Start); + + static ConceptSyntax Concept(string name, string type, object value) => + new( + name, + type, + [], + [], + SourceLocation.Start, + [ + new DeclarativeValidateSyntax( + [ + new ValidationRuleSyntax( + ValidationRuleSyntax.ConceptValue, + ValidationRuleKind.Max, + new LiteralExpressionSyntax(value, SourceLocation.Start), + null, + SourceLocation.Start) + ], + SourceLocation.Start) + ]); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_description.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_description.cs new file mode 100644 index 0000000..4af63f4 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_description.cs @@ -0,0 +1,36 @@ +// 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.when_printing_text_containing_quotes; + +public class and_it_is_a_description : given.a_printer +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + description "He said \"hello\" loudly" + invoiceId Uuid + """; + + const string Expected = "He said \"hello\" loudly"; + + given.a_printer.RoundTripResult _roundtrip; + + void Because() => _roundtrip = RoundTrip(Source); + + [Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty(); + [Fact] void should_unescape_the_description() => Command(_roundtrip.Original!).Description.ShouldEqual(Expected); + [Fact] void should_escape_the_quotes_when_printing() => _roundtrip.Printed.ShouldContain("description \"He said \\\"hello\\\" loudly\""); + [Fact] void should_reparse_successfully() => _roundtrip.Reparsed.Success.ShouldBeTrue(); + [Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty(); + [Fact] void should_preserve_the_description() => Command(_roundtrip.Reparsed).Description.ShouldEqual(Expected); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed); + + static CommandSyntax Command(CompilationResult result) => + result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single(); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_screen_label.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_screen_label.cs new file mode 100644 index 0000000..7e4e397 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_screen_label.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.Syntax; + +namespace Cratis.Screenplay.for_ScreenplayPrinter.when_printing_text_containing_quotes; + +public class and_it_is_a_screen_label : given.a_printer +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateView InvoiceDashboard + screen InvoiceDashboard + action RegisterInvoice + label "Register a \"draft\" invoice" + """; + + const string Expected = "Register a \"draft\" invoice"; + + given.a_printer.RoundTripResult _roundtrip; + + void Because() => _roundtrip = RoundTrip(Source); + + [Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty(); + [Fact] void should_unescape_the_label() => Action(_roundtrip.Original!).Label.ShouldEqual(Expected); + [Fact] void should_escape_the_quotes_when_printing() => _roundtrip.Printed.ShouldContain("label \"Register a \\\"draft\\\" invoice\""); + [Fact] void should_reparse_successfully() => _roundtrip.Reparsed.Success.ShouldBeTrue(); + [Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty(); + [Fact] void should_preserve_the_label() => Action(_roundtrip.Reparsed).Label.ShouldEqual(Expected); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed); + + static ScreenActionSyntax Action(CompilationResult result) => + result.Value!.Modules.Single().Features.Single().Slices.Single().Screens.Single() + .Directives.OfType().Single(); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_tag.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_tag.cs new file mode 100644 index 0000000..fa37461 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_tag.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.Syntax; + +namespace Cratis.Screenplay.for_ScreenplayPrinter.when_printing_text_containing_quotes; + +public class and_it_is_a_tag : given.a_printer +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + event InvoiceRegistered + tag "release \"2024.1\"" + invoiceId Uuid + """; + + const string Expected = "release \"2024.1\""; + + given.a_printer.RoundTripResult _roundtrip; + + void Because() => _roundtrip = RoundTrip(Source); + + [Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty(); + [Fact] void should_unescape_the_tag() => TagValue(_roundtrip.Original!).ShouldEqual(Expected); + [Fact] void should_escape_the_quotes_when_printing() => _roundtrip.Printed.ShouldContain("tag \"release \\\"2024.1\\\"\""); + [Fact] void should_reparse_successfully() => _roundtrip.Reparsed.Success.ShouldBeTrue(); + [Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty(); + [Fact] void should_preserve_the_tag() => TagValue(_roundtrip.Reparsed).ShouldEqual(Expected); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed); + + static object? TagValue(CompilationResult result) => + ((LiteralExpressionSyntax)result.Value!.Modules.Single().Features.Single().Slices.Single() + .Events.Single().Tags!.Single().Value).Value; +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_validation_message.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_validation_message.cs new file mode 100644 index 0000000..6b6201e --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_text_containing_quotes/and_it_is_a_validation_message.cs @@ -0,0 +1,38 @@ +// 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.when_printing_text_containing_quotes; + +public class and_it_is_a_validation_message : given.a_printer +{ + const string Source = + """ + module Invoicing + feature InvoiceManagement + slice StateChange RegisterInvoice + command RegisterInvoice + invoiceNumber String + validate + invoiceNumber not empty message "Must start with \"INV-\"" + """; + + const string Expected = "Must start with \"INV-\""; + + given.a_printer.RoundTripResult _roundtrip; + + void Because() => _roundtrip = RoundTrip(Source); + + [Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty(); + [Fact] void should_unescape_the_message() => Rule(_roundtrip.Original!).Message.ShouldEqual(Expected); + [Fact] void should_escape_the_quotes_when_printing() => _roundtrip.Printed.ShouldContain("message \"Must start with \\\"INV-\\\"\""); + [Fact] void should_reparse_successfully() => _roundtrip.Reparsed.Success.ShouldBeTrue(); + [Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty(); + [Fact] void should_preserve_the_message() => Rule(_roundtrip.Reparsed).Message.ShouldEqual(Expected); + [Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed); + + static ValidationRuleSyntax Rule(CompilationResult result) => + result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single() + .Validations.OfType().Single().Rules.Single(); +} From 1501d774ba61b933e3e67bf657ca025120c50a9d Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:43:59 +0200 Subject: [PATCH 4/5] Highlight string escapes in the Monaco and VS Code grammars --- Source/Screenplay/Monaco/screenplay-language/tokens.ts | 4 ++-- .../VSCodeExtension/syntaxes/screenplay.tmLanguage.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Screenplay/Monaco/screenplay-language/tokens.ts b/Source/Screenplay/Monaco/screenplay-language/tokens.ts index 0e6f2b7..abc28da 100644 --- a/Source/Screenplay/Monaco/screenplay-language/tokens.ts +++ b/Source/Screenplay/Monaco/screenplay-language/tokens.ts @@ -80,8 +80,8 @@ export function createTokensProvider(subLanguages: SubLanguage[]): languages.IMo [/\$(?:context|eventContext|eventSourceId|causedBy)(?:\.\w+)*/, 'variable.predefined'], [/\$(?:env|secrets|strings)\.[\w.]+/, 'variable.predefined'], [/\$\.[\w.]*/, 'variable.predefined'], - [/"[^"]*"/, 'string'], - [/"[^"]*$/, 'string.invalid'], + [/"(?:[^"\\]|\\.)*"/, 'string'], + [/"(?:[^"\\]|\\.)*$/, 'string.invalid'], [/\d+(?:ms|s|m|h|d)\b/, 'number'], [/-?\d+\.\d+/, 'number.float'], [/-?\d+/, 'number'], diff --git a/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json b/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json index 4ac2da0..eb30585 100644 --- a/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json +++ b/Source/Screenplay/VSCodeExtension/syntaxes/screenplay.tmLanguage.json @@ -52,11 +52,11 @@ "name": "variable.language.screenplay" }, { - "match": "\"[^\"]*\"", + "match": "\"(?:[^\"\\\\]|\\\\.)*\"", "name": "string.quoted.double.screenplay" }, { - "match": "\"[^\"]*$", + "match": "\"(?:[^\"\\\\]|\\\\.)*$", "name": "invalid.illegal.unterminated-string.screenplay" }, { From 7a8dfb225332facb05b5308a5917f27bf51bc273 Mon Sep 17 00:00:00 2001 From: woksin Date: Wed, 29 Jul 2026 01:43:59 +0200 Subject: [PATCH 5/5] Document string escapes in the grammar and printing pages Give StringLiteral a real production with the recognized escapes, note that any other backslash sequence is kept verbatim, and state on the printing page that strings are escaped and numbers are culture-invariant. --- Documentation/screenplay/grammar.md | 20 +++++++++++++++++++- Documentation/screenplay/printing.md | 5 +++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Documentation/screenplay/grammar.md b/Documentation/screenplay/grammar.md index 38b5ecc..4354db7 100644 --- a/Documentation/screenplay/grammar.md +++ b/Documentation/screenplay/grammar.md @@ -401,7 +401,9 @@ FilePath = (* relative path string *) ; InlineBlock = LanguageTag, NL, "```", NL, { AnyLine }, "```", NL ; LanguageTag = "csharp" | "typescript" | "react" | "html" ; -StringLiteral = '"', { ? any char except '"' ? }, '"' ; +StringLiteral = '"', { StringChar }, '"' ; +StringChar = ? any char except '"', '\' and newline ? | Escape ; +Escape = "\", ( '"' | "\" | "n" | "r" | "t" ) ; Number = [ "-" ], { "0".."9" }, [ ".", { "0".."9" } ] ; Ident = Letter, { Letter | Digit | "_" } ; Letter = "A".."Z" | "a".."z" ; @@ -412,3 +414,19 @@ INDENT = ? increase in indentation level ? ; DEDENT = ? decrease in indentation level ? ; AnyLine = ? any text until newline ? ; ``` + +## String escapes + +A string literal carries `"` and `\` through the backslash escapes above, so a value survives the trip out to text and back: + +```play +description "He said \"hello\" loudly" +``` + +Only `\"`, `\\`, `\n`, `\r` and `\t` are recognized. Any other backslash sequence is kept verbatim - `\d` stays `\d` - which is what lets a regular expression operand read naturally: + +```play +invoiceNumber matches "^INV-\d{6}$" +``` + +The printer escapes on the way out, so a value holding a quote prints as `\"` and compiles back to the same value. That is what makes [printing](printing.md) the inverse of compiling. diff --git a/Documentation/screenplay/printing.md b/Documentation/screenplay/printing.md index 6159bc4..12b197f 100644 --- a/Documentation/screenplay/printing.md +++ b/Documentation/screenplay/printing.md @@ -46,6 +46,11 @@ var reprinted = printer.Print(compiler.Compile(printed).Value!); Because the two directions agree, you can read a `.play` file, adjust the syntax tree - rename a slice, add an event, change a mapping - and print it back out without disturbing the rest of the document. +Two details make the guarantee hold for values you did not type yourself: + +- **Strings are escaped.** A description, message, label or tag holding a `"` or a `\` prints with the backslash escapes described in [the grammar](grammar.md#string-escapes), and compiling that text gives the original value back. You never have to strip quotes out of a value before handing it to the printer. +- **Numbers are culture-invariant.** Every numeric literal - `decimal`, `float`, `int`, `long` or `double` - prints with a `.` decimal separator regardless of `CurrentCulture`, so output produced on a machine set to `nb-NO` compiles anywhere. + ## Generating from a model You do not have to start from text. Build the syntax nodes directly and print them to generate Screenplay from your own representation: