Repository: Behat/Gherkin (php)

Issue:
CucumberND table headers might be missing
The following line: https://github.com/Behat/Gherkin/blob/e016902ab9dcca51d72d22c64d1424a2d03e7b14/src/Behat/Gherkin/Loader/CucumberNDJsonAstLoader.php#L160

assumes that `tableHeader` is always set, but according to the spec schema, [tableHeader](https://github.com/cucumber/messages/blob/e1537b07e511feb6405ed9aa00261ff79d8a9710/jsonschema/GherkinDocument.json#L138) is [not required](https://github.com/cucumber/messages/blob/e1537b07e511feb6405ed9aa00261ff79d8a9710/jsonschema/GherkinDocument.json#L108), and therefore optional.

I discovered this while implementing PHPStan fixes, using hand-written schema in PHPStan types DSL:
```php
/**
 * @phpstan-type TExamples THasTags&array{
 *     location: TLocation,
 *     keyword: string,
 *     name: string,
 *     description: string,
 *     tableHeader?: TTableRow,              <-----
 *     tableBody: list<TTableRow>,
 *     id: string,
 * }
 */
```
What shall we do about this?

1. Make the PHPStan type mandatory (by removing the `?`) and generally ignore the code.
2. Make PHPStan ignore that line (`@phpstan-ignore-next-line`)
3. Skip that entire line (the top line shown above) when `$tableJson['tableHeader']` is not set`
4. Empty table header row?
5. Something else...?

I think option 3 sounds reasonable, but we need to sure it doesn't change behaviour.

Pull request description:
test: Handle optional tableHeader when loading NDJson examples
Closes #286 (refer there for details).

Patch:
diff --git a/src/Loader/CucumberNDJsonAstLoader.php b/src/Loader/CucumberNDJsonAstLoader.php
index 491109c..458b134 100644
--- a/src/Loader/CucumberNDJsonAstLoader.php
+++ b/src/Loader/CucumberNDJsonAstLoader.php
@@ -10,6 +10,7 @@
 
 namespace Behat\Gherkin\Loader;
 
+use Behat\Gherkin\Exception\NodeException;
 use Behat\Gherkin\Node\BackgroundNode;
 use Behat\Gherkin\Node\ExampleTableNode;
 use Behat\Gherkin\Node\FeatureNode;
@@ -153,23 +154,38 @@ class CucumberNDJsonAstLoader implements LoaderInterface
      */
     private static function getTables(array $items): array
     {
-        return array_map(
-            static function ($tableJson) {
-                $table = [];
+        return array_filter(
+            array_map(
+                static function ($tableJson): ?ExampleTableNode {
+                    if ($tableJson['tableBody'] === []) {
+                        return null;
+                    }
 
-                $table[$tableJson['tableHeader']['location']['line']] = array_column($tableJson['tableHeader']['cells'], 'value');
+                    if (!isset($tableJson['tableHeader'])) {
+                        throw new NodeException(
+                            sprintf(
+                                'Table header is required when a table body is provided for the example on line %s.',
+                                $tableJson['location']['line'],
+                            )
+                        );
+                    }
 
-                foreach ($tableJson['tableBody'] as $bodyRow) {
-                    $table[$bodyRow['location']['line']] = array_column($bodyRow['cells'], 'value');
-                }
+                    $table = [
+                        $tableJson['tableHeader']['location']['line'] => array_column($tableJson['tableHeader']['cells'], 'value'),
+                    ];
 
-                return new ExampleTableNode(
-                    $table,
-                    $tableJson['keyword'],
-                    self::getTags($tableJson)
-                );
-            },
-            array_values($items)
+                    foreach ($tableJson['tableBody'] as $bodyRow) {
+                        $table[$bodyRow['location']['line']] = array_column($bodyRow['cells'], 'value');
+                    }
+
+                    return new ExampleTableNode(
+                        $table,
+                        $tableJson['keyword'],
+                        self::getTags($tableJson)
+                    );
+                },
+                array_values($items)
+            )
         );
     }
 }


Test patch:
diff --git a/tests/Loader/CucumberNDJsonAstLoaderTest.php b/tests/Loader/CucumberNDJsonAstLoaderTest.php
new file mode 100644
index 0000000..df1acbb
--- /dev/null
+++ b/tests/Loader/CucumberNDJsonAstLoaderTest.php
@@ -0,0 +1,239 @@
+<?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 Tests\Behat\Gherkin\Loader;
+
+use Behat\Gherkin\Exception\NodeException;
+use Behat\Gherkin\Loader\CucumberNDJsonAstLoader;
+use Behat\Gherkin\Node\BackgroundNode;
+use Behat\Gherkin\Node\ExampleTableNode;
+use Behat\Gherkin\Node\FeatureNode;
+use Behat\Gherkin\Node\OutlineNode;
+use Behat\Gherkin\Node\ScenarioNode;
+use PHPUnit\Framework\TestCase;
+
+final class CucumberNDJsonAstLoaderTest extends TestCase
+{
+    private CucumberNDJsonAstLoader $loader;
+
+    protected function setUp(): void
+    {
+        $this->loader = new CucumberNDJsonAstLoader();
+    }
+
+    public function testStringResourcesAreSupported(): void
+    {
+        $this->assertTrue($this->loader->supports('a string'));
+    }
+
+    public function testValidLoading(): void
+    {
+        $file = $this->serializeCucumberMessagesToFile([
+            'gherkinDocument' => [
+                'feature' => [
+                    'location' => ['line' => 111],
+                    'name' => 'Feature with a valid Scenario',
+                    'description' => '',
+                    'keyword' => 'fea',
+                    'language' => 'en',
+                    'children' => [
+                        [
+                            'background' => [
+                                'location' => ['line' => 222],
+                                'keyword' => 'bac',
+                                'name' => 'Empty Background',
+                            ],
+                        ],
+                        [
+                            'scenario' => [
+                                'location' => ['line' => 333],
+                                'name' => 'Empty Scenario',
+                                'keyword' => 'sce',
+                                'examples' => [],
+                            ],
+                        ],
+                        [
+                            'scenario' => [
+                                'location' => ['line' => 444],
+                                'name' => 'Examples Scenario',
+                                'keyword' => 'out',
+                                'examples' => [
+                                    [
+                                        'location' => ['line' => 555],
+                                        'keyword' => 'exa',
+                                        'tableHeader' => [
+                                            'location' => ['line' => 666],
+                                            'cells' => [
+                                                ['value' => 'A'],
+                                                ['value' => 'B'],
+                                            ],
+                                        ],
+                                        'tableBody' => [
+                                            [
+                                                'location' => ['line' => 777],
+                                                'cells' => [
+                                                    ['value' => 'A1'],
+                                                    ['value' => 'B1'],
+                                                ],
+                                            ],
+                                            [
+                                                'location' => ['line' => 888],
+                                                'cells' => [
+                                                    ['value' => 'A2'],
+                                                    ['value' => 'B2'],
+                                                ],
+                                            ],
+                                        ],
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+
+        $features = $this->loader->load($file);
+
+        $this->assertEquals(
+            [
+                new FeatureNode(
+                    'Feature with a valid Scenario',
+                    null,
+                    [],
+                    new BackgroundNode('Empty Background', [], 'bac', 222),
+                    [
+                        new ScenarioNode('Empty Scenario', [], [], 'sce', 333),
+                        new OutlineNode('Examples Scenario', [], [], [
+                            new ExampleTableNode([
+                                666 => ['A', 'B'],
+                                777 => ['A1', 'B1'],
+                                888 => ['A2', 'B2'],
+                            ], 'exa'),
+                        ], 'out', 444),
+                    ],
+                    'fea',
+                    'en',
+                    $file,
+                    111,
+                ),
+            ],
+            $features,
+        );
+    }
+
+    public function testNonEmptyOutlineTableBodyRequiresTableHeader(): void
+    {
+        $file = $this->serializeCucumberMessagesToFile([
+            'gherkinDocument' => [
+                'feature' => [
+                    'location' => ['line' => 1],
+                    'name' => 'Feature with an invalid example table',
+                    'description' => '',
+                    'keyword' => 'feature',
+                    'language' => 'en',
+                    'children' => [
+                        [
+                            'scenario' => [
+                                'location' => ['line' => 2],
+                                'name' => 'Examples Scenario',
+                                'keyword' => 'outline',
+                                'examples' => [
+                                    [
+                                        'location' => ['line' => 3],
+                                        'keyword' => 'example',
+                                        'tableBody' => [
+                                            [
+                                                'location' => ['line' => 777],
+                                                'cells' => [
+                                                    ['value' => 'A1'],
+                                                    ['value' => 'B1'],
+                                                ],
+                                            ],
+                                            [
+                                                'location' => ['line' => 888],
+                                                'cells' => [
+                                                    ['value' => 'A2'],
+                                                    ['value' => 'B2'],
+                                                ],
+                                            ],
+                                        ],
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+
+        $this->expectExceptionObject(new NodeException('Table header is required when a table body is provided for the example on line 3.'));
+
+        $this->loader->load($file);
+    }
+
+    public function testEmptyOutlineTableBodyDoesNotRequireTableHeader(): void
+    {
+        $file = $this->serializeCucumberMessagesToFile([
+            'gherkinDocument' => [
+                'feature' => [
+                    'location' => ['line' => 1],
+                    'name' => 'Feature with an empty example table',
+                    'description' => '',
+                    'keyword' => 'feature',
+                    'language' => 'en',
+                    'children' => [
+                        [
+                            'scenario' => [
+                                'location' => ['line' => 2],
+                                'name' => 'Examples Scenario',
+                                'keyword' => 'outline',
+                                'examples' => [
+                                    [
+                                        'location' => ['line' => 3],
+                                        'keyword' => 'example',
+                                        'tableBody' => [],
+                                    ],
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+
+        $features = $this->loader->load($file);
+
+        $this->assertEquals(
+            [
+                new FeatureNode(
+                    'Feature with an empty example table',
+                    '',
+                    [],
+                    null,
+                    [
+                        new OutlineNode('Examples Scenario', [], [], [], 'outline', 2),
+                    ],
+                    'feature',
+                    'en',
+                    $file,
+                    1,
+                ),
+            ],
+            $features,
+        );
+    }
+
+    private function serializeCucumberMessagesToFile(mixed ...$messages): string
+    {
+        return 'data://application/x-ndjson;base64,'
+            . base64_encode(implode("\n", array_map(json_encode(...), $messages)) . "\n");
+    }
+}