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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion Documentation/screenplay/grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" ;
Expand All @@ -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.
5 changes: 5 additions & 0 deletions Documentation/screenplay/printing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 7 additions & 6 deletions Source/DotNET/Screenplay/Parsing/CaptureParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -175,7 +176,7 @@ static List<CaptureMapOperationSyntax> 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));
}
}

Expand Down Expand Up @@ -225,7 +226,7 @@ static List<CaptureMapOperationSyntax> 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)
Expand Down Expand Up @@ -359,7 +360,7 @@ static List<CaptureMapOperationSyntax> 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<PropertyMappingSyntax> mappings, List<TagSyntax> tags)
{
Expand Down Expand Up @@ -455,10 +456,10 @@ static bool TryParseMapping(ParserContext context, SourceLine line, List<Propert
[GeneratedRegex(@"^([a-z_]\w*)\s*=\s*(.+)$", RegexOptions.None, 1000)]
private static partial Regex MapEntryRegex();

[GeneratedRegex("^\"([^\"]*)\"\\s*=>\\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)]
Expand All @@ -467,7 +468,7 @@ static bool TryParseMapping(ParserContext context, SourceLine line, List<Propert
[GeneratedRegex(@"^append\s+([A-Z]\w*)$", RegexOptions.None, 1000)]
private static partial Regex AppendRegex();

[GeneratedRegex("\"[^\"]*\"|[\\w.]+", RegexOptions.None, 1000)]
[GeneratedRegex("\"" + StringLiteral.BodyPattern + "\"|[\\w.]+", RegexOptions.None, 1000)]
private static partial Regex WhenTokenRegex();

[GeneratedRegex(@"^([\w.]+)\s*=(?!=|>)\s*(.+)$", RegexOptions.None, 1000)]
Expand Down
3 changes: 2 additions & 1 deletion Source/DotNET/Screenplay/Parsing/ConditionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.RegularExpressions;
using Cratis.Screenplay.Diagnostics;
using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Parsing;

Expand Down Expand Up @@ -124,6 +125,6 @@ internal static partial class ConditionParser
static List<string> 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();
}
5 changes: 3 additions & 2 deletions Source/DotNET/Screenplay/Parsing/DescriptionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Text.RegularExpressions;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Parsing;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
}
5 changes: 3 additions & 2 deletions Source/DotNET/Screenplay/Parsing/ExpressionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
};
Expand Down
6 changes: 5 additions & 1 deletion Source/DotNET/Screenplay/Parsing/LineText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ public static IEnumerable<string> 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;
}
Expand Down
5 changes: 3 additions & 2 deletions Source/DotNET/Screenplay/Parsing/PolicyParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.RegularExpressions;
using Cratis.Screenplay.Diagnostics;
using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Parsing;

Expand Down Expand Up @@ -166,14 +167,14 @@ 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<string> Tokenize(string text) =>
[.. TokenRegex().Matches(text).Select(_ => _.Value)];

[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();
}
11 changes: 6 additions & 5 deletions Source/DotNET/Screenplay/Parsing/ScreenParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Text.RegularExpressions;
using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Parsing;

Expand Down Expand Up @@ -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();
Expand All @@ -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)]
Expand All @@ -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();
}
5 changes: 3 additions & 2 deletions Source/DotNET/Screenplay/Parsing/SeedParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Text.RegularExpressions;
using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Parsing;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -82,7 +83,7 @@ static List<PropertyMappingSyntax> 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)]
Expand Down
6 changes: 5 additions & 1 deletion Source/DotNET/Screenplay/Parsing/SourceLineSplitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions Source/DotNET/Screenplay/Parsing/SpecificationParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -222,7 +223,7 @@ static List<PropertyMappingSyntax> 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)]
Expand Down
5 changes: 3 additions & 2 deletions Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Text.RegularExpressions;
using Cratis.Screenplay.Syntax;
using Cratis.Screenplay.Text;

namespace Cratis.Screenplay.Parsing;

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

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

namespace Cratis.Screenplay.Printing;

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

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

namespace Cratis.Screenplay.Printing;

Expand Down Expand Up @@ -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)}");
}
}
}
Expand Down
Loading
Loading