Skip to content

Commit 5e29d60

Browse files
committed
feat(dsl): repeat+N syntax for at-least-N iterations
Extends the existing `repeat+` shorthand (min=1, one-or-more) to accept an arbitrary minimum count: `repeat+2 <X>` requires at least 2 iterations, `repeat+5 <X>` at least 5, etc. The engine already supported any `Node.min` value (it's a `uint32_t`); only the DSL surface gated higher counts behind hand-writing the JSON form. **Use cases**: * Binary operators that need exactly two operands (and would currently reach for a `Choice` between "multi" and "single" forms or an awkward initial-operand + `repeat`-tail dance): OP: sequence dict { repeat+2 <OPERAND>:args[]=@ separator <OP_TOK>:op=@ } Wrapping the variadic form in `repeat+2` says "≥2 args required" in one quantifier rather than spreading it across multiple rules. * EDA spec cardinalities — `PIN` needs ≥1 ports, `MACRO` needs ≥0 pins, `NET` may require ≥2 ROUTED segments, etc. `repeat+1` (= the existing `repeat+`) and `repeat+2` cover the common cases. **Surface forms**: repeat <X> → min=0 (zero-or-more, classic PEG `*`) repeat+ <X> → min=1 (one-or-more, classic PEG `+`) repeat+N <X> → min=N (at-least-N; N ≥ 2) `repeat+` is shorthand for `repeat+1`. Save canonicalises to the shortest form: min=0 → `repeat`, min=1 → `repeat+`, min=N≥2 → `repeat+N`. Round-trip stable. **Meta-grammar change**: The `REPEAT_EXPR` rule's optional `?"+":min=1` becomes an optional Choice between two alternatives: REPEAT_PLUS_FORM: choice { <REPEAT_PLUS_N>, // "+N" — emits min from int <REPEAT_PLUS_ONE> // "+" alone — emits min=1 } REPEAT_PLUS_N: sequence { "+", int:min=@ } REPEAT_PLUS_ONE: sequence { "+":min=1 } `REPEAT_PLUS_N` first in source order so `+2` greedy-matches the digit; `REPEAT_PLUS_ONE` is the fallback for bare `+` via Choice-frame alt-failure recovery. Save dispatch picks `REPEAT_PLUS_ONE` for dict.min == 1 (Value-const discriminator matches) and falls to `REPEAT_PLUS_N` for higher values. **Trade-off — informational lint warning on the meta-grammar**: Adding the Choice introduces an LL(k) shared-prefix pattern (both alts start with `"+"`). The lint correctly flags it as informational since the previous `ddb274f` commit made the LL(k) warning unsilenceable. Updated the "bundled .rawast grammar is clean" test to allow informational warnings on this specific rule (`REPEAT_PLUS_FORM`) — same shape as the FastDRC constraint grammar pattern that motivated the lint clarification. Any *other* lint issue on the rawast grammar still fails the test. **Verification**: End-to-end test (`/tmp/test_repeat_n.rawast` with `repeat+2 int:nums[]=@`): parse "1, 2" → {nums: [1, 2]} ✓ parse "1, 2, 3" → {nums: [1, 2, 3]} ✓ parse "1" → error (min=2 not met) ✓ Round-trip via meta-grammar: source: `repeat+2 int:nums[]=@ separator ","` parsed dict: {min: 2, type: "repeat", value: ..., separator: ...} saved DSL: `repeat+2 <int>:nums[]=@ separator ","` ✓ All shipped grammars (json, rawast, gdsii, lefdef, tcl) still load cleanly. 216/216 C++ doctest + 47/47 Python pytest passing. **Docs**: * `docs/rawast-format.md` §4.8 — updated quantifier section with the new surface form, examples for min=0/1/2/5 cases, and explicit round-trip canonicalisation note.
1 parent ddb274f commit 5e29d60

4 files changed

Lines changed: 104 additions & 31 deletions

File tree

docs/rawast-format.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -762,20 +762,37 @@ cost is real, even if small), not bugs. There is no flag to suppress
762762
them; either restructure the alternatives to diverge earlier, or
763763
accept the warning as a permanent design note.
764764

765-
### 4.8 Repeat — `repeat expression [separator expression]`
765+
### 4.8 Repeat — `repeat[+[N]] expression [separator expression]`
766766

767767
```
768-
repeat_expr ::= 'repeat' expression ('separator' expression)?
768+
repeat_expr ::= 'repeat' ('+' int?)? expression ('separator' expression)?
769769
```
770770

771-
Zero-or-more iteration of the given expression, optionally separated
772-
between iterations by the separator expression. Produces no container
773-
of its own; the surrounding sequence's container catches the iteration
771+
Iteration of the given expression, optionally separated between
772+
iterations by the separator expression. Produces no container of its
773+
own; the surrounding sequence's container catches the iteration
774774
results.
775775

776+
The quantifier suffix sets a minimum required iteration count:
777+
778+
```
779+
repeat <X> // min=0 (zero-or-more)
780+
repeat+ <X> // min=1 (one-or-more)
781+
repeat+2 <X> // min=2 (at-least-two)
782+
repeat+5 <X> // min=5 (at-least-five), etc.
783+
```
784+
785+
`repeat+` is shorthand for `repeat+1`. The save direction canonicalises
786+
back to the same surface form on round-trip (min=1 emits `repeat+`,
787+
min=N for N≥2 emits `repeat+N`).
788+
789+
Examples:
790+
776791
```
777-
repeat <VALUE> separator "," // for arrays
778-
repeat <PAIR> separator "," // for dicts
792+
repeat <VALUE> separator "," // 0+ values, for arrays
793+
repeat <PAIR> separator "," // 0+ pairs, for dicts
794+
repeat+ <ITEM> // 1+ items (classic PEG `+`)
795+
repeat+2 <ARG>:args[]=@ separator "," // at least 2 args (e.g. a binary op)
779796
```
780797

781798
### 4.9 Optional — `?expression`

grammars/rawast.json

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -321,35 +321,47 @@
321321
"type": "key",
322322
"key": "repeat",
323323
"bindings": [
324-
{
325-
"name": "type",
326-
"value": "repeat"
327-
}
324+
{ "name": "type", "value": "repeat" }
328325
]
329326
},
327+
{ "type": "REPEAT_PLUS_FORM", "optional": true },
330328
{
331-
"type": "key",
332-
"key": "+",
333-
"optional": true,
329+
"type": "ITEM",
334330
"bindings": [
335-
{
336-
"name": "min",
337-
"value": 1
338-
}
331+
{ "name": "value", "value": "@" }
339332
]
340333
},
334+
{ "type": "SEPARATOR", "optional": true }
335+
]
336+
},
337+
"REPEAT_PLUS_FORM": {
338+
"type": "choice",
339+
"value": [
340+
{ "type": "REPEAT_PLUS_N" },
341+
{ "type": "REPEAT_PLUS_ONE" }
342+
]
343+
},
344+
"REPEAT_PLUS_N": {
345+
"type": "sequence",
346+
"value": [
347+
{ "type": "key", "key": "+" },
341348
{
342-
"type": "ITEM",
349+
"type": "int",
343350
"bindings": [
344-
{
345-
"name": "value",
346-
"value": "@"
347-
}
351+
{ "name": "min", "value": "@" }
348352
]
349-
},
353+
}
354+
]
355+
},
356+
"REPEAT_PLUS_ONE": {
357+
"type": "sequence",
358+
"value": [
350359
{
351-
"type": "SEPARATOR",
352-
"optional": true
360+
"type": "key",
361+
"key": "+",
362+
"bindings": [
363+
{ "name": "min", "value": 1 }
364+
]
353365
}
354366
]
355367
},

