Skip to content

Modernize gherkin: spec-conformant parser, pickle compiler, cucumber-messages (3.0.0 proposal)#23

Open
tomasz-tomczyk wants to merge 34 commits into
cabbage-ex:nextfrom
tomasz-tomczyk:next
Open

Modernize gherkin: spec-conformant parser, pickle compiler, cucumber-messages (3.0.0 proposal)#23
tomasz-tomczyk wants to merge 34 commits into
cabbage-ex:nextfrom
tomasz-tomczyk:next

Conversation

@tomasz-tomczyk

@tomasz-tomczyk tomasz-tomczyk commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Hello! I'm Tomasz, I've contacted the maintainer of this GH org with the interest of updating both gherkin and cabbage packages to the latest & greatest standards. I've driven Claude through this, as it is indeed a lot of work, but thankfully the CCK from Cucumber makes this a great candidate for LLM tools IMHO!

What this is

A proposal to revive and modernize gherkin as the canonical Elixir Gherkin package. This is a near-total rewrite — please review it as a proposal with evidence, not a line-by-line diff. It targets next (not master) so nothing takes over master until we agree to integrate.

Highlights

  • Spec-conformant parser — hand-written recursive-descent AST parser proven against the Cucumber Compatibility Kit (CCK) cross-language goldens. Full parity.
  • Pickle compiler — compiles features into fully-resolved, runnable pickles (the same model the reference implementations use).
  • cucumber-messages — emits the NDJSON envelope protocol, jason-free.
  • i18n — 80 dialects via # language: headers.
  • Markdown — Gherkin-in-Markdown support (the last CCK gap, now closed).
  • Clean toolchainmix compile --warnings-as-errors clean; CI matrix Elixir 1.18 / 1.19 / 1.20.

Evidence

  • Conformance: CCK AST/Pickles 46/46, Errors 11/11, Markdown parity (see README scoreboard).
  • Gates: mix compile --warnings-as-errors clean · mix test --include conformance7 doctests, 99 tests, 0 failures · mix format --check-formatted clean.
  • A standalone transition/technical guide documents the architecture (Scanner/MarkdownScanner → AstParser → PickleCompiler → Gherkin.Message) and the deliberate choice of a hand-written parser over the reference's Berp-generated one.

Standing issues / PRs this addresses

This rewrite subsumes the following open items:

Notes

  • Versioned 3.0.0 (never published to Hex, so no unreleased split).
  • The downstream cabbage runner has a companion proposal PR that depends on this.
  • Not for merge yet — opening for review/discussion.

tomasz-tomczyk and others added 30 commits June 5, 2026 20:12
Vendored verbatim from cucumber/gherkin @ 374bc7a (see test/conformance/UPSTREAM.md):
46 good features (+ ast/pickles/tokens/source goldens), 11 bad features (+ errors
goldens), and the 80-language gherkin-languages.json dialect data. These are the
objective parity gate for the parser rewrite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cts)

Loads the vendored 80-language dialect data, caches it in :persistent_term, and
exposes keyword lookups per language/group (feature, scenario, step keywords, etc.).
Custom UnknownLanguageError matches the bad/invalid_language golden message. Fully
implemented and tested: asserts the 80-dialect count and en/fr/de keyword lookups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Type-only spine the parser fan-out builds against, modeled on the cucumber-messages
schema: Gherkin.Location on every node; Gherkin.AST.* nodes (GherkinDocument, Feature,
Rule, Background, Scenario, Examples, Step, DataTable, TableRow, TableCell, DocString,
Tag, Comment); Gherkin.Pickle + Gherkin.PickleStep. Gherkin.Message serializes envelopes
to NDJSON (source done; ast/pickle return :not_implemented). Gherkin.Pipeline behaviour
+ NotImplemented default give a config-swappable parser/compiler seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gherkin.Conformance is the single public entry point the harness drives; it routes
through the configurable Gherkin.Pipeline backend so a real parser raises the score
with no harness edits. conformance_test.exs grades every good/ and bad/ corpus file
(byte-exact after key-sort + uri normalization) and prints a baseline scoreboard
(AST 0/46, Pickles 0/46, Errors 0/11). Tagged :conformance + :pending so the default
mix test stays green; run via mix conformance / mix test --only conformance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ARCHITECTURE.md maps the module layout and the exact plug-in seams where the scanner,
AST parser, pickles compiler and NDJSON serializers land (Gherkin.Pipeline backend +
the two :not_implemented projections in Gherkin.Message), each mapped to the score
column it moves. ROADMAP.md / PARITY.md capture the full plan and the objective
conformance gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the scanner -> parser -> AST-serializer pipeline behind the
Gherkin.Pipeline behaviour, wired via config:

  * Gherkin.AstParser.Scanner   — one classified token per source line,
    dialect-driven keyword matching, # language: header, CRLF handling,
    table-cell + tag tokenization with escape/whitespace rules.
  * Gherkin.AstParser           — recursive-descent grammar producing the
    Gherkin.AST.* tree (feature/background/rule/scenario/outline/examples/
    steps/doc-strings/data-tables/tags/comments/descriptions).
  * Gherkin.AstParser.IdAssigner — post-order id numbering matching the
    reference cucumber AstBuilder (children, then tags, then node).
  * Gherkin.Message.AST          — projects the AST struct tree into the
    cucumber-messages GherkinDocument map shape (gherkin_document_envelope/1).

