Repository: Behat/Gherkin
Pull request #316: add: Parse `Scenario` as a synonym for `Scenario Outline`
Author: login: acoulton
Created: 2025-05-06T16:26:06Z | Merged: 2025-05-16T09:50:40Z

In cucumber/gherkin, the `Scenario:` and `Scenario Outline:` keywords are interchangeable. Either may have (or not have) an `Examples:` table.

We still need to differentiate between `OutlineNode` and `ScenarioNode` in the parsed result, for BC and because Behat treats these differently at runtime.

However, we can safely parse a `Scenario:` that contains an `Examples:` table (which would have previously been invalid) to an `OutlineNode` - exactly as if the user had written `Scenario Outline:`.

This does require refactoring how we parse tags, because:

* Previously, we could make assumptions based on whether we were expecting (or had already seen) an Examples table.
* Now, we can't tell from the tags alone whether they belong to an Examples table, or mark the end of the current Scenario and the start of the next.

Therefore, we now scan ahead to predict which node the tags are attached to. This also allows us to throw a clearer exception if tags are found in unexpected places in the document.

Relates to #153


Diff:
diff --git a/src/Exception/UnexpectedTaggedNodeException.php b/src/Exception/UnexpectedTaggedNodeException.php
new file mode 100644
index 00000000..6bb977da
--- /dev/null
+++ b/src/Exception/UnexpectedTaggedNodeException.php
@@ -0,0 +1,36 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Behat\Gherkin\Exception;
+
+class UnexpectedTaggedNodeException extends ParserException
+{
+    public function __construct(
+        public readonly array $taggedToken,
+        public readonly ?string $sourceFile,
+    ) {
+        $msg = match ($this->taggedToken['type']) {
+            'EOS' => 'Unexpected end of file after tags',
+            default => sprintf(
+                '%s can not be tagged, but it is',
+                $taggedToken['type'],
+            ),
+        };
+
+        parent::__construct(
+            sprintf(
+                '%s on line: %d%s',
+                $msg,
+                $taggedToken['line'],
+                $this->sourceFile ? " in file: {$this->sourceFile}" : '',
+            ),
+        );
+    }
+}
diff --git a/src/Parser.php b/src/Parser.php
index 52bdbd41..5b4cff33 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -14,6 +14,7 @@
 use Behat\Gherkin\Exception\NodeException;
 use Behat\Gherkin\Exception\ParserException;
 use Behat\Gherkin\Exception\UnexpectedParserNodeException;
+use Behat\Gherkin\Exception\UnexpectedTaggedNodeException;
 use Behat\Gherkin\Node\BackgroundNode;
 use Behat\Gherkin\Node\ExampleTableNode;
 use Behat\Gherkin\Node\FeatureNode;
@@ -43,8 +44,6 @@ class Parser
     private $tags = [];
     private $languageSpecifierLine;
 