grammars/rawast.rawast

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,32 @@ CHOICE_EXPR: sequence {
111111
"{", <ITEMS>:value=@, "}"
112112
}
113113

114+
// `repeat` quantifier syntax:
115+
// repeat <X> → min=0 (zero-or-more, classic PEG `*`)
116+
// repeat+ <X> → min=1 (one-or-more, classic PEG `+`)
117+
// repeat+N <X> → min=N (at least N occurrences, N ≥ 2)
118+
//
119+
// The two `+`-prefixed forms are a Choice. Save dispatch picks
120+
// `REPEAT_PLUS_ONE` for dict.min == 1 (specific const matches) and
121+
// falls through to `REPEAT_PLUS_N` for higher values. Parse tries
122+
// `REPEAT_PLUS_N` first so `+2` greedy-matches the digit; the engine
123+
// falls back to `REPEAT_PLUS_ONE` for bare `+` via Choice-frame
124+
// alt-failure recovery.
114125
REPEAT_EXPR: sequence {
115126
"repeat":type="repeat",
116-
?"+":min=1,
127+
?<REPEAT_PLUS_FORM>,
117128
<ITEM>:value=@,
118129
?<SEPARATOR>
119130
}
120131

132+
REPEAT_PLUS_FORM: choice {
133+
<REPEAT_PLUS_N>,
134+
<REPEAT_PLUS_ONE>
135+
}
136+
137+
REPEAT_PLUS_N: sequence { "+", int:min=@ }
138+
REPEAT_PLUS_ONE: sequence { "+":min=1 }
139+
121140
SEPARATOR: sequence { "separator", <ITEM>:separator=@ }
122141