All 46 testdata/good AST goldens pass byte-for-byte. Pickle compilation is
left stubbed (next wave). Existing 41 tests stay green; mix format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the AST parser with structured error reporting matching the
reference parser's bad-path output:

  * Error recovery in the feature/children walk: unrecognized lines are
    recorded and skipped so multiple errors surface in one pass
    (multiple_parser_errors).
  * Unterminated doc-string detection -> unexpected-EOF error.
  * Whitespace-in-tags detection.
  * Tag-then-EOF expected-token set aligned with the reference
    (#TagLine, #RuleLine, #Comment, #Empty).
  * Errors sorted into source order; combined with semantic validation
    (inconsistent table cell count).

All 11 testdata/bad error goldens now pass. Adds scanner + parser unit
tests (30 new). AST stays 46/46; existing tests green; format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement Gherkin.AstParser.PickleCompiler and wire it as the pipeline's
compile_pickles/1 backend. The compiler:

  * emits one pickle per Scenario and one per Examples body row of an outline,
    expanding <placeholder>s in step text, data-table cells, doc-string content
    and media type, and the scenario name;
  * prepends background steps (feature background, then rule background) to each
    pickle in scope, but skips them for a step-less scenario (which still emits an
    empty pickle, matching the reference);
  * inherits and unions tags Feature -> Rule -> Scenario/Outline -> Examples;
  * resolves And/But/* (Conjunction) step types to the preceding non-conjunction
    step's type.

Pickle ids use their own counter that continues from max(AST id) + 1; for each
pickle every step is numbered before the pickle itself. astNodeIds link a pickle
to [scenario] (or [scenario, examples-row] for outlines) and a step to [step]
(or [step, examples-row]). Add a `location` field to %Gherkin.Pickle{} (scenario
location, or the examples body-row location for outlines) as the goldens require.

Covered by 18 unit tests exercising outline expansion, background prepend/skip,
tag union, conjunction resolution, and table/doc-string substitution.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Gherkin.Message.Pickle (a mechanical snake_case -> camelCase projection
mirroring Gherkin.Message.AST) and implement Gherkin.Message.pickle_envelope/1
to use it. Pickle steps project astNodeIds/id/text/type plus an optional
argument: a dataTable of cell values (no row ids, no cell locations) or a
docString of content + optional mediaType (no location, no delimiter). Tags
project to {astNodeId, name}. to_ndjson's key-sort + nil-drop yields byte-exact
golden output.

Drives the conformance Pickles column to 46/46 (AST 46/46, Errors 11/11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes the runtime jason dependency in favour of the stdlib JSON module (Elixir 1.18+). Serializer now builds sorted objects explicitly, delegating only scalar leaves to JSON.encode!, preserving byte-exact conformance output. Bumps elixir floor to ~> 1.18.
Gherkin.parse/2, parse!/2 and pickles/2 are the new public surface, backed
directly by Gherkin.AstParser.Pipeline (NOT via Application config, since Mix
does not load a dependency's config/config.exs — a downstream git/hex dep would
otherwise fall back to the stub). The optional config :gherkin, :pipeline
override is still honoured when present.

The original line-based parser surface (parse/1, parse_file/1, flatten/1,
scenarios_for/1, producing Gherkin.Elements.* structs) moves to Gherkin.Legacy,
kept for backwards compatibility. Add Gherkin.ParseError for the bang variants.

gherkin_test.exs retargeted at Gherkin.Legacy; new public_api_test.exs added.
102 tests (was 89); conformance unchanged (AST 46/46, Pickles 46/46, Errors 11/11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation

cabbage now consumes Gherkin.pickles/2, so the legacy line-based parser has no
remaining consumers. Delete it entirely:

  - Gherkin.Legacy (the moved-aside surface from the previous commit)
  - Gherkin.Parser and Gherkin.Parsers.* (FeatureParser, ScenarioParser,
    BackgroundParser, RuleParser, StepParser, DescriptionParser, TableParser,
    DocStringParser, TagParser)
  - Gherkin.Elements.* (Feature, Scenario, ScenarioOutline, Step, Line, Rule)
  - test/gherkin/parser_test.exs and test/gherkin/tag_parser_test.exs

Tag/outline/table behaviour these tests covered is exercised by the conformance
suite and the dedicated ast_parser/pickle_compiler tests. gherkin_test.exs is
rewritten against the public Gherkin.parse/2 + pickles/2 API. Stale doc comments
referencing the legacy modules are updated.

Gherkin.{parse/2, parse!/2, pickles/2} remain the canonical public API, backed
directly by Gherkin.AstParser.Pipeline. 78 tests; conformance unchanged
(AST 46/46, Pickles 46/46, Errors 11/11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the official Gherkin grammar (Description := (#Other | #Comment)+),
descriptions consume any line that is not one of the current context's
terminator tokens. In Feature/Rule-header position that includes step
keywords and `*` bullets (e.g. the CCK minimal.feature), which are
description text, not steps.

Make take_description context-aware via per-scope terminator sets that
mirror the reference parser's state machine:

  - Feature/Rule header: background/tag/scenario/rule lines terminate
  - Background:           step/tag/scenario/rule lines terminate
  - Scenario/Outline:     + examples line terminates
  - Examples:             table_row/tag/examples/scenario/rule terminate

Description text now matches the reference AstBuilder exactly: leading
empty lines dropped, interior blank lines preserved verbatim (raw
whitespace), trailing blanks trimmed, each line kept as full raw text
(no trailing trim, mirroring getLineText(0)).

Conformance unchanged: AST 46/46, Pickles 46/46, Errors 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implement the Markdown-with-Gherkin (MDG) dialect from cucumber/gherkin's
MARKDOWN_WITH_GHERKIN.md (referenced upstream commit 374bc7a8). A new
Gherkin.AstParser.MarkdownScanner emits the SAME token stream the existing
recursive-descent parser consumes, so MDG and the equivalent classic
.feature produce an identical AST/pickles. The scanner is a port of the
reference GherkinInMarkdownTokenMatcher:

  - `#{1,6} <keyword>:` headers -> Feature/Background/Rule/Scenario/Outline/
    Examples lines (column = indent + header-prefix length + 1)
  - first significant non-block line -> Feature line fallback (whole-line name,
    no keyword)
  - `[*+-] <step keyword>` list items -> steps (the `* ` star keyword is the
    bullet marker here, not a step keyword)
  - GFM table rows indented 2-5 spaces -> table rows; GFM separator rows
    (`| --- |`) and all other non-Gherkin prose are neutered to :empty
    (hence MDG descriptions are always empty, matching the reference)
  - fenced code blocks -> doc strings (longer fences treat shorter inner
    fences as literal body)
  - backtick-wrapped `@tag` -> tag lines (whitespace between tags is allowed)

API: Gherkin.parse/pickles take `markdown: true`; a `.md` :uri auto-detects
the dialect. Threaded through an optional Gherkin.Pipeline.parse/3 callback
and AstParser.parse/3; the conformance harness routes `.feature.md` uris to
the Markdown scanner.

Supporting fixes: the AST serializer omits a nil feature keyword (header-less
MDG feature); unescape_doc handles arbitrary-length backtick fences; the
whitespace-in-tags validation is skipped for Markdown tag lines.

Conformance: plain unchanged (AST 46/46, Pickles 46/46, Errors 11/11); the 5
vendored Markdown twins pass fully (AST 5/5, Pickles 5/5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Markdown-with-Gherkin scanner neutered every non-keyword line to
:empty, so MDG feature/rule/scenario descriptions were always empty. The
official MDG dialect does populate descriptions in one case: a GFM table
separator row (| --- |) is matched as a #Comment by the reference
GherkinInMarkdownTokenMatcher, and a #Comment opens the Description rule.
Once opened, the in-description parser state has no #Empty short-circuit,
so subsequent lines (including unindented table rows and blanks) are
collected as #Other description text, with trailing blanks trimmed.

This is the sole reason the CCK `markdown` sample's leading
"| boz | boo |" table row becomes the feature.description.

Changes:
  * markdown_scanner: classify a GFM separator row (any indent) as a
    :comment carrying `gfm_separator: true`, instead of neutering to
    :empty. Non-separator unindented pipe lines stay :empty.
  * ast_parser: a :comment now OPENS a description (started? = true),
    mirroring the reference grammar's start_rule("Description"); and
    collect_comments rejects gfm_separator comments so they never appear
    in gherkinDocument.comments (the reference gives them matchedType
    Empty).

Verified the full CCK markdown AST + pickles match the reference JS
implementation byte-for-byte (ids/uri aside). Conformance unchanged: AST
46/46, Pickles 46/46, Errors 11/11, Markdown AST 5/5, Pickles 5/5; the
5 markdown twins still emit empty descriptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
.tool-versions wrongly pinned Elixir 1.14.5-otp-26 / Erlang 26.0, which no
longer matches the dev baseline or the ~> 1.18 mix.exs requirement. Point it
at the actual baseline so mise resolves the right toolchain in this dir.

Remove the legacy CircleCI config; CI moves to GitHub Actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…retarget fork metadata

- Remove excoveralls from deps and the test_coverage: [tool: ExCoveralls]
  config; switch to built-in coverage via 'mix test --cover'. excoveralls was
  the last puller of jason, so jason is now fully gone from the dep tree.
- Set test_coverage summary threshold to 0 so --cover stays informational and
  never exits non-zero on coverage alone; the test + conformance suites are the
  CI gates.
- Migrate the conformance harness from Jason.decode! to the built-in JSON
  (Elixir 1.18+), the project's only remaining Jason use.
- Harden the conformance test: it now asserts 100% pass / 0 fail / 0 pending /
  0 error in every area (AST, Pickles, Errors + Markdown twins). 'mix conformance'
  now exits non-zero on any regression (verified: a corrupted golden yields exit 2).
- Retarget mix.exs package metadata at the fork (tomasz-tomczyk/gherkin) for
  source_url/homepage_url/links and maintainers, keeping MIT and a note that the
  hex package name / app: stays :gherkin (rename deferred).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gherkin.parse/2, parse!/2, pickles/2 and the Gherkin.Dialect public surface were
already fully specced. Cover the last gaps on Gherkin.Message: media_type_plain/0,
media_type_markdown/0, and encode_sorted/1. Verified clean under the Elixir 1.20
set-theoretic type checker (mix compile --warnings-as-errors) with all 19 gherkin
modules compiling with zero type warnings; mix test and mix conformance green on
both 1.18 and 1.20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the removed CircleCI config. Uses erlef/setup-beam@v1 across the three
supported Elixir/Erlang pairs from the official compatibility table:
  1.18.1 / OTP 27, 1.19.5 / OTP 28, 1.20.0-rc.6 / OTP 27.
1.20 is still an RC, pinned to the exact version the .tool-versions baseline uses.

Each leg caches deps + _build keyed on elixir/otp/mix.lock, then runs
mix deps.get, format --check-formatted, compile --warnings-as-errors, test, and
the conformance gate (mix conformance), which exits non-zero on any corpus
regression. Triggers on push and pull_request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old README documented a long-gone API (Gherkin.Elements.Feature, stream
parsing). Rewrite it to describe the current parser: the parse -> AST -> pickles
pipeline, the conformance status (AST/Pickles/Errors 46/46/11 + Markdown 5/5
against the official cucumber/gherkin corpus), Elixir 1.18+ / built-in JSON /
zero runtime deps, a GitHub Actions status badge, and accurate Gherkin.parse/2
and Gherkin.pickles/2 usage (the passing module doctests).

Add a clear attribution section: a fork of cabbage-ex/gherkin (orig. authors
Matt Widmann / Steve B / Max Marcon), MIT. Drop the dead Hex/coveralls/CircleCI
badges. Add the MIT LICENSE file the package metadata and README reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The conformance alias is mix test --only conformance, which must run in the
test env. Without def cli/preferred_envs, running mix conformance from a
default shell aborted with "mix test is running in the dev environment",
breaking the CI conformance gate. Add preferred_envs: [conformance: :test].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pipeline is fully shipped. Gherkin.AstParser.Pipeline is the only backend
and both entry points (public Gherkin, Gherkin.Conformance) now default to it
directly, so the stub backend and its sentinel value were unreachable.

- Delete Gherkin.Pipeline.NotImplemented and drop :not_implemented from the
  Gherkin.Pipeline callback return types.
- Default Gherkin.Conformance to Gherkin.AstParser.Pipeline; drop every
  :not_implemented branch from ast/pickles/errors_ndjson.
- Drop the not_implemented type, the _other fallback clauses, and the
  to_ndjson(:not_implemented) skip from Gherkin.Message.
- Trim config/config.exs: the :pipeline config line is no longer needed (both
  entry points default directly) and its comment was false (pickles shipped).
- Refresh stale moduledocs (Message, Conformance, dialect_test).

The optional :pipeline override (public API opt + config) is retained as a
test seam. Conformance + full test suite stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
keywordType derives purely from the keyword's dialect group(s); the previous
step accumulator was never read. Make it a 2-arity function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The old doc described a pre-implementation type-only spine and referenced
deleted legacy files (lib/gherkin/elements/*, parser.ex, parsers/*,
Gherkin.parse/1) and a stub backend returning :not_implemented. Replace it
with the actual scanner/markdown-scanner -> parser -> id-assigner -> pickle
compiler -> message serializer design, the Dialect i18n foundation, the single
AstParser.Pipeline backend, and the conformance harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feature_tag_errors/1 always returned [] — there is no feature-tag semantic
validation in the reference parser (tags are validated at scan time). Drop the
dead clause and its call from validate/1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
split_lines/1, drop_trailing_empty/1 and leading_space_count/1 were duplicated
byte-for-byte between Scanner and MarkdownScanner. Extract them into a shared
pure module Gherkin.AstParser.LineUtil so the two scanners cannot drift.

Also document the block-group try-order invariant in both scanners: only
scenario_outline-before-scenario is load-bearing; the differing :rule position
between the two lists is intentional and harmless (groups are disjoint).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rpus

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The recursive-descent parser already turns a Markdown table that precedes
the first heading into the Feature's description (the GFM separator row is a
match_Comment that opens the Description rule). But Gherkin.Conformance routed
.feature.md sources to the Markdown scanner with a bare function_exported?/3
guard, which returns false for a backend module that has not yet been loaded.
In a fresh VM the first .feature.md parse therefore fell through to the plain
.feature scanner, which rejects Markdown prose and emitted spurious parse
errors instead of the AST -- dropping feature.description.

Pair the guard with Code.ensure_loaded?/1 so routing is robust to module load
order. Vendor the CCK markdown sample into the conformance corpus and add a
regression test that purges the backend module to pin the routing fix; the
sample's feature.description is exactly "| boz | boo |", matching the
reference byte-for-byte.
tomasz-tomczyk and others added 4 commits June 9, 2026 20:51
Address review feedback comparing next vs upstream/master ahead of the
cabbage-ex/gherkin proposal:

- ci.yml: bump actions (checkout@v6, cache@v5); drop redundant comments
- mix.exs: remove `mix new` boilerplate and the package-name NOTE comment
- LICENSE: drop the "(fork)" qualifier
- README: lead with Highlights → Usage; drop Attribution/License prose
- message/{ast,pickle}.ex: make moduledocs self-sufficient (drop
  ARCHITECTURE.md references)
- Move ROADMAP.md and PARITY.md out of the repo (dev-process artifacts,
  now under ../); PARITY.md was also stale

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-audit cleanup (low-risk, conformance-gated):

- Promote the duplicated `location_map/3` and `put_optional/3` helpers to
  `Gherkin.Message` (@doc false) and import them in the AST and Pickle
  serializers, removing three copies of the location projection and two of
  put_optional. Behaviour unchanged (location_map now also accepts nil,
  matching the pickle path).
- Add an "Architecture" module map to the `Gherkin` moduledoc: the four
  stages (lex/parse/compile/serialize) mapped to their modules, plus a note
  that `Gherkin.Conformance` is test/CI-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1.20.0/1.20.1 are now released; drop the 1.20.0-rc.6 pin so the matrix
tracks stable, matching cabbage's CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Point @source_url (and the homepage that follows it) at the canonical
cabbage-ex/gherkin repo ahead of the proposal PR. Drop the now
self-referential "Upstream (cabbage-ex/gherkin)" package link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant