Gherkin Grammar and ast-grep Language Contract
Date: 2026-09-13
Question
criv extracts Source symbols with tree-sitter and evaluates ADR policy patterns with ast-grep. Gherkin support needs the exact contract of both.
Answer
ast-grep registers no Gherkin language, and no usable tree-sitter Gherkin
grammar exists. Use the gherkin crate (MIT OR Apache-2.0, 0.16.0,
2026-04-23) for extraction. It costs 99,568 bytes of release binary. It gives
Rust structs, not tree-sitter nodes, so it keeps no comments, it does not
separate Scenario Outline from Scenario, and it returns nothing at all for a
file with one bad keyword. Localization changes only a keyword string; it
changes no type name.
An ADR policy.patterns rule therefore cannot reach a .feature file.
Behaviour policy needs another mechanism.
Which Gherkin parser criv should use
There is no Gherkin in ast-grep, so a tree-sitter grammar buys nothing
The ast-grep-language dependency and criv’s own tree-sitter-*
dependencies are separate lists. A tree-sitter grammar is only worth the cost
when it fills both lists, as
ADR-0119
did for Elixir. Gherkin can fill neither list, for the reasons below.
No tree-sitter-gherkin package is published anywhere
- crates.io returns no result for
tree-sitter-gherkin. Search:https://crates.io/api/v1/crates?q=tree-sitter-gherkin. - npm returns
{"error":"Not found"}fortree-sitter-gherkin. Request:https://registry.npmjs.org/tree-sitter-gherkin. - The
tree-sitterGitHub organization holds nogherkinrepository. Request:gh api orgs/tree-sitter/repos. - The
tree-sitter-grammarsGitHub organization holds nogherkinrepository. Request:gh api orgs/tree-sitter-grammars/repos. nvim-treesitterhas nogherkinentry. Request:gh api search/code -f q="gherkin repo:nvim-treesitter/nvim-treesitter"returnstotal_count: 0.
Only unpublished personal repositories exist. A GitHub search for
tree-sitter-gherkin returns nine, the largest with 6 stars.
The grammar that Helix uses cannot parse ordinary Gherkin
Helix pins
SamyAB/tree-sitter-gherkin
at revision 43873ee8de16476635b48d52c46f5b6407cb5c09. See
languages.toml,
lines 4630-4639. The repository is MIT, 6 stars, last pushed 2024-07-04.
Its
grammar.js
rejects valid Gherkin:
title: $ => /[A-Z][a-zA-Z ]+/. A title must start with a capital letter and hold only letters and spaces.Pay with <method>fails.place an orderfails. Any digit or punctuation fails.tag: $ => token(seq('@', /[a-z_]+/)). A tag must be lowercase.@Smokefails.@wip-2fails.feature: seq(optional($.tag), ...). One tag only.@billing @webfails.steps: seq(optional($.given_steps), $.when_step, $.then_steps). A scenario must hold at least oneWhenstep and at least oneThenstep, in that order. AGiven-only scenario fails.scenario_keywordischoice('Scenario', 'Example')with:. There is noScenario Outlinerule and noExamplesrule at all.- Every keyword is a literal English string. There is no
# language:rule.
Its
Cargo.toml
also states version = "0.0.1", tree-sitter = "~0.20.10", and
repository = "https://github.com/tree-sitter/tree-sitter-gherkin", which
does not exist. criv uses tree-sitter = "0.26.3", so the generated parser
would need regeneration.
The most complete grammar is a one-day project
DevAbdullahUk/tree-sitter-gherkin
(MIT) does cover Scenario Outline, Examples, doc strings, data tables,
multiple tags, and comments. Its rules are source_file, comment,
tag_list, tag, feature, rule_header, background, scenario,
examples, step, step_keyword, step_text, quoted_string,
parameter, text_fragment, name_line, description, narrative_line,
description_line, data_table, table_row, table_cell, doc_string.
It has no # language: rule.
gh api repos/DevAbdullahUk/tree-sitter-gherkin reports
created_at: 2026-07-14T20:03:30Z and pushed_at: 2026-07-14T20:29:50Z,
0 stars, and 0 forks. It was created and abandoned in 27 minutes. It is not
a maintenance story that criv can depend on.
The official cucumber parser has no Rust implementation
cucumber/gherkin is MIT, 403 stars,
and active (pushed_at: 2026-09-13). Its top level holds c, cpp,
dart, dotnet, elixir, go, java, javascript, perl, php,
python, and ruby. There is no rust directory. Request:
gh api repos/cucumber/gherkin/contents.
The parser is generated from
gherkin.berp
by the Berp tool. Generating a Rust target is new work, not a dependency.
Recommendation: the gherkin crate
gherkin 0.16.0, repository
cucumber-rs/gherkin.
| Fact | Value |
|---|---|
| Licence | MIT OR Apache-2.0 |
| Latest release | 0.16.0, 2026-04-23 |
| Previous releases | 0.15.0 (2025-12-12), 0.14.0 (2023-07-14) |
| Downloads | 16,932,413 |
| MSRV | 1.88 |
| Runtime dependencies | peg 0.6.3, textwrap 0.16, thiserror 2.0, typed-builder 0.23 |
| Transitive normal crates | 53 |
Facts from https://crates.io/api/v1/crates/gherkin,
https://crates.io/api/v1/crates/gherkin/0.16.0/dependencies, and the
crate’s own
Cargo.toml.orig.
Maintenance state: active but slow. Three releases in three years, with a
1,144-day gap between 0.14.0 and 0.15.0. The
changelog
shows real upstream alignment work in 0.15.0 and 0.16.0. It is the parser
that the cucumber crate uses, which explains the download count. It is not
the official cucumber parser; it is an independent pure-Rust reimplementation
by the cucumber-rs organization.
One stale point: it pins peg = "0.6.3", released 2019. The current peg
is 0.8.6 (2026-05-04). Source:
https://crates.io/api/v1/crates/peg.
Size cost against ADR-0015
Measured, not estimated. Two binaries were built in one crate with exactly
the release profile from
ADR-0015
(strip = true, opt-level = "z", lto = true, codegen-units = 1,
panic = "abort") on aarch64-apple-darwin, Rust edition 2024:
| Binary | Bytes |
|---|---|
| Baseline: read a file, print its length | 286,128 |
Same, plus gherkin::Feature::parse and a full AST walk | 385,696 |
| Delta | 99,568 |
99,568 bytes is 1.01% of the 9,815,840-byte criv binary that ADR-0015
records. This is small, and ADR-0015 asks that future size work “start with
dependency and feature analysis”. Two notes for that analysis:
textwrap 0.16.3pullsicu_segmenter 2.3.0,icu_collections,icu_locale_core,icu_provider,zerovec,yoke, andpotential_utf. Most of the 53 transitive crates come from this one path.gherkinexposes no feature to droptextwrap.gherkinhas a defaultparserfeature that addstyped-builder. Turning it off removesFeature::parseas well, so criv must keep it.
Node shapes
The gherkin crate has no node type. It has Rust structs, so criv gets a
typed tree, not a syntax tree. Field lists are from
src/lib.rs
at version 0.16.0.
Feature keyword: String
name: String
description: Option<String>
background: Option<Background>
scenarios: Vec<Scenario>
rules: Vec<Rule>
tags: Vec<String>
span: Span
position: LineCol
path: Option<PathBuf>
Rule keyword, name, description, background: Option<Background>,
scenarios: Vec<Scenario>, tags, span, position
Background keyword, name, description, steps: Vec<Step>, span, position
Scenario keyword, name, description, steps: Vec<Step>,
examples: Vec<Examples>, tags, span, position
Examples keyword, name: Option<String>, description,
table: Option<Table>, tags, span, position
Step keyword: String, ty: StepType, value: String,
docstring: Option<String>, table: Option<Table>, span, position
Table rows: Vec<Vec<String>>, span, position
StepType Given | When | Then
Span start: usize, end: usize
LineCol line: usize, col: usize
Observed output from the reference feature file below, produced by a binary
that links gherkin 0.16.0:
# a leading comment
@billing @web
Feature: Checkout
As a shopper I want to pay.
Background: A signed-in shopper
Given a signed-in shopper
Rule: One order per basket
Background: A full basket
Given a basket with 2 items
@smoke
Scenario: Place an order
Given a basket
# an inline comment
When I place the order
"""
a doc string
"""
Then the order exists
| name | value |
| a | 1 |
Scenario Outline: Pay with <method>
Given a basket
When I pay with <method>
Then the payment is <result>
@slow
Examples: Cards
| method | result |
| visa | accepted |
| amex | declined |
feature keyword="Feature" name="Checkout" tags=["billing","web"]
description=Some("As a shopper I want to pay.") background=true
scenarios=0 rules=1 span=Span{start:34,end:730}
position=LineCol{line:3,col:1}
rule keyword="Rule" name="One order per basket" tags=[]
background=true scenarios=2 span=Span{start:150,end:730}
position=LineCol{line:9,col:3}
scenario keyword="Scenario" name="Place an order" tags=["smoke"]
steps=3 examples=0 position=LineCol{line:14,col:5}
step keyword="Given " ty=Given value="a basket"
step keyword="When " ty=When value="I place the order"
docstring=Some("\na doc string\n")
step keyword="Then " ty=Then value="the order exists"
table=Some([["name","value"],["a","1"]])
scenario keyword="Scenario Outline" name="Pay with <method>" tags=[]
steps=3 examples=1 position=LineCol{line:25,col:5}
step keyword="Given " ty=Given value="a basket"
step keyword="When " ty=When value="I pay with <method>"
step keyword="Then " ty=Then value="the payment is <result>"
examples keyword="Examples" name=Some("Cards") tags=["slow"]
table=Some([["method","result"],["visa","accepted"],
["amex","declined"]])
Seven facts that the design must absorb:
- Scenario Outline is not a distinct type. Both branches of the parser
rule scenario()build aScenario. Onlykeyworddiffers, andexamplesis non-empty. Seesrc/parser.rs, the two alternatives ofrule scenario(). There is noScenarioTypeenum in 0.16.0. - Comments are discarded. Every comment rule in the PEG grammar is
wrapped in
quiet!{}and the result is dropped:rule comment() = quiet!{comment_no_nl() nl_eof()}. No struct has a comment field. A crivfeature:symbol can carry no doc comment. - Tags lose the
@.rule tag() = "@" s:tag_char()+ { s.join("") }returns["billing", "web"], not["@billing", "@web"]. - Step keywords keep a trailing space.
keyword="Given ". Block keywords do not:keyword="Scenario",keyword="Scenario Outline". spanexcludes leading tags.span.startis captured after the tag list, sospanfor the reference Feature starts at byte 34, after# a leading commentand@billing @web.positionis the line and column of the keyword, 1-based.StepTypenormalizesAnd,But, and*. OnlyGiven,When, andThenexist. The literal keyword survives inStep::keyword. Version 0.16.0 also treats anAnd-like orBut-like keyword at the start of a scenario as aGivenstep (#53).- Doc string content keeps its leading and trailing newline.
docstring=Some("\na doc string\n").rule docstring()accepts both"""and```delimiters and runstextwrap::dedenton the body. This is the only use oftextwrapin the crate.
Error recovery on an incomplete file
There is none. Feature::parse returns
Result<Feature, ParseError>, and Feature::parse_path returns
Result<Feature, ParseFileError>. A failure gives no partial Feature, so
criv would get zero symbols from the file. Measured cases:
| Input | Result |
|---|---|
Truncated last step (When I) | Parses. The partial step becomes value="I". |
Scenario with no name (Scenario:) | Parses. name="". |
One typed keyword (Scenaro:) | Error at 8:20: {"unknown keyword"}. No symbols. |
No Feature: header | Error at 1:14: {"unknown keyword"}. No symbols. |
| Empty file | Error at 2:1: {...}. No symbols. |
ParseError exposes only a Display impl,
"Error at {line}:{col}: {expected:?}". Its position and expected
fields are private, so criv cannot read the location without parsing the
message string. GherkinEnv also holds a last_error and a fatal_error
for EnvError::UnsupportedLanguage, EnvError::UnknownKeyword, and
EnvError::InconsistentCellCount, but those are crate-private
(pub(crate) last_error).
This contradicts the Elixir precedent. ADR-0119 requires that criv “use the partial Tree-sitter result for an Elixir file with parse errors” and that “safe declarations before and after the error remain available”. A PEG parser cannot do this. A typed keyword in one scenario silently removes every scenario in the file from the graph.
The truncated case is worse than a clean failure: the file parses, and the half-written step enters the graph with wrong text.
Whether ast-grep registers a Gherkin language
No. This is the decisive finding.
ast-grep-language exposes a closed enum, not a registry. All 28 variants
of SupportLang in version 0.44.1, which criv pins, are:
Bash, C, Cpp, CSharp, Css, Dart, Go, Elixir, Haskell, Hcl, Html, Java,
JavaScript, Json, Kotlin, Lua, Markdown, Nix, Php, Python, Ruby, Rust,
Scala, Solidity, Swift, Tsx, TypeScript, Yaml
Source: SupportLang in ast-grep-language-0.44.1/src/lib.rs, and
SupportLang::all_langs() in the same file. The published crate has one
module per language and there is no gherkin.rs. A grep -n feature over
that file returns nothing, so no extension table maps .feature. The
official
supported-languages reference
names no Gherkin, no Cucumber, and no .feature extension for the current
release either. (Its table also omits Dart, which the enum does hold, so
the enum is the authority.)
What this means for criv today
criv’s src/structural.rs types the language as SupportLang:
#![allow(unused)]
fn main() {
pub struct CompiledPolicy {
language: SupportLang,
matcher: CompiledMatcher,
}
}
Two consequences follow, both already in the code:
-
An ADR that declares
language: gherkinfails to compile its policy.parse_languagecallsSupportLang::from_strand reportsunsupported ast-grep language `gherkin`. -
Even with a valid language,
find_policies_batchskips a.featurefile with no diagnostic:#![allow(unused)] fn main() { for source_file in vault.source_files() { let Some(language) = SupportLang::from_path(source_file) else { continue; }; }SupportLang::from_pathmaps by extension and knows nofeature, so the file is dropped silently.
The dynamic-language loading path
ast-grep does have one, and it does not fit criv.
ast-grep-dynamic (MIT, 0.45.3, 2026-08-31,
https://crates.io/crates/ast-grep-dynamic) provides DynamicLang,
CustomLang, and DynamicLang::register(Vec<Registration>). A
Registration holds lang_name, lib_path, symbol, meta_var_char,
expando_char, and extensions. Registration calls libloading::Library
and dlopens a compiled tree-sitter dynamic library. The comment on
register states it “should be called exactly once before use”, so it is a
process-global one-shot.
The user-facing form is customLanguages in sgconfig.yml
(documentation,
sgconfig reference):
customLanguages:
mojo:
libraryPath: mojo.so
extensions: [mojo]
outlineRules: outline/mojo.yml
expandoChar: _
Three blockers:
- It needs a compiled
.so,.dylib, or.dllper target triple. criv ships one static binary per platform under ADR-0014. Shipping a sidecar library is a release-workflow change, not a dependency change. DynamicLangis a different type fromSupportLang.CompiledPolicywould need a language enum of its own.- It still needs a tree-sitter Gherkin grammar to compile, and none is usable.
The third path, for completeness
ast_grep_core::Language is a public trait
(ast-grep-core-0.44.1/src/language.rs) with kind_to_id, field_to_id,
and build_pattern. criv could implement it for a statically linked
Gherkin grammar and avoid both SupportLang and the dynamic loader. This
is the only path that keeps one static binary, but it too needs a usable
tree-sitter Gherkin grammar, so it is blocked by the same missing grammar.
Conclusion for behaviour policy
An ADR policy.patterns rule cannot reach a feature file. Behaviour policy
needs another mechanism. criv’s own Language enum
(src/source/graph.rs, line 332) is independent of SupportLang, so the
Source-graph half of
#199 is unaffected: adding
a Gherkin variant beside Rust, TypeScript, JavaScript, Python,
Go, and Elixir needs no ast-grep change.
Whether keyword localization changes the node names
No for the gherkin crate. Yes in effect for any tree-sitter grammar.
The gherkin crate loads the official keyword table. Its
src/languages.json
is a copy of
gherkin-languages.json
from the official repository, and build.rs generates a keyword table per
language code with heck, quote, and syn. GherkinEnv::new(language)
selects the table, and a # language: xx first line switches it at parse
time (rule language_directive() in src/parser.rs).
Measured, with the same structure as the reference file above and a
# language: fr header:
feature keyword="Fonctionnalité" name="Paiement" tags=["facturation"]
rule keyword="Règle" name="Une commande par panier"
scenario keyword="Scénario" name="Passer une commande" tags=["rapide"]
step keyword="Soit " ty=Given value="un panier"
step keyword="Quand " ty=When value="je passe la commande"
step keyword="Alors " ty=Then value="la commande existe"
scenario keyword="Plan du Scénario" name="Payer avec <methode>"
step keyword="Soit " ty=Given value="un panier"
examples keyword="Exemples" name=Some("Cartes")
The struct names are unchanged. StepType still resolves to Given,
When, and Then. Only the keyword: String field carries localized
text. The design rule follows directly:
Derive a criv symbol kind from the Rust type, never from
Step::keywordorScenario::keyword. A kind derived from the keyword string would be localized, andscenario:would becomescénario:in a French vault.
Two limits remain:
BackgroundisContextein French and is reached through the samebackgroundfield, so it needs no keyword read.ScenarioandScenario Outlinecan only be told apart by the keyword string or byexamples.is_empty(). The keyword string is localized, soexamples.is_empty()is the only localization-safe test, and it is wrong for aScenario Outlinethat has noExamplesblock yet.
For a tree-sitter grammar the answer is different. Every grammar reviewed
hardcodes English keyword literals ('Feature', 'Given ') and none has a
# language: rule, so a localized file does not parse at all. A grammar
that did support localization would have to either add one node kind per
language or use a scanner, and node names are the whole ast-grep pattern
surface.
Reproduction
Every measurement above comes from one throwaway crate:
[package]
name = "sizetest"
version = "0.1.0"
edition = "2024"
[dependencies]
gherkin = "0.16.0"
bin
name = "sizetest"
path = "src/main.rs"
bin
name = "baseline"
path = "src/baseline.rs"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
sizetest calls gherkin::Feature::parse and prints every field.
baseline reads the file and prints its length. Build with
cargo build --release and compare with stat -f %z.
Unconfirmed
- Whether any of the seven remaining personal
tree-sitter-gherkinrepositories is better than the two reviewed. OnlySamyAB/tree-sitter-gherkin(the Helix pin) andDevAbdullahUk/tree-sitter-gherkin(the most complete) were read. - The binary-size delta on Linux and Windows targets. Only
aarch64-apple-darwinwas measured. - Whether the
icu_*crates thattextwrappulls in are already in criv’s dependency graph. If they are, the real delta is smaller than 99,568 bytes. This needs a measurement inside criv, not in a throwaway crate. - Whether
gherkin0.16.0 handles a data table with an escaped\|cell the same way the official parser does. The changelog claims 0.15.0 fixed escape sequences (#47), but this was not tested against the officialtestdatacorpus incucumber/gherkin/testdata. - Whether
gherkinaccepts the Markdown-with-Gherkin format thatMARKDOWN_WITH_GHERKIN.mddefines. This is out of scope for #198 under ADR-0032, so it was not tested.