123142
// Key literal. Two surface forms with distinct `type` values so save

tests/test_linter.cpp

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
#include <rawast/parsers.hpp>
66

77
#include <memory>
8+
#include <set>
9+
#include <string>
810

911
using namespace rawast;
1012

@@ -359,7 +361,14 @@ TEST_CASE("Linter: the bundled JSON grammar is clean") {
359361
CHECK(issues.empty());
360362
}
361363

362-
TEST_CASE("Linter: the bundled .rawast grammar is clean") {
364+
TEST_CASE("Linter: the bundled .rawast grammar has only intentional informational warnings") {
365+
// The meta-grammar uses intentional shared-prefix Choices in a few
366+
// places (KEY_EXPR's normal vs strict variants, REPEAT_PLUS_FORM's
367+
// +N vs + variants) — these trigger LL(k) informational warnings
368+
// but parse and round-trip correctly. The test allows informational
369+
// warnings on those specific Choice rules; any OTHER lint output
370+
// (prefix-collision, wildcard-Choice-type-emit, raw-consume misuse,
371+
// or LL(k) warnings on unexpected rules) still fails the test.
363372
Grammar g;
364373
g.register_parser(std::make_unique<DoubleQuoteStringParser>());
365374
g.register_parser(std::make_unique<IdentifierParser>());
@@ -371,9 +380,25 @@ TEST_CASE("Linter: the bundled .rawast grammar is clean") {
371380
g.add_ignore("block_comment");
372381
REQUIRE(load_json_grammar_from_file(g, "grammars/rawast.json"));
373382

383+
// Allow informational LL(k) warnings on these specific rules
384+
// (the grammar uses intentional shared-prefix Choices here).
385+
const std::set<std::string> allowed_informational = {
386+
"REPEAT_PLUS_FORM",
387+
};
388+
374389
auto issues = lint_grammar(g);
375-
if (!issues.empty()) {
376-
FAIL("Unexpected lint issue in .rawast grammar: " << issues[0].description);
390+
for (const auto& issue : issues) {
391+
// Informational warnings on allowed rules are fine.
392+
if (issue.description.find("informational [") != std::string::npos) {
393+
bool is_allowed = false;
394+
for (const auto& rule : allowed_informational) {
395+
if (issue.description.find("[" + rule + "]") != std::string::npos) {
396+
is_allowed = true;
397+
break;
398+
}
399+
}
400+
if (is_allowed) continue;
401+
}
402+
FAIL("Unexpected lint issue in .rawast grammar: " << issue.description);
377403
}
378-
CHECK(issues.empty());
379404
}

0 commit comments

Comments
 (0)