-    private $passedNodesStack = [];
-
     /**
      * Initializes parser.
      *
@@ -234,8 +233,6 @@ protected function parseFeature()
         $file = $this->file;
         $line = $token['line'];
 
-        $this->passedNodesStack[] = 'Feature';
-
         // Parse description, background, scenarios & outlines
         while ($this->predictTokenType() !== 'EOS') {
             $node = $this->parseExpression();
@@ -302,11 +299,8 @@ protected function parseBackground()
         $line = $token['line'];
 
         if (count($this->popTags()) !== 0) {
-            throw new ParserException(sprintf(
-                'Background can not be tagged, but it is on line: %d%s',
-                $line,
-                $this->file ? ' in file: ' . $this->file : ''
-            ));
+            // Should not be possible to happen, parseTags should have already picked this up.
+            throw new UnexpectedTaggedNodeException($token, $this->file);
         }
 
         // Parse description and steps
@@ -345,41 +339,7 @@ protected function parseBackground()
      */
     protected function parseScenario()
     {
-        $token = $this->expectTokenType('Scenario');
-
-        $title = trim($token['value'] ?? '');
-        $tags = $this->popTags();
-        $keyword = $token['keyword'];
-        $line = $token['line'];
-
-        $this->passedNodesStack[] = 'Scenario';
-
-        // Parse description and steps
-        $steps = [];
-        while (in_array($this->predictTokenType(), ['Step', 'Newline', 'Text', 'Comment'])) {
-            $node = $this->parseExpression();
-
-            if ($node instanceof StepNode) {
-                $steps[] = $this->normalizeStepNodeKeywordType($node, $steps);
-                continue;
-            }
-
-            if ($steps === [] && is_string($node)) {
-                $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
-                $title .= "\n" . $text;
-                continue;
-            }
-
-            if ($node === "\n") {
-                continue;
-            }
-
-            throw new UnexpectedParserNodeException('Step', $node, $this->file);
-        }
-
-        array_pop($this->passedNodesStack);
-
-        return new ScenarioNode(rtrim($title) ?: null, $tags, $steps, $keyword, $line);
+        return $this->parseScenarioOrOutlineBody($this->expectTokenType('Scenario'));
     }
 
     /**
@@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
 
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
+
+        return $node;
+    }
+
+    private function parseScenarioOrOutlineBody(array $token): OutlineNode|ScenarioNode
+    {
         $title = trim($token['value'] ?? '');
         $tags = $this->popTags();
         $keyword = $token['keyword'];
@@ -404,50 +378,94 @@ protected function parseOutline()
         // Parse description, steps and examples
         $steps = [];
 
-        $this->passedNodesStack[] = 'Outline';
-
         while (in_array($nextTokenType = $this->predictTokenType(), ['Step', 'Examples', 'Newline', 'Text', 'Comment', 'Tag'])) {
             if ($nextTokenType === 'Comment') {
                 $this->lexer->skipPredictedToken();
                 continue;
             }
 
+            if ($nextTokenType === 'Tag') {
+                // The only thing inside a Scenario / Scenario Outline that can be tagged is an Examples table
+                // Scan on to see what the tags are attached to - if it's not Examples then we must have reached the
+                // end of this scenario and be about to start a new one.
+                if ($this->validateAndGetNextTaggedNodeType() !== 'Examples') {
+                    break;
+                }
+            }
+
             $node = $this->parseExpression();
 
-            if ($node instanceof StepNode) {
-                $steps[] = $this->normalizeStepNodeKeywordType($node, $steps);
+            if ($steps === [] && is_string($node)) {
+                // Free text is only allowed before the first step (or when parsing Examples: which is done elsewhere)
+                $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
+                $title .= "\n" . $text;
                 continue;
             }
 
-            if ($node instanceof ExampleTableNode) {
-                $examples[] = $node;
-
+            if ($node === "\n") {
                 continue;
             }
 
-            if (count($steps) === 0 && is_string($node)) {
-                $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
-                $title .= "\n" . $text;
+            if ($examples === [] && $node instanceof StepNode) {
+                // Steps are only allowed before the first Examples table (if any)
+                $steps[] = $this->normalizeStepNodeKeywordType($node, $steps);
                 continue;
             }
 
-            if ($node === "\n") {
+            if ($node instanceof ExampleTableNode) {
+                // NB: It is valid to have a Scenario with Examples: but no Steps
+                $examples[] = $node;
                 continue;
             }
 
-            throw new UnexpectedParserNodeException('Step or Examples table', $node, $this->file);
+            throw new UnexpectedParserNodeException(
+                match ($examples) {
+                    [] => 'Step, Examples table, or end of Scenario',
+                    default => 'Examples table or end of Scenario',
+                },
+                $node,
+                $this->file,
+            );
         }
 
-        if (count($examples) === 0) {
-            throw new ParserException(sprintf(
-                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
-                rtrim($title),
-                $line,
-                $this->file ? ' in file: ' . $this->file : ''
-            ));
+        if ($examples !== []) {
+            return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
         }
 
-        return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
+        return new ScenarioNode(rtrim($title) ?: null, $tags, $steps, $keyword, $line);
+    }
+
+    /**
+     * Peek ahead to find the node that the current tags belong to.
+     *
+     * @throws UnexpectedTaggedNodeException if there is not a taggable node
+     */
+    private function validateAndGetNextTaggedNodeType(): string
+    {
+        $deferred = [];
+        try {
+            while (true) {
+                $deferred[] = $next = $this->lexer->getAdvancedToken();
+                $nextType = $next['type'];
+
+                if (in_array($nextType, ['Tag', 'Comment', 'Newline'], true)) {
+                    // These are the only node types allowed between tag node(s) and the node they are tagging
+                    continue;
+                }
+
+                if (in_array($nextType, ['Feature', 'Examples', 'Scenario', 'Outline'], true)) {
+                    // These are the only taggable node types
+                    return $nextType;
+                }
+
+                throw new UnexpectedTaggedNodeException($next, $this->file);
+            }
+        } finally {
+            // Rewind the lexer back to where it was when we started scanning ahead
+            foreach ($deferred as $token) {
+                $this->lexer->deferToken($token);
+            }
+        }
     }
 
     /**
@@ -464,8 +482,6 @@ protected function parseStep()
         $text = trim($token['text']);
         $line = $token['line'];
 
-        $this->passedNodesStack[] = 'Step';
-
         $arguments = [];
         while (in_array($predicted = $this->predictTokenType(), ['PyStringOp', 'TableRow', 'Newline', 'Comment'])) {
             if ($predicted === 'Comment' || $predicted === 'Newline') {
@@ -480,8 +496,6 @@ protected function parseStep()
             }
         }
 
-        array_pop($this->passedNodesStack);
-
         return new StepNode($keyword, $text, $arguments, $line, $keywordType);
     }
 
@@ -551,37 +565,13 @@ protected function parseTags()
     {
         $token = $this->expectTokenType('Tag');
 
+        // Validate that the tags are followed by a node that can be tagged
+        $this->validateAndGetNextTaggedNodeType();
+
         $this->guardTags($token['tags']);
 
         $this->tags = array_merge($this->tags, $token['tags']);
 
-        $possibleTransitions = [
-            'Outline' => [
-                'Examples',
-                'Step',
-            ],
-        ];
-
-        $currentType = '-1';
-        // check if that is ok to go inside:
-        if (!empty($this->passedNodesStack)) {
-            $currentType = $this->passedNodesStack[count($this->passedNodesStack) - 1];
-        }
-
-        $nextType = $this->predictTokenType();
-
-        if ($nextType === 'EOS') {
-            throw new ParserException(sprintf(
-                'Unexpected end of file after tags on line %d%s',
-                $token['line'],
-                $this->file ? ' in file: ' . $this->file : '',
-            ));
-        }
-
-        if (!isset($possibleTransitions[$currentType]) || in_array($nextType, $possibleTransitions[$currentType])) {
-            return $this->parseExpression();
-        }
-
         return "\n";
     }
 
diff --git a/tests/Cucumber/CompatibilityTest.php b/tests/Cucumber/CompatibilityTest.php
index 96c2f46d..409079ef 100644
--- a/tests/Cucumber/CompatibilityTest.php
+++ b/tests/Cucumber/CompatibilityTest.php
@@ -44,9 +44,8 @@ class CompatibilityTest extends TestCase
         'descriptions.feature' => 'Examples table descriptions not supported',
         'descriptions_with_comments.feature' => 'Examples table descriptions not supported',
         'extra_table_content.feature' => 'Table without right border triggers a ParserException',
-        'incomplete_scenario_outline.feature' => 'Scenario and Scenario outline not yet synonyms',
-        'padded_example.feature' => 'Scenario and Scenario outline not yet synonyms',
-        'scenario_outline.feature' => 'Scenario and Scenario outline not yet synonyms',
+        'incomplete_scenario_outline.feature' => 'Outline without Examples table triggers a ParserException',
+        'padded_example.feature' => 'Table padding is not trimmed as aggressively',
         'spaces_in_language.feature' => 'Whitespace not supported around language selector',
         'incomplete_feature_3.feature' => 'file with no feature keyword not handled correctly',
         'rule_without_name_and_description.feature' => 'Rule is wrongly parsed as Description',
diff --git a/tests/ParserExceptionsTest.php b/tests/ParserExceptionsTest.php
index 6700919f..56226eeb 100644
--- a/tests/ParserExceptionsTest.php
+++ b/tests/ParserExceptionsTest.php
@@ -205,7 +205,29 @@ public function testWrongTagPlacement(): void
         GHERKIN;
 
         $this->expectExceptionObject(
-            new ParserException('Expected Scenario, Outline or Background, but got Step on line: 6')
+            new ParserException('Step can not be tagged, but it is on line: 6')
+        );
+
+        $this->gherkin->parse($feature);
+    }
+
+    public function testStepsAfterExamples(): void
+    {
+        $feature = <<<'GHERKIN'
+        Feature: Fail to parse step after examples
+
+          Scenario: there are some steps after the examples table
+            Given I have some <thing>
+
+            Examples:
+            | thing    |
+            | whatever |
+
+            When I write more steps
+        GHERKIN;
+
+        $this->expectExceptionObject(
+            new ParserException('Expected Examples table or end of Scenario, but got Step on line: 10'),
         );
 
         $this->gherkin->parse($feature);
@@ -258,7 +280,7 @@ public function testWrongStepType(): void
         GHERKIN;
 
         $this->expectExceptionObject(
-            new ParserException('Expected Step, but got text: "        Aaand some step"')
+            new ParserException('Expected Step, Examples table, or end of Scenario, but got text: "        Aaand some step"'),
         );
 
         $this->gherkin->parse($feature);
@@ -310,7 +332,7 @@ public function testTableWithoutRightBorder(): void
         GHERKIN;
 
         $this->expectExceptionObject(
-            new ParserException('Expected Step, but got text: "        | foo | bar"')
+            new ParserException('Expected Step, Examples table, or end of Scenario, but got text: "        | foo | bar"'),
         );
 
         $this->gherkin->parse($feature);
diff --git a/tests/ParserTest.php b/tests/ParserTest.php
index ce4556d1..3e61f73e 100644
--- a/tests/ParserTest.php
+++ b/tests/ParserTest.php
@@ -59,7 +59,7 @@ public function testParserResetsTagsBetweenFeatures(): void
             );
         } catch (ParserException $e) {
             // expected - features cannot end with tags
-            $this->assertSame('Unexpected end of file after tags on line 5', $e->getMessage());
+            $this->assertSame('Unexpected end of file after tags on line: 5', $e->getMessage());
         }
         $feature2 = $parser->parse(
             <<<'FEATURE'
@@ -146,7 +146,7 @@ public static function parserErrorDataProvider(): iterable
         ];
 
         yield 'text content in outline' => [
-            'expectedException' => new ParserException('Expected Step or Examples table, but got text: "    nope" in file: /fake.feature'),
+            'expectedException' => new ParserException('Expected Step, Examples table, or end of Scenario, but got text: "    nope" in file: /fake.feature'),
             'featureText' => <<<'FEATURE'
             Feature:
               Scenario Outline:


Reviews:
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:27:14Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:28:38Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:29:42Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:32:51Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:34:22Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:35:10Z
user: login: carlos-granados
body: Approved, just a tiny comment, feel free to ignore it
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T16:00:27Z
user: login: stof
body: should we throw a ParserException if we find steps _after_ an examples table ? currently, the parser seems to allow mixing them AFAICT (unless the token type determination returns a different result for such case).
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T11:15:41Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:09:34Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T12:27:27Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T12:38:23Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T12:56:00Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T14:25:43Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T09:06:20Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T09:28:06Z
user: login: carlos-granados
body: @acoulton looking great overall, just a small comment
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T06:47:25Z
user: login: carlos-granados
body: All good now
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T09:12:59Z

Review comments:
diff_hunk: @@ -43,8 +44,6 @@ class Parser
     private $tags = [];
     private $languageSpecifierLine;
 
-    private $passedNodesStack = [];
path: src/Parser.php
user: login: acoulton
body: This was only used to track recursion into nodes for the purpose of making assumptions about whether we could take tags (and looks like it wasn't always unwound). This is now handled differently.
created_at: 2025-05-06T16:27:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -302,11 +299,8 @@ protected function parseBackground()
         $line = $token['line'];
 
         if (count($this->popTags()) !== 0) {
-            throw new ParserException(sprintf(
-                'Background can not be tagged, but it is on line: %d%s',
-                $line,
-                $this->file ? ' in file: ' . $this->file : ''
-            ));
+            // Should not be possible to happen, parseTags should have already picked this up.
+            throw new UnexpectedTaggedNodeException($token, $this->file);
path: src/Parser.php
user: login: acoulton
body: I have kept this for the moment out of caution, I don't think it can happen now (it doesn't with any of the test examples as Codecov has also detected).
created_at: 2025-05-06T16:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
+
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
path: src/Parser.php
user: login: acoulton
body: NB cucumber/gherkin doesn't throw in this case (see #153) - I have kept it for now but plan to look at that separately.
created_at: 2025-05-06T16:29:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,16 +419,43 @@ protected function parseOutline()
             throw new UnexpectedParserNodeException('Step or Examples table', $node, $this->file);
         }
 
-        if (count($examples) === 0) {
-            throw new ParserException(sprintf(
-                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
-                rtrim($title),
-                $line,
-                $this->file ? ' in file: ' . $this->file : ''
-            ));
+        if ($examples !== []) {
+            return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
         }
 
-        return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
+        return new ScenarioNode(rtrim($title) ?: null, $tags, $steps, $keyword, $line);
path: src/Parser.php
user: login: acoulton
body: Note that at the moment calling `->getKeyword()` on the parsed node returns a hardcoded result. So if the feature file used `Scenario:` but had an examples table then e.g. Behat's pretty printer would still print `Scenario Outline:`. 

We could add a new property into the nodes to record the actual keyword that we found, but I would be a little nervous about BC impact if the result of `OutlineNode->getKeyword()` can vary in case anyone is matching on / doing anything specific with that string.
created_at: 2025-05-06T16:32:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -205,7 +205,7 @@ public function testWrongTagPlacement(): void
         GHERKIN;
 
         $this->expectExceptionObject(
-            new ParserException('Expected Scenario, Outline or Background, but got Step on line: 6')
+            new ParserException('Step can not be tagged, but it is on line: 6')
path: tests/ParserExceptionsTest.php
user: login: acoulton
body: I think this new exception is clearer than the previous anyway. I have left the assertion as `ParserException` (rather than change to the actual `UnexpectedTaggedNodeException`) to prove that this is all still BC.
created_at: 2025-05-06T16:34:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,7 +59,7 @@ public function testParserResetsTagsBetweenFeatures(): void
             );
         } catch (ParserException $e) {
             // expected - features cannot end with tags
-            $this->assertSame('Unexpected end of file after tags on line 5', $e->getMessage());
+            $this->assertSame('Unexpected end of file after tags on line: 5', $e->getMessage());
path: tests/ParserTest.php
user: login: acoulton
body: Slight change to the format for consistency with the other exceptions.
created_at: 2025-05-06T16:35:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -438,16 +419,43 @@ protected function parseOutline()
             throw new UnexpectedParserNodeException('Step or Examples table', $node, $this->file);
         }
 
-        if (count($examples) === 0) {
-            throw new ParserException(sprintf(
-                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
-                rtrim($title),
-                $line,
-                $this->file ? ' in file: ' . $this->file : ''
-            ));
+        if ($examples !== []) {
+            return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
         }
 
-        return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
+        return new ScenarioNode(rtrim($title) ?: null, $tags, $steps, $keyword, $line);
+    }
+
+    /**
+     * Peek ahead to find the node that the current tags belong to.
+     *
+     * @throws UnexpectedTaggedNodeException if there is not a taggable node
+     */
+    private function predictNextTaggedNodeType(): string
+    {
+        $deferred = [];
+        try {
+            while (true) {
+                $deferred[] = $next = $this->lexer->getAdvancedToken();
+
+                if (in_array($next['type'], ['Tag', 'Comment', 'Newline'], true)) {
path: src/Parser.php
user: login: carlos-granados
body: Nitpick: I would probably assign `$next['type']` to a variable instead of accessing it several times
created_at: 2025-05-07T16:00:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,7 +46,6 @@ class CompatibilityTest extends TestCase
         'extra_table_content.feature' => 'Table without right border triggers a ParserException',
         'incomplete_scenario_outline.feature' => 'Scenario and Scenario outline not yet synonyms',
         'padded_example.feature' => 'Scenario and Scenario outline not yet synonyms',
path: tests/Cucumber/CompatibilityTest.php
user: login: stof
body: what about those 2 that were skipped for the same reason ?
created_at: 2025-05-13T11:12:47Z
author_association: MEMBER
diff_hunk: @@ -46,7 +46,6 @@ class CompatibilityTest extends TestCase
         'extra_table_content.feature' => 'Table without right border triggers a ParserException',
         'incomplete_scenario_outline.feature' => 'Scenario and Scenario outline not yet synonyms',
         'padded_example.feature' => 'Scenario and Scenario outline not yet synonyms',
path: tests/Cucumber/CompatibilityTest.php
user: login: acoulton
body: They now fail for unrelated reasons.

* `incomplete_scenario_outline.feature` because we throw if an Outline has no Examples table, but cucumber/gherkin does not (tracked in #153 and noted in the diff above).
* `padded_example.feature` because we don't trim table padding (specifically &nbsp; I think) the same as cucumber/gherkin - no issue for this yet (I plan to create one), and it has BC implications.

I'll update the reason descriptions in the testcase
created_at: 2025-05-13T14:09:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
+
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
path: src/Parser.php
user: login: stof
body: I think solving #153 fully is just a matter of removing that exception, by _fully_ making them synonyms.
created_at: 2025-05-14T12:27:27Z
author_association: MEMBER
diff_hunk: @@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
+
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
path: src/Parser.php
user: login: acoulton
body: I agree, I just mean it's a separate PR/change. It relates to other cases where we currently throw on partially-complete feature files that gherkin accepts and I think we should consider that in the round.
created_at: 2025-05-14T12:38:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
+
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
path: src/Parser.php
user: login: stof
body: well, to me, accepting outlines without examples (returning them as a ScenarioNode) is part of making `Scenario` and `Scenario Outline` be synonyms. Right now, you are making `Scenario` a synonym of `Scenario Outline` but `Scenario Outline` is not a synonym of `Scenario`, which is quite weird. Synonyms are meant to be bidirectional relations to me.
created_at: 2025-05-14T12:56:00Z
author_association: MEMBER
diff_hunk: @@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
+
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
path: src/Parser.php
user: login: acoulton
body: > Right now, you are making Scenario a synonym of Scenario Outline but Scenario Outline is not a synonym of Scenario

Which is why I was careful to title the PR "add: Parse Scenario as a synonym for Scenario Outline" and not "Treat Scenario and Scenario Outlines as synonyms" :laughing: 

> Synonyms are meant to be bidirectional relations to me.

Synonyms are very often only synonyms in context, I can think of lots of examples of synonyms that are not always bidirectional, or where one is a generalisation of a more specific term.

A `Scenario` with an `Examples` table is clearly equivalent to a `Scenario Outline`. Is a `Scenario Outline` (where the writer has taken care to use the specific term) without an `Examples` table **always** the same as a `Scenario`? I think that's open to debate (but of course we'll eventually follow cucumber/gherkin's opinion).

---
The bigger point is - I am trying to move forward with cucumber/gherkin parity (which has been stalled for years). Some things - like this PR - are very obviously just new features with no BC impact.

Others - like no longer throwing if the user said "Scenario Outline" but did not provide a table - involve changes to existing behaviour that is currently explicitly tested.

Therefore I want to split these changes up into separate commits / PRs so that we can properly review them and consider any BC impact. I do not want to ship another release that breaks people's CI because we overlooked something, so I think we should keep each change as incremental as possible.

I note that I said from the moment I opened the PR that I would be coming back to this specific point (which we also have an open issue for) so tbh I don't see why it should block approving / merging these changes.
created_at: 2025-05-14T14:25:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
+
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
path: src/Parser.php
user: login: stof
body: I would say that turning a parsing exception into a supported case won't break CI of anyone: if they have files matching that case, it will potentially _fix_ their CI. Turning invalid files into valid ones is generally not considered a BC break (otherwise, it would be impossible to add any new syntax)
And this is also exactly the kind of changes you are doing for files using `Scenario` that have an `Examples` table today (which are also a ParserException).

But anyway, I won't block the merge on that if you decide to keep it split.
created_at: 2025-05-15T09:06:20Z
author_association: MEMBER
diff_hunk: @@ -391,8 +351,22 @@ protected function parseScenario()
      */
     protected function parseOutline()
     {
-        $token = $this->expectTokenType('Outline');
+        $node = $this->parseScenarioOrOutlineBody($this->expectTokenType('Outline'));
+
+        if (!($node instanceof OutlineNode && $node->hasExamples())) {
+            throw new ParserException(sprintf(
+                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
+                $node->getTitle(),
+                $node->getLine(),
+                $this->file ? ' in file: ' . $this->file : ''
+            ));
+        }
path: src/Parser.php
user: login: acoulton
body: Fair points and broadly I agree, I just think there's a philosophical difference between changing behaviour that is undefined, and changing behaviour that we have explicit tests for. Hence I prefer the discipline of considering the second type of changes separately. The next PR will be very soon :smile: 
created_at: 2025-05-15T09:28:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -404,50 +378,94 @@ protected function parseOutline()
         // Parse description, steps and examples
         $steps = [];
 
-        $this->passedNodesStack[] = 'Outline';
-
         while (in_array($nextTokenType = $this->predictTokenType(), ['Step', 'Examples', 'Newline', 'Text', 'Comment', 'Tag'])) {
             if ($nextTokenType === 'Comment') {
                 $this->lexer->skipPredictedToken();
                 continue;
             }
 
+            if ($nextTokenType === 'Tag') {
+                // The only thing inside a Scenario / Scenario Outline that can be tagged is an Examples table
+                // Scan on to see what the tags are attached to - if it's not Examples then we must have reached the
+                // end of this scenario and be about to start a new one.
+                if ($this->predictNextTaggedNodeType() !== 'Examples') {
+                    break;
+                }
+            }
+
             $node = $this->parseExpression();
 
-            if ($node instanceof StepNode) {
-                $steps[] = $this->normalizeStepNodeKeywordType($node, $steps);
+            if ($steps === [] && is_string($node)) {
+                // Free text is only allowed before the first step (or when parsing Examples: which is done elsewhere)
+                $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
+                $title .= "\n" . $text;
                 continue;
             }
 
-            if ($node instanceof ExampleTableNode) {
-                $examples[] = $node;
-
+            if ($node === "\n") {
                 continue;
             }
 
-            if (count($steps) === 0 && is_string($node)) {
-                $text = preg_replace('/^\s{0,' . ($token['indent'] + 2) . '}|\s*$/', '', $node);
-                $title .= "\n" . $text;
+            if ($examples === [] && $node instanceof StepNode) {
+                // Steps are only allowed before the first Examples table (if any)
+                $steps[] = $this->normalizeStepNodeKeywordType($node, $steps);
                 continue;
             }
 
-            if ($node === "\n") {
+            if ($node instanceof ExampleTableNode) {
+                // NB: It is valid to have a Scenario with Examples: but no Steps
+                $examples[] = $node;
                 continue;
             }
 
-            throw new UnexpectedParserNodeException('Step or Examples table', $node, $this->file);
+            throw new UnexpectedParserNodeException(
+                match ($examples) {
+                    [] => 'Step, Examples table, or end of Scenario',
+                    default => 'Examples table or end of Scenario',
+                },
+                $node,
+                $this->file,
+            );
         }
 
-        if (count($examples) === 0) {
-            throw new ParserException(sprintf(
-                'Outline should have examples table, but got none for outline "%s" on line: %d%s',
-                rtrim($title),
-                $line,
-                $this->file ? ' in file: ' . $this->file : ''
-            ));
+        if ($examples !== []) {
+            return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
         }
 
-        return new OutlineNode(rtrim($title) ?: null, $tags, $steps, $examples, $keyword, $line);
+        return new ScenarioNode(rtrim($title) ?: null, $tags, $steps, $keyword, $line);
+    }
+
+    /**
+     * Peek ahead to find the node that the current tags belong to.
+     *
+     * @throws UnexpectedTaggedNodeException if there is not a taggable node
+     */
+    private function predictNextTaggedNodeType(): string
path: src/Parser.php
user: login: carlos-granados
body: This function actually does two things: validates that the next tagged node is correct and returns it. But the function name does not reflect the validation part, so when we call it and don't use the return value it looks weird. Also `predict` sounds a bit magic in my head 😆 . What about `validateAndGetNextTaggedNodeType()`?
created_at: 2025-05-16T06:46:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-06T16:26:45Z
body: ## [Codecov](https://app.codecov.io/gh/Behat/Gherkin/pull/316?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) Report
Attention: Patch coverage is `98.41270%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 96.70%. Comparing base [(`7eaf0ec`)](https://app.codecov.io/gh/Behat/Gherkin/commit/7eaf0ecc0a01252392045de36b2d3702bfe1094c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) to head [(`0816d7e`)](https://app.codecov.io/gh/Behat/Gherkin/commit/0816d7e4cc92e298723ccc3d6a588644a2b9b0f0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat).
> Report is 4 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/Behat/Gherkin/pull/316?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | Patch % | Lines |
|---|---|---|
| [src/Parser.php](https://app.codecov.io/gh/Behat/Gherkin/pull/316?src=pr&el=tree&filepath=src%2FParser.php&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat#diff-c3JjL1BhcnNlci5waHA=) | 97.87% | [1 Missing :warning: ](https://app.codecov.io/gh/Behat/Gherkin/pull/316?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##             master     #316      +/-   ##
============================================
+ Coverage     96.23%   96.70%   +0.47%     
- Complexity      598      602       +4     
============================================
  Files            35       36       +1     
  Lines          1751     1760       +9     
============================================
+ Hits           1685     1702      +17     
+ Misses           66       58       -8     
```

| [Flag](https://app.codecov.io/gh/Behat/Gherkin/pull/316/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | Coverage Δ | |
|---|---|---|
| [php8.1](https://app.codecov.io/gh/Behat/Gherkin/pull/316/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | `96.70% <98.41%> (+0.47%)` | :arrow_up: |
| [php8.1--with=symfony/yaml:^5.4](https://app.codecov.io/gh/Behat/Gherkin/pull/316/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | `96.70% <98.41%> (+0.47%)` | :arrow_up: |
| [php8.1--with=symfony/yaml:^6.4](https://app.codecov.io/gh/Behat/Gherkin/pull/316/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | `96.70% <98.41%> (+0.47%)` | :arrow_up: |
| [php8.2](https://app.codecov.io/gh/Behat/Gherkin/pull/316/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | `96.70% <98.41%> (+0.47%)` | :arrow_up: |
| [php8.3](https://app.codecov.io/gh/Behat/Gherkin/pull/316/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | `96.70% <98.41%> (+0.47%)` | :arrow_up: |
| [php8.4](https://app.codecov.io/gh/Behat/Gherkin/pull/316/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) | `96.70% <98.41%> (+0.47%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Behat/Gherkin/pull/316?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat).

author_association: NONE
user: login: acoulton
created_at: 2025-05-13T14:03:44Z
body: > should we throw a ParserException if we find steps after an examples table?

Yeah, probably - it does seem that (as now) we don't detect / fail in that situation. I'll double check what cucumber/gherkin does.
author_association: CONTRIBUTOR
user: login: acoulton
created_at: 2025-05-14T11:58:19Z
body: I checked and `@cucumber/gherkin` does indeed throw a Parser error if `Steps` follow `Examples:` - https://codesandbox.io/p/devbox/gherkin-parser-ast-forked-f9j44t?workspaceId=ws_Mrsa3f4At4n3w9MazShoyf

I have added a test for that and updated the Parser to detect and throw in this situation.
author_association: CONTRIBUTOR
user: login: stof
created_at: 2025-05-14T12:20:19Z
body: > I have added a test for that and updated the Parser to detect and throw in this situation.

I suggest contributing such a test for bad data to the cucumber testsuite as well, to make it part of the shared tests in the future.
author_association: MEMBER
user: login: acoulton
created_at: 2025-05-16T07:49:23Z
body: @carlos-granados good catch, thanks. The method started off just looking ahead, and I named it `predict` to follow the naming of other methods in this class & the Lexer. But then moved the validation & exception inside the method and didn't rename it. Fixed now.
author_association: CONTRIBUTOR