diff --git a/Documentation/screenplay/commands.md b/Documentation/screenplay/commands.md index c4283b7..72dff0b 100644 --- a/Documentation/screenplay/commands.md +++ b/Documentation/screenplay/commands.md @@ -77,6 +77,7 @@ Declarative validation covers the common cases without code: | `matches ` | `email matches email` | | `matches ""` | `invoiceNumber matches "^INV-[0-9]{6}$"` | | `all > ` (on collection) | `lines.quantity all > 0` | +| `rule ` | `orgNumber rule BeAValidOrganizationNumber` | Every rule carries a `message` shown when it fails: @@ -87,6 +88,21 @@ validate dueDate > today message "Due date must be in the future" ``` +### Rules whose logic is not expressible + +Not every rule is a comparison. A predicate — "is this a valid organization number", "is this still available" — has logic that lives in code, and the declarative shapes above cannot express it. + +Leaving it out is the worst option, because it makes the document lie: a property with two declarative rules and three predicates reads as a property with two rules, and a reader cannot tell "nothing further constrains this" from "the rest could not be written down". Name the rule instead: + +```screenplay +validate + orgNumber not empty message "Organization number is required" + orgNumber rule BeAValidOrganizationNumber message "Must be a valid organization number" + orgNumber rule BeUnique +``` + +The name is a reference into the implementation, not a declared construct — nothing resolves it, and the compiler does not check that anything called `BeAValidOrganizationNumber` exists. It is there so the document is honest about how constrained a value is, and so a reader has something more useful than "a rule was omitted". + Cross-field or complex rules drop into C#: ````screenplay diff --git a/Documentation/screenplay/grammar.md b/Documentation/screenplay/grammar.md index 281a354..ece46ef 100644 --- a/Documentation/screenplay/grammar.md +++ b/Documentation/screenplay/grammar.md @@ -196,7 +196,8 @@ RuleOp = "not empty" | "length", "==", Number | "matches", ( "email" | StringLiteral ) | "all", ">", Value - | "all", ">=", Value ; + | "all", ">=", Value + | "rule", Ident ; Value = Number | StringLiteral | "today" | "true" | "false" ; diff --git a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs index d3bf693..04dc40e 100644 --- a/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs +++ b/Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs @@ -84,6 +84,11 @@ internal static partial class ValidationRuleParser return (null, null); } + if (operand.Groups[1].Value == "rule") + { + return ParseNamedRule(context, operand.Groups[2].Value.Trim(), line); + } + var value = ExpressionParser.ParseMappingSource(operand.Groups[2].Value, line.Location); ValidationRuleKind? kind = operand.Groups[1].Value switch { @@ -110,12 +115,38 @@ internal static partial class ValidationRuleParser return (kind, value); } + /// + /// Parses a rule <Name> - a named predicate whose logic the document does not express. + /// + /// The to report diagnostics to. + /// The predicate name. + /// The the rule came from. + /// The rule kind and the name, or null when the name is not an identifier. + /// + /// The name is a reference into the implementation, not a declared entity - nothing resolves it. It is + /// there so a reader can tell that a constraint exists and what it is called, rather than seeing a + /// property that appears to carry no further rules. + /// + static (ValidationRuleKind? Kind, ExpressionSyntax? Value) ParseNamedRule(ParserContext context, string name, SourceLine line) + { + if (!NameRegex().IsMatch(name)) + { + context.Error($"Invalid rule name '{name}' in '{line.Content}' - expected 'rule ' with an identifier", line.Location); + return (null, null); + } + + return (ValidationRuleKind.Rule, new PathExpressionSyntax(name, line.Location)); + } + [GeneratedRegex("\\bmessage\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)] private static partial Regex MessageRegex(); [GeneratedRegex(@"^([\w.]+)\s+(.+)$", RegexOptions.None, 1000)] private static partial Regex RuleRegex(); - [GeneratedRegex(@"^(not empty|length ==|all >=|all >|matches|max|min|>=|<=|==|>|<)\s*(.*)$", RegexOptions.None, 1000)] + [GeneratedRegex(@"^(not empty|length ==|all >=|all >|matches|max|min|rule|>=|<=|==|>|<)\s*(.*)$", RegexOptions.None, 1000)] private static partial Regex OperandRegex(); + + [GeneratedRegex(@"^[A-Za-z_]\w*$", RegexOptions.None, 1000)] + private static partial Regex NameRegex(); } diff --git a/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs b/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs index 650b404..b865555 100644 --- a/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs +++ b/Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs @@ -215,6 +215,7 @@ static string ValidationRuleBody(ValidationRuleSyntax rule) ValidationRuleKind.Matches => $"matches {value}", ValidationRuleKind.AllGreaterThan => $"all > {value}", ValidationRuleKind.AllGreaterThanOrEqual => $"all >= {value}", + ValidationRuleKind.Rule => $"rule {value}", _ => value }; } diff --git a/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs b/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs index 7b06a09..4991cfc 100644 --- a/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs +++ b/Source/DotNET/Screenplay/Syntax/CommandSyntax.cs @@ -68,7 +68,12 @@ public enum ValidationRuleKind /// /// Every element of a collection must be greater than or equal to the operand. /// - AllGreaterThanOrEqual = 11 + AllGreaterThanOrEqual = 11, + + /// + /// The value must satisfy a named predicate whose logic lives outside the document. + /// + Rule = 12 } /// diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play b/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play index d6fc3ef..3107953 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/invoicing.play @@ -142,6 +142,7 @@ module Invoicing lines.unitPrice all >= 0 message "Line prices cannot be negative" currency length == 3 message "Currency must be a 3-letter ISO code" dueDate > today message "Due date must be in the future" + invoiceNumber rule BeUnusedInvoiceNumber message "Invoice number is already in use" validate csharp ``` diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_named_predicate_rule.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_named_predicate_rule.cs new file mode 100644 index 0000000..ebfb9da --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_named_predicate_rule.cs @@ -0,0 +1,44 @@ +// 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; + +public class when_compiling_a_named_predicate_rule : given.a_compiler +{ + const string Source = + """ + module Customers + feature Approval + slice StateChange ApproveCustomer + command ApproveCustomer + orgNumber String + + validate + orgNumber not empty message "Organization number is required" + orgNumber rule BeAValidOrganizationNumber message $strings.customers.orgNumberRequired + orgNumber rule BeUnique + """; + + CompilationResult _result; + IEnumerable _rules; + + void Because() + { + _result = _compiler.Compile(Source); + _rules = _result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single() + .Validations.OfType().Single().Rules; + } + + [Fact] void should_succeed() => _result.Success.ShouldBeTrue(); + [Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty(); + [Fact] void should_keep_every_rule() => _rules.Count().ShouldEqual(3); + [Fact] void should_recognize_the_named_rule() => Rule(1).Rule.ShouldEqual(ValidationRuleKind.Rule); + [Fact] void should_carry_the_predicate_name() => ((PathExpressionSyntax)Rule(1).Value!).Path.ShouldEqual("BeAValidOrganizationNumber"); + [Fact] void should_keep_the_localized_message() => Rule(1).Message.ShouldEqual("$strings.customers.orgNumberRequired"); + [Fact] void should_allow_a_named_rule_without_a_message() => Rule(2).Message.ShouldBeNull(); + [Fact] void should_keep_the_subject_of_the_named_rule() => Rule(2).Property.ShouldEqual("orgNumber"); + + ValidationRuleSyntax Rule(int index) => _rules.ElementAt(index); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_named_predicate_rule_without_a_name.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_named_predicate_rule_without_a_name.cs new file mode 100644 index 0000000..b1d0e79 --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_a_named_predicate_rule_without_a_name.cs @@ -0,0 +1,33 @@ +// 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; + +public class when_compiling_a_named_predicate_rule_without_a_name : given.a_compiler +{ + const string Source = + """ + module Customers + feature Approval + slice StateChange ApproveCustomer + command ApproveCustomer + orgNumber String + + validate + orgNumber rule + orgNumber rule "not an identifier" + """; + + CompilationResult _result; + + void Because() => _result = _compiler.Compile(Source); + + [Fact] void should_not_succeed() => _result.Success.ShouldBeFalse(); + [Fact] void should_report_both_rules() => _result.Diagnostics.Count().ShouldEqual(2); + [Fact] void should_report_them_as_errors() => _result.Diagnostics.All(_ => _.Severity == DiagnosticSeverity.Error).ShouldBeTrue(); + [Fact] void should_not_keep_any_rule() => _result.Value!.Modules.Single().Features.Single().Slices.Single() + .Commands.Single().Validations.OfType().Single().Rules.ShouldBeEmpty(); +} diff --git a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs index daae6dd..6f7aa42 100644 --- a/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs +++ b/Source/DotNET/Screenplay/for_ScreenplayCompiler/when_compiling_the_invoicing_sample.cs @@ -55,7 +55,8 @@ void Because() [Fact] void should_parse_the_event_tags() => RegisteredEvent.Tags!.Count().ShouldEqual(3); [Fact] void should_parse_the_static_event_tags() => RegisteredEvent.Tags!.Take(2).Select(_ => ((LiteralExpressionSyntax)_.Value).Value).ShouldContainOnly("invoicing", "billing"); [Fact] void should_parse_the_dynamic_event_tag() => ((ContextExpressionSyntax)RegisteredEvent.Tags!.Last().Value).Path.ShouldEqual("identity.id"); - [Fact] void should_parse_the_validation_rules() => RegisterCommand.Validations.OfType().Single().Rules.Count().ShouldEqual(7); + [Fact] void should_parse_the_named_predicate_rule() => RegisterCommand.Validations.OfType().Single().Rules.Count(_ => _.Rule == ValidationRuleKind.Rule).ShouldEqual(1); + [Fact] void should_parse_the_validation_rules() => RegisterCommand.Validations.OfType().Single().Rules.Count().ShouldEqual(8); [Fact] void should_parse_the_code_validation() => RegisterCommand.Validations.OfType().Count().ShouldEqual(1); [Fact] void should_parse_the_authorize_policies() => RegisterCommand.Authorize!.Policies.Select(_ => _.Name).ShouldContainOnly("CanManageInvoice", "IsAdultCustomer"); [Fact] void should_parse_the_concurrency_event_source() => RegisterCommand.Concurrency!.EventSource.ShouldBeTrue(); diff --git a/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_named_predicate_rule.cs b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_named_predicate_rule.cs new file mode 100644 index 0000000..14bcc2a --- /dev/null +++ b/Source/DotNET/Screenplay/for_ScreenplayPrinter/when_printing_a_named_predicate_rule.cs @@ -0,0 +1,47 @@ +// 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_named_predicate_rule : given.a_printer +{ + const string Source = + """ + concept OrganizationNumber : String + validate + rule BeAValidOrganizationNumber message "Must be a valid organization number" + + module Customers + feature Approval + slice StateChange ApproveCustomer + command ApproveCustomer + orgNumber String + + validate + orgNumber not empty + orgNumber rule BeAValidOrganizationNumber message $strings.customers.orgNumberRequired + orgNumber rule BeUnique + """; + + 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_print_the_named_rule() => _roundtrip.Printed.ShouldContain("orgNumber rule BeAValidOrganizationNumber message $strings.customers.orgNumberRequired"); + [Fact] void should_print_a_named_rule_without_a_message() => _roundtrip.Printed.ShouldContain("orgNumber rule BeUnique\n"); + [Fact] void should_print_the_implied_subject_form() => _roundtrip.Printed.ShouldContain("rule BeAValidOrganizationNumber message \"Must be a valid organization number\""); + [Fact] void should_preserve_the_predicate_name() => ((PathExpressionSyntax)Rule(1).Value!).Path.ShouldEqual("BeAValidOrganizationNumber"); + [Fact] void should_preserve_the_concept_rule() => ConceptRule().Rule.ShouldEqual(ValidationRuleKind.Rule); + + ValidationRuleSyntax Rule(int index) => + _roundtrip.Reparsed.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single() + .Validations.OfType().Single().Rules.ElementAt(index); + + ValidationRuleSyntax ConceptRule() => + _roundtrip.Reparsed.Value!.Concepts.Single().Validations!.OfType().Single().Rules.Single(); +} diff --git a/Source/Screenplay/Monaco/screenplay-language/completion-items.ts b/Source/Screenplay/Monaco/screenplay-language/completion-items.ts index a84a415..bd7fcea 100644 --- a/Source/Screenplay/Monaco/screenplay-language/completion-items.ts +++ b/Source/Screenplay/Monaco/screenplay-language/completion-items.ts @@ -92,6 +92,7 @@ export const validateItems: CompletionEntry[] = [ { label: 'max', insertText: '${1:property} max ${2:500} message "${3:message}"', documentation: 'Maximum length or value.' }, { label: 'min', insertText: '${1:property} min ${2:1} message "${3:message}"', documentation: 'Minimum length or value.' }, { label: 'matches', insertText: '${1:property} matches "${2:pattern}" message "${3:message}"', documentation: 'The property must match a regular expression.' }, + { label: 'rule', insertText: '${1:property} rule ${2:PredicateName} message "${3:message}"', documentation: 'Names a predicate whose logic lives in code — states that a constraint exists without expressing it.' }, { label: 'length ==', insertText: '${1:property} length == ${2:3} message "${3:message}"', documentation: 'The property must have an exact length.' }, { label: 'all >', insertText: '${1:collection}.${2:property} all > ${3:0} message "${4:message}"', documentation: 'Every element of a collection must satisfy the comparison.' }, ]; diff --git a/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts b/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts index 0c3db87..aad92f9 100644 --- a/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts +++ b/Source/Screenplay/Monaco/screenplay-language/keyword-docs.ts @@ -28,6 +28,7 @@ export const keywordDocs: Record = { produces: 'Declares the events a command emits — single, multiple, or conditional.', handler: 'A fully imperative command implementation — a `file ` reference or an inline `csharp` block, instead of `produces`.', when: 'Guards a produced event or a capture append with a condition.', + rule: 'Names a predicate whose logic lives in code — states that a constraint exists without expressing what it computes. Nothing resolves the name.', require: 'A policy condition: `authenticated`, `role "..."`, or `claim "..." matches ...`.', authenticated: 'Requires an authenticated caller.', role: 'Requires the caller to have the given role.', diff --git a/Source/Screenplay/Monaco/screenplay-language/language.ts b/Source/Screenplay/Monaco/screenplay-language/language.ts index 0faf402..dfc9b0e 100644 --- a/Source/Screenplay/Monaco/screenplay-language/language.ts +++ b/Source/Screenplay/Monaco/screenplay-language/language.ts @@ -54,6 +54,7 @@ export const clauseKeywords = [ 'message', 'not', 'empty', + 'rule', 'matches', 'all', 'max',