From ef8bbbd9ba4d990ee74caf5b03212367b9ebf547 Mon Sep 17 00:00:00 2001 From: "Alexey O. Shigarov" Date: Sat, 29 Aug 2026 12:18:05 +0800 Subject: [PATCH] Zero-width subrows match empty; accept {0}/{1} quantifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subrow (or subtable) whose children all matched zero elements used to throw "colEnd must be >= colStart" mid-row and silently fail at the end of a row. It now matches the empty sequence like `*` in regex: tried at the end of the sequence too, never repeated by +/*/{n}, and not materialized in the ITM. Quantifier.exactly(n) accepts n >= 0; an invalid {n} in RTL is an RtlCompileException with position. Conformance: semantic subrow_zero_width_{mid,tail,repeated,repeated_star}, quantifier_exactly_{one,zero}; negative quantifier_exactly_negative; positive quantifier_small_n. Found in regtab-eval-on-atbench (reports/full-run.md §5). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 28 ++++ conformance/README.md | 23 +++ conformance/VERSION | 5 +- .../negative/quantifier_exactly_negative.rtl | 1 + .../positive/quantifier_small_n.expected.rtl | 1 + conformance/positive/quantifier_small_n.rtl | 1 + .../quantifier_exactly_one/expected.csv | 2 + .../semantic/quantifier_exactly_one/input.csv | 3 + .../quantifier_exactly_one/pattern.rtl | 2 + .../quantifier_exactly_zero/expected.csv | 2 + .../quantifier_exactly_zero/input.csv | 3 + .../quantifier_exactly_zero/pattern.rtl | 2 + .../subrow_zero_width_mid/expected.csv | 2 + .../semantic/subrow_zero_width_mid/input.csv | 3 + .../subrow_zero_width_mid/pattern.rtl | 2 + .../subrow_zero_width_repeated/expected.csv | 2 + .../subrow_zero_width_repeated/input.csv | 3 + .../subrow_zero_width_repeated/pattern.rtl | 2 + .../expected.csv | 2 + .../subrow_zero_width_repeated_star/input.csv | 3 + .../pattern.rtl | 2 + .../subrow_zero_width_tail/expected.csv | 2 + .../semantic/subrow_zero_width_tail/input.csv | 3 + .../subrow_zero_width_tail/pattern.rtl | 2 + docs/api.md | 2 +- docs/model/atp.md | 2 +- docs/rtl-reference.md | 9 +- .../java/ru/icc/regtab/atp/AtpMatcher.java | 3 + .../icc/regtab/atp/match/MatchedSubrow.java | 26 +++- .../icc/regtab/atp/match/MatchedSubtable.java | 25 +++- .../icc/regtab/atp/match/SyntaxMatcher.java | 46 ++++-- .../ru/icc/regtab/atp/spec/Quantifier.java | 9 +- .../icc/regtab/rtl/internal/ATPBuilder.java | 11 +- .../java/ru/icc/regtab/atp/AtpSpecTest.java | 13 +- .../ru/icc/regtab/atp/SyntaxMatcherTest.java | 132 ++++++++++++++++++ .../regtab/conformance/ConformanceCorpus.java | 7 + .../ru/icc/regtab/rtl/RtlCompilerTest.java | 16 +++ 37 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 conformance/negative/quantifier_exactly_negative.rtl create mode 100644 conformance/positive/quantifier_small_n.expected.rtl create mode 100644 conformance/positive/quantifier_small_n.rtl create mode 100644 conformance/semantic/quantifier_exactly_one/expected.csv create mode 100644 conformance/semantic/quantifier_exactly_one/input.csv create mode 100644 conformance/semantic/quantifier_exactly_one/pattern.rtl create mode 100644 conformance/semantic/quantifier_exactly_zero/expected.csv create mode 100644 conformance/semantic/quantifier_exactly_zero/input.csv create mode 100644 conformance/semantic/quantifier_exactly_zero/pattern.rtl create mode 100644 conformance/semantic/subrow_zero_width_mid/expected.csv create mode 100644 conformance/semantic/subrow_zero_width_mid/input.csv create mode 100644 conformance/semantic/subrow_zero_width_mid/pattern.rtl create mode 100644 conformance/semantic/subrow_zero_width_repeated/expected.csv create mode 100644 conformance/semantic/subrow_zero_width_repeated/input.csv create mode 100644 conformance/semantic/subrow_zero_width_repeated/pattern.rtl create mode 100644 conformance/semantic/subrow_zero_width_repeated_star/expected.csv create mode 100644 conformance/semantic/subrow_zero_width_repeated_star/input.csv create mode 100644 conformance/semantic/subrow_zero_width_repeated_star/pattern.rtl create mode 100644 conformance/semantic/subrow_zero_width_tail/expected.csv create mode 100644 conformance/semantic/subrow_zero_width_tail/input.csv create mode 100644 conformance/semantic/subrow_zero_width_tail/pattern.rtl diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6b17c6..3066d93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Zero-width subrows no longer break the matcher.** An explicit subrow `{…}` whose cell + patterns all matched zero cells (e.g. `{ [BLANK]* }` in a row without blank cells) used to + throw `IllegalArgumentException: colEnd must be >= colStart` in the middle of a row and to + silently fail ("pattern did not match") at the end of a row, because a trailing pattern was + never attempted once the row was exhausted. Such a subrow now matches the empty sequence, + as `*` does in regular expressions: it is tried at the end of the row as well, a repeated + empty match (`{ [BLANK]* }+`, `{ [BLANK]* }*`, `{ [BLANK]* }{n}`) counts as a single empty + iteration and never loops, and an empty subrow is not materialized in the interpretable + table (it covers no cells, so the spatial index of 0.5.2 is unaffected). The same applies to + a subtable whose row patterns are all optional. Found while evaluating RTL on ATBench + (regtab-eval-on-atbench, `reports/full-run.md` §5); the workaround + `{ [BLANK]* [!BLANK ? VAL: COL->AVP] [BLANK]* }+` keeps its outcome, and the natural form + `{ [!BLANK ? VAL: COL->AVP] }+ { [BLANK]* }` now works. +- **`{1}` and `{0}` quantifiers are accepted.** `Quantifier.exactly(n)` required `n ≥ 2` and + the RTL compiler let the raw `IllegalArgumentException: EXACTLY requires n >= 2` escape. + `{1}` is now equivalent to no quantifier and `{0}` to zero occurrences (an empty match); + only a negative `n` is rejected. An invalid `{n}` (negative, or out of `int` range) is + reported as an `RtlCompileException` with the source position instead of a runtime exception. +- API: `MatchedSubrow` / `MatchedSubtable` allow an empty interval (`colEnd == colStart - 1`, + `rowEnd == rowStart - 1`) and gain `empty(...)`, `isEmpty()`, `width()` / `height()`; + `Quantifier.exactly(n)` accepts `n ≥ 0`. +- Conformance: semantic cases `subrow_zero_width_mid`, `subrow_zero_width_tail`, + `subrow_zero_width_repeated` (`+`), `subrow_zero_width_repeated_star` (`*`), + `quantifier_exactly_one`, `quantifier_exactly_zero`; negative case + `quantifier_exactly_negative`; curated positive case `quantifier_small_n`. + ## [0.7.0] - 2026-08-29 ### Added diff --git a/conformance/README.md b/conformance/README.md index 737b2fd4..547074c5 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -103,6 +103,29 @@ compound specification). > Changed in jRegTab 0.5.0. Earlier versions trimmed each substring and silently > dropped empty ones; patterns relying on that must add `=TRIM` to the delimited atom. +## Zero-width subrows and `{n}` with n < 2 + +An explicit subrow (or subtable) whose children are all optional may consume no cells at +all, e.g. `{ [BLANK]* }` in a row without blank cells. It then matches the *empty sequence*, +as `*` does in regular expressions: + +- the empty match is tried at the end of the row as well, so a trailing `{ [BLANK]* }` + does not make the row fail; +- a repeated empty match (`{ [BLANK]* }+`, `{ [BLANK]* }*`, `{ [BLANK]* }{3}`) counts as a + single empty iteration — implementations must not loop; +- an empty subrow covers no cells and must not appear in the interpretable table. + +The quantifier `{n}` accepts any `n ≥ 0`: `{1}` is equivalent to no quantifier, `{0}` to +zero occurrences (an empty match). Only a negative `n` is a compile error. Positive case +`quantifier_small_n` pins the canonical form; the executable checks are +`semantic/subrow_zero_width_mid`, `semantic/subrow_zero_width_tail`, +`semantic/subrow_zero_width_repeated` (`+`), `semantic/subrow_zero_width_repeated_star` (`*`), +`semantic/quantifier_exactly_one`, `semantic/quantifier_exactly_zero` and the negative case +`quantifier_exactly_negative`. + +> Changed in jRegTab after 0.7.0. Earlier versions raised an error for a zero-width subrow in the +> middle of a row, silently failed on one at the end of a row, and rejected `{0}` / `{1}`. + In jRegTab items 1–4 of the contract are executed by `ru.icc.regtab.conformance.RtlConformanceTest` and item 5 by `ru.icc.regtab.conformance.RtlSemanticConformanceTest`; diff --git a/conformance/VERSION b/conformance/VERSION index a1e09b85..d1bdc4db 100644 --- a/conformance/VERSION +++ b/conformance/VERSION @@ -2,4 +2,7 @@ generated: 2026-08-29 sources: RtlTask001..150 + curated extras note: key K of CONCAT(K)/JOIN(K) may name attributes (CONCAT(0, 1, 'A', 'B'), JOIN('k')), resolved per record; canonical form sorts positions, then names, single-quoted; curated extra named_key and - semantic cases concat_named_key, join_named_key, negative concat_empty_key_name added + semantic cases concat_named_key, join_named_key, negative concat_empty_key_name added; + zero-width subrows match the empty sequence (never repeated) and {n} accepts n in {0, 1}: + curated extra quantifier_small_n, semantic cases subrow_zero_width_{mid,tail,repeated,repeated_star}, + quantifier_exactly_{one,zero}, negative quantifier_exactly_negative added diff --git a/conformance/negative/quantifier_exactly_negative.rtl b/conformance/negative/quantifier_exactly_negative.rtl new file mode 100644 index 00000000..b8461548 --- /dev/null +++ b/conformance/negative/quantifier_exactly_negative.rtl @@ -0,0 +1 @@ +[ [VAL]{-1} ] diff --git a/conformance/positive/quantifier_small_n.expected.rtl b/conformance/positive/quantifier_small_n.expected.rtl new file mode 100644 index 00000000..6c01062d --- /dev/null +++ b/conformance/positive/quantifier_small_n.expected.rtl @@ -0,0 +1 @@ +[ [ VAL ]{1} [ VAL ]{0} { [ BLANK ]* }{1} ] diff --git a/conformance/positive/quantifier_small_n.rtl b/conformance/positive/quantifier_small_n.rtl new file mode 100644 index 00000000..00952248 --- /dev/null +++ b/conformance/positive/quantifier_small_n.rtl @@ -0,0 +1 @@ +[ [VAL]{1} [VAL]{0} { [BLANK]* }{1} ] diff --git a/conformance/semantic/quantifier_exactly_one/expected.csv b/conformance/semantic/quantifier_exactly_one/expected.csv new file mode 100644 index 00000000..972ee6c5 --- /dev/null +++ b/conformance/semantic/quantifier_exactly_one/expected.csv @@ -0,0 +1,2 @@ +x,1 +y,2 diff --git a/conformance/semantic/quantifier_exactly_one/input.csv b/conformance/semantic/quantifier_exactly_one/input.csv new file mode 100644 index 00000000..6b4f5fa3 --- /dev/null +++ b/conformance/semantic/quantifier_exactly_one/input.csv @@ -0,0 +1,3 @@ +A,B +x,1 +y,2 diff --git a/conformance/semantic/quantifier_exactly_one/pattern.rtl b/conformance/semantic/quantifier_exactly_one/pattern.rtl new file mode 100644 index 00000000..a6de8638 --- /dev/null +++ b/conformance/semantic/quantifier_exactly_one/pattern.rtl @@ -0,0 +1,2 @@ +[ [ATTR]+ ] +[ [VAL: COL->AVP, ROW*->REC] { [BLANK]* }{1} [VAL: COL->AVP] ]+ diff --git a/conformance/semantic/quantifier_exactly_zero/expected.csv b/conformance/semantic/quantifier_exactly_zero/expected.csv new file mode 100644 index 00000000..972ee6c5 --- /dev/null +++ b/conformance/semantic/quantifier_exactly_zero/expected.csv @@ -0,0 +1,2 @@ +x,1 +y,2 diff --git a/conformance/semantic/quantifier_exactly_zero/input.csv b/conformance/semantic/quantifier_exactly_zero/input.csv new file mode 100644 index 00000000..6b4f5fa3 --- /dev/null +++ b/conformance/semantic/quantifier_exactly_zero/input.csv @@ -0,0 +1,3 @@ +A,B +x,1 +y,2 diff --git a/conformance/semantic/quantifier_exactly_zero/pattern.rtl b/conformance/semantic/quantifier_exactly_zero/pattern.rtl new file mode 100644 index 00000000..430165c5 --- /dev/null +++ b/conformance/semantic/quantifier_exactly_zero/pattern.rtl @@ -0,0 +1,2 @@ +[ [ATTR]+ ] +[ [VAL: COL->AVP, ROW*->REC] { [BLANK]* }{0} [VAL: COL->AVP] ]+ diff --git a/conformance/semantic/subrow_zero_width_mid/expected.csv b/conformance/semantic/subrow_zero_width_mid/expected.csv new file mode 100644 index 00000000..972ee6c5 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_mid/expected.csv @@ -0,0 +1,2 @@ +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_mid/input.csv b/conformance/semantic/subrow_zero_width_mid/input.csv new file mode 100644 index 00000000..6b4f5fa3 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_mid/input.csv @@ -0,0 +1,3 @@ +A,B +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_mid/pattern.rtl b/conformance/semantic/subrow_zero_width_mid/pattern.rtl new file mode 100644 index 00000000..4543f9fc --- /dev/null +++ b/conformance/semantic/subrow_zero_width_mid/pattern.rtl @@ -0,0 +1,2 @@ +[ [ATTR]+ ] +[ [VAL: COL->AVP, ROW*->REC] { [BLANK]* } [VAL: COL->AVP] ]+ diff --git a/conformance/semantic/subrow_zero_width_repeated/expected.csv b/conformance/semantic/subrow_zero_width_repeated/expected.csv new file mode 100644 index 00000000..972ee6c5 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_repeated/expected.csv @@ -0,0 +1,2 @@ +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_repeated/input.csv b/conformance/semantic/subrow_zero_width_repeated/input.csv new file mode 100644 index 00000000..6b4f5fa3 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_repeated/input.csv @@ -0,0 +1,3 @@ +A,B +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_repeated/pattern.rtl b/conformance/semantic/subrow_zero_width_repeated/pattern.rtl new file mode 100644 index 00000000..77639d35 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_repeated/pattern.rtl @@ -0,0 +1,2 @@ +[ [ATTR]+ ] +[ [VAL: COL->AVP, ROW*->REC] { [BLANK]* }+ [VAL: COL->AVP] { [BLANK]* }+ ]+ diff --git a/conformance/semantic/subrow_zero_width_repeated_star/expected.csv b/conformance/semantic/subrow_zero_width_repeated_star/expected.csv new file mode 100644 index 00000000..972ee6c5 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_repeated_star/expected.csv @@ -0,0 +1,2 @@ +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_repeated_star/input.csv b/conformance/semantic/subrow_zero_width_repeated_star/input.csv new file mode 100644 index 00000000..6b4f5fa3 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_repeated_star/input.csv @@ -0,0 +1,3 @@ +A,B +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_repeated_star/pattern.rtl b/conformance/semantic/subrow_zero_width_repeated_star/pattern.rtl new file mode 100644 index 00000000..787a76f9 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_repeated_star/pattern.rtl @@ -0,0 +1,2 @@ +[ [ATTR]+ ] +[ [VAL: COL->AVP, ROW*->REC] { [BLANK]* }* [VAL: COL->AVP] { [BLANK]* }* ]+ diff --git a/conformance/semantic/subrow_zero_width_tail/expected.csv b/conformance/semantic/subrow_zero_width_tail/expected.csv new file mode 100644 index 00000000..972ee6c5 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_tail/expected.csv @@ -0,0 +1,2 @@ +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_tail/input.csv b/conformance/semantic/subrow_zero_width_tail/input.csv new file mode 100644 index 00000000..6b4f5fa3 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_tail/input.csv @@ -0,0 +1,3 @@ +A,B +x,1 +y,2 diff --git a/conformance/semantic/subrow_zero_width_tail/pattern.rtl b/conformance/semantic/subrow_zero_width_tail/pattern.rtl new file mode 100644 index 00000000..dd6b2da3 --- /dev/null +++ b/conformance/semantic/subrow_zero_width_tail/pattern.rtl @@ -0,0 +1,2 @@ +[ [ATTR]+ ] +[ [VAL: COL->AVP, ROW*->REC] [VAL: COL->AVP] { [BLANK]* } ]+ diff --git a/docs/api.md b/docs/api.md index 775b0486..64fd349a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -318,7 +318,7 @@ For custom conditions use `new ItemFilterConditionSpec.Custom("description", (an | `zeroOrOne()` | `?` | Zero or one. | | `oneOrMore()` | `+` | One or more. | | `zeroOrMore()` | `*` | Zero or more. | -| `exactly(int n)` | `{n}` | Exactly n (n ≥ 2). | +| `exactly(int n)` | `{n}` | Exactly n (n ≥ 0; `{1}` ≡ no quantifier, `{0}` ≡ empty match). | --- diff --git a/docs/model/atp.md b/docs/model/atp.md index 23f2bc4e..416c3150 100644 --- a/docs/model/atp.md +++ b/docs/model/atp.md @@ -47,7 +47,7 @@ row pattern, its subtable pattern, and the top-level table pattern. |---|---| | `?` | zero or one occurrence | | `1` (default) | exactly one occurrence | -| `{n}` | exactly `n` occurrences (`n ≥ 2`) | +| `{n}` | exactly `n` occurrences (`n ≥ 0`; `{1}` ≡ no quantifier, `{0}` ≡ zero occurrences, i.e. an empty match) | | `+` | one or more occurrences | | `*` | zero or more occurrences | diff --git a/docs/rtl-reference.md b/docs/rtl-reference.md index c2ffecef..b132f072 100644 --- a/docs/rtl-reference.md +++ b/docs/rtl-reference.md @@ -104,11 +104,18 @@ cell patterns: | `?` | 0 or 1 | | `*` | 0 or more | | `+` | 1 or more | -| `{n}` | exactly *n* | +| `{n}` | exactly *n* (*n* ≥ 0; `{1}` is the same as no quantifier, `{0}` matches nothing, i.e. an empty match) | The `+` on the outer row pattern above means "one or more data rows"; the `{2}` means "exactly two value cells". +**Empty matches.** An explicit subrow (or subtable) whose children are all optional may +match zero cells (rows) — for instance `{ [BLANK]* }` in a row without blank cells. Such a +subrow matches the empty sequence, exactly like `*` in a regular expression: it never fails +the row, it is tried at the end of the row as well, and a repeated empty match (`{ [BLANK]* }+`, +`{ [BLANK]* }*`, `{ [BLANK]* }{3}`) counts as a single empty iteration rather than looping. +An empty subrow covers no cells and therefore never appears in the interpretable table. + **Inherited action specs** — `[acts]` placed at the table, subtable, row, or subrow level are inherited by all descendant cells. Inherited actions are merged with any local actions on the cell's `contSpec`. Incompatible inherited actions (e.g. `COL->AVP` on an `ATTR` anchor) are diff --git a/src/main/java/ru/icc/regtab/atp/AtpMatcher.java b/src/main/java/ru/icc/regtab/atp/AtpMatcher.java index da5ab0c1..32459ebf 100644 --- a/src/main/java/ru/icc/regtab/atp/AtpMatcher.java +++ b/src/main/java/ru/icc/regtab/atp/AtpMatcher.java @@ -87,7 +87,9 @@ public static Optional match( } private static void applyMatchedStructure(TableSyntax syntax, MatchResult result) { + // Empty (zero-width) matches are not materialized: they cover no cells. int[] starts = result.matchedSubtables().stream() + .filter(m -> !m.isEmpty()) .mapToInt(m -> m.rowStart()) .distinct() .sorted() @@ -97,6 +99,7 @@ private static void applyMatchedStructure(TableSyntax syntax, MatchResult result } result.matchedSubrows().stream() + .filter(m -> !m.isEmpty()) .sorted(Comparator.comparingInt(MatchedSubrow::rowIndex) .thenComparingInt(MatchedSubrow::colStart)) .forEach(m -> syntax.defineSubrow(m.rowIndex(), m.colStart(), m.colEnd())); diff --git a/src/main/java/ru/icc/regtab/atp/match/MatchedSubrow.java b/src/main/java/ru/icc/regtab/atp/match/MatchedSubrow.java index a380abca..833ba8d2 100644 --- a/src/main/java/ru/icc/regtab/atp/match/MatchedSubrow.java +++ b/src/main/java/ru/icc/regtab/atp/match/MatchedSubrow.java @@ -6,6 +6,13 @@ /** * Matched subrow pattern and the cell interval it consumes within a row. + *

+ * The interval {@code [colStart, colEnd]} is inclusive. A subrow pattern whose cell + * patterns all matched zero cells (e.g. {@code { [BLANK]* }} in a row without blank + * cells) yields an empty match: {@code colEnd == colStart - 1}, where + * {@code colStart} is the position at which the empty match occurred (equal to the + * number of columns when it occurred at the end of the row). Empty matches are + * recorded here for completeness but are never materialized as ITM subrows. */ public record MatchedSubrow(SubrowPattern pattern, int rowIndex, int colStart, int colEnd) { @@ -17,8 +24,23 @@ public record MatchedSubrow(SubrowPattern pattern, int rowIndex, int colStart, i if (colStart < 0) { throw new IllegalArgumentException("colStart must be non-negative: " + colStart); } - if (colEnd < colStart) { - throw new IllegalArgumentException("colEnd must be >= colStart: " + colEnd + " < " + colStart); + if (colEnd < colStart - 1) { + throw new IllegalArgumentException("colEnd must be >= colStart - 1: " + colEnd + " < " + (colStart - 1)); } } + + /** An empty (zero-width) match of {@code pattern} at column {@code col} of row {@code rowIndex}. */ + public static MatchedSubrow empty(SubrowPattern pattern, int rowIndex, int col) { + return new MatchedSubrow(pattern, rowIndex, col, col - 1); + } + + /** {@code true} if this match consumed no cells. */ + public boolean isEmpty() { + return colEnd < colStart; + } + + /** Number of cells consumed (0 for an empty match). */ + public int width() { + return colEnd - colStart + 1; + } } diff --git a/src/main/java/ru/icc/regtab/atp/match/MatchedSubtable.java b/src/main/java/ru/icc/regtab/atp/match/MatchedSubtable.java index 43979ae7..3de802a1 100644 --- a/src/main/java/ru/icc/regtab/atp/match/MatchedSubtable.java +++ b/src/main/java/ru/icc/regtab/atp/match/MatchedSubtable.java @@ -6,6 +6,12 @@ /** * Matched subtable pattern and the row interval it consumes. + *

+ * The interval {@code [rowStart, rowEnd]} is inclusive. A subtable pattern whose row + * patterns all matched zero rows yields an empty match: + * {@code rowEnd == rowStart - 1}, where {@code rowStart} is the position at which the + * empty match occurred. Empty matches are recorded for completeness but never + * introduce a subtable boundary in the ITM. */ public record MatchedSubtable(SubtablePattern pattern, int rowStart, int rowEnd) { @@ -14,8 +20,23 @@ public record MatchedSubtable(SubtablePattern pattern, int rowStart, int rowEnd) if (rowStart < 0) { throw new IllegalArgumentException("rowStart must be non-negative: " + rowStart); } - if (rowEnd < rowStart) { - throw new IllegalArgumentException("rowEnd must be >= rowStart: " + rowEnd + " < " + rowStart); + if (rowEnd < rowStart - 1) { + throw new IllegalArgumentException("rowEnd must be >= rowStart - 1: " + rowEnd + " < " + (rowStart - 1)); } } + + /** An empty (zero-height) match of {@code pattern} at row {@code row}. */ + public static MatchedSubtable empty(SubtablePattern pattern, int row) { + return new MatchedSubtable(pattern, row, row - 1); + } + + /** {@code true} if this match consumed no rows. */ + public boolean isEmpty() { + return rowEnd < rowStart; + } + + /** Number of rows consumed (0 for an empty match). */ + public int height() { + return rowEnd - rowStart + 1; + } } diff --git a/src/main/java/ru/icc/regtab/atp/match/SyntaxMatcher.java b/src/main/java/ru/icc/regtab/atp/match/SyntaxMatcher.java index d12ac5bf..629ee0bd 100644 --- a/src/main/java/ru/icc/regtab/atp/match/SyntaxMatcher.java +++ b/src/main/java/ru/icc/regtab/atp/match/SyntaxMatcher.java @@ -24,6 +24,12 @@ /** * Syntactic layer matching exactly following the formal algorithms from the paper. + *

+ * Empty matches: a subrow (or subtable) pattern whose children all matched zero + * elements matches the empty sequence, like {@code *} in regular expressions. Such an + * iteration is recorded as an empty {@link MatchedSubrow}/{@link MatchedSubtable}, is + * never repeated by {@code +}/{@code *}/{@code {n}}, and is tried at the end of the + * sequence as well, so a trailing {@code { [BLANK]* }} matches a row without blanks. */ public final class SyntaxMatcher { @@ -52,7 +58,6 @@ private static MatchOutcome matchPatterns( int rowIndex) { int i = elementIndex; - int n = elements.size(); for (int j = 0; j < patterns.size(); j++) { P pattern = patterns.get(j); @@ -61,16 +66,24 @@ private static MatchOutcome matchPatterns( int max = quantifier.max(); Deque stack = new ArrayDeque<>(); - while (stack.size() < max && i < n) { + // No "i < n" guard: a pattern that can match empty (a subrow/subtable whose + // children are all optional) must be tried at the end of the sequence too; + // cell and row dispatchers fail on their own when i >= n. + while (stack.size() < max) { MatchSnapshot saved = state.snapshot(); MatchOutcome dispatched = dispatchPattern(pattern, elements, i, state, structureKind, rowIndex); - if (dispatched.success()) { - stack.push(new StackEntry(i, saved)); - i = dispatched.nextIndex(); - } else { + if (!dispatched.success()) { state.restore(saved); break; } + stack.push(new StackEntry(i, saved)); + if (dispatched.nextIndex() == i) { + // Empty iteration: as in regex engines, an empty match is never repeated + // and satisfies any remaining lower bound ((a*)+ and (a*){3} match ""). + min = 0; + break; + } + i = dispatched.nextIndex(); } if (stack.size() < min) { @@ -143,7 +156,9 @@ private static MatchOutcome dispatchSubtablePattern( return MatchOutcome.failure(rowIndex); } - state.matchedSubtables.add(new MatchedSubtable(pattern, rowIndex, inner.nextIndex() - 1)); + state.matchedSubtables.add(inner.nextIndex() == rowIndex + ? MatchedSubtable.empty(pattern, rowIndex) + : new MatchedSubtable(pattern, rowIndex, inner.nextIndex() - 1)); return inner; } @@ -192,11 +207,18 @@ private static MatchOutcome dispatchSubrowPattern( return MatchOutcome.failure(cellIndex); } - state.matchedSubrows.add(new MatchedSubrow( - pattern, - rowIndex, - cells.get(cellIndex).col(), - cells.get(inner.nextIndex() - 1).col())); + int next = inner.nextIndex(); + if (next == cellIndex) { + // Zero-width subrow: record where it matched (cells.size() at the end of the row). + int col = cellIndex < cells.size() ? cells.get(cellIndex).col() : cells.size(); + state.matchedSubrows.add(MatchedSubrow.empty(pattern, rowIndex, col)); + } else { + state.matchedSubrows.add(new MatchedSubrow( + pattern, + rowIndex, + cells.get(cellIndex).col(), + cells.get(next - 1).col())); + } return inner; } diff --git a/src/main/java/ru/icc/regtab/atp/spec/Quantifier.java b/src/main/java/ru/icc/regtab/atp/spec/Quantifier.java index 7bc4620c..821822c3 100644 --- a/src/main/java/ru/icc/regtab/atp/spec/Quantifier.java +++ b/src/main/java/ru/icc/regtab/atp/spec/Quantifier.java @@ -16,7 +16,10 @@ public enum Kind { ZERO_OR_ONE, /** {@code 1} — exactly one occurrence (default). */ ONE, - /** {@code {n}} — exactly n occurrences (n ≥ 2). */ + /** + * {@code {n}} — exactly n occurrences (n ≥ 0): {@code {1}} is equivalent to no + * quantifier, {@code {0}} to zero occurrences (an empty match). + */ EXACTLY, /** {@code +} — one or more occurrences. */ ONE_OR_MORE, @@ -35,8 +38,8 @@ public enum Kind { public Quantifier { Objects.requireNonNull(kind, "kind"); - if (kind == Kind.EXACTLY && n < 2) { - throw new IllegalArgumentException("EXACTLY requires n >= 2, got: " + n); + if (kind == Kind.EXACTLY && n < 0) { + throw new IllegalArgumentException("EXACTLY requires n >= 0, got: " + n); } } diff --git a/src/main/java/ru/icc/regtab/rtl/internal/ATPBuilder.java b/src/main/java/ru/icc/regtab/rtl/internal/ATPBuilder.java index dd6f9f5d..26bf89c4 100644 --- a/src/main/java/ru/icc/regtab/rtl/internal/ATPBuilder.java +++ b/src/main/java/ru/icc/regtab/rtl/internal/ATPBuilder.java @@ -455,8 +455,15 @@ private static Quantifier buildQuantifier(RTLParser.QuantifierContext ctx) { if (ctx.zeroOrOne() != null) return Quantifier.zeroOrOne(); if (ctx.zeroOrMore() != null) return Quantifier.zeroOrMore(); if (ctx.oneOrMore() != null) return Quantifier.oneOrMore(); - if (ctx.exactly() != null) - return Quantifier.exactly(Integer.parseInt(ctx.exactly().INT().getText())); + if (ctx.exactly() != null) { + var tok = ctx.exactly().INT().getSymbol(); + try { + return Quantifier.exactly(Integer.parseInt(tok.getText())); + } catch (IllegalArgumentException e) { // NumberFormatException included + throw new RtlCompileException("Invalid quantifier {" + tok.getText() + "}: " + e.getMessage(), + tok.getLine(), tok.getCharPositionInLine()); + } + } throw new RtlCompileException("Unknown quantifier"); } diff --git a/src/test/java/ru/icc/regtab/atp/AtpSpecTest.java b/src/test/java/ru/icc/regtab/atp/AtpSpecTest.java index 63d36297..64eefce0 100644 --- a/src/test/java/ru/icc/regtab/atp/AtpSpecTest.java +++ b/src/test/java/ru/icc/regtab/atp/AtpSpecTest.java @@ -31,9 +31,16 @@ void quantifierBounds() { } @Test - void quantifierExactlyRejectsSmallN() { - assertThrows(IllegalArgumentException.class, () -> Quantifier.exactly(1)); - assertThrows(IllegalArgumentException.class, () -> Quantifier.exactly(0)); + void quantifierExactlyAcceptsSmallN() { + assertEquals(1, Quantifier.exactly(1).min()); + assertEquals(1, Quantifier.exactly(1).max()); + assertEquals(0, Quantifier.exactly(0).min()); + assertEquals(0, Quantifier.exactly(0).max()); + } + + @Test + void quantifierExactlyRejectsNegativeN() { + assertThrows(IllegalArgumentException.class, () -> Quantifier.exactly(-1)); } @Test diff --git a/src/test/java/ru/icc/regtab/atp/SyntaxMatcherTest.java b/src/test/java/ru/icc/regtab/atp/SyntaxMatcherTest.java index 06dc6476..748b9634 100644 --- a/src/test/java/ru/icc/regtab/atp/SyntaxMatcherTest.java +++ b/src/test/java/ru/icc/regtab/atp/SyntaxMatcherTest.java @@ -1,6 +1,7 @@ package ru.icc.regtab.atp; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import ru.icc.regtab.atp.match.MatchResult; import ru.icc.regtab.atp.match.SyntaxMatcher; import ru.icc.regtab.atp.spec.*; @@ -149,6 +150,137 @@ void zeroOrMoreCells_matchesEmpty() { assertEquals(1, result.matchedPairs().size()); } + // ---- zero-width subrows / subtables: empty matches, as `*` in regex ---- + + private static final CellMatchCondition BLANK_COND = + new CellMatchCondition(CellPredicate.Blank.INSTANCE); + + /** { [BLANK]* } with the given quantifier. */ + private static SubrowPattern blankStarSubrow(Quantifier q) { + return SubrowPattern.of(q, CellPattern.of(BLANK_COND, Quantifier.zeroOrMore(), null)); + } + + private static SubrowPattern valSubrow() { + return SubrowPattern.of(CellPattern.of(AtomicContentSpec.val())); + } + + private static TablePattern rowOf(SubrowPattern... subrows) { + return TablePattern.of(SubtablePattern.of(RowPattern.of(Quantifier.one(), subrows))); + } + + private static long emptySubrows(MatchResult r) { + return r.matchedSubrows().stream().filter(m -> m.isEmpty()).count(); + } + + @Test + void emptySubrow_midRow() { + var syntax = table(new String[][]{{"x", "1"}}); + var atp = rowOf(valSubrow(), blankStarSubrow(Quantifier.one()), valSubrow()); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + assertEquals(2, result.matchedPairs().size()); + assertEquals(3, result.matchedSubrows().size()); + var empty = result.matchedSubrows().get(1); + assertTrue(empty.isEmpty()); + assertEquals(1, empty.colStart()); + assertEquals(0, empty.width()); + assertTrue(AtpMatcher.match(atp, syntax).isPresent()); + } + + @Test + void emptySubrow_tail() { + var syntax = table(new String[][]{{"x", "1"}}); + var atp = rowOf(valSubrow(), valSubrow(), blankStarSubrow(Quantifier.one())); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + var empty = result.matchedSubrows().get(2); + assertTrue(empty.isEmpty()); + assertEquals(2, empty.colStart()); // == numCols: matched at the end of the row + assertTrue(AtpMatcher.match(atp, syntax).isPresent()); + } + + @Test + @Timeout(5) + void emptySubrow_repeatedPlus_doesNotLoop() { + var syntax = table(new String[][]{{"x", "1"}}); + var atp = rowOf(valSubrow(), blankStarSubrow(Quantifier.oneOrMore()), valSubrow(), + blankStarSubrow(Quantifier.oneOrMore())); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + assertEquals(2, emptySubrows(result)); // one empty iteration per `+`, never repeated + assertTrue(AtpMatcher.match(atp, syntax).isPresent()); + } + + @Test + @Timeout(5) + void emptySubrow_repeatedStar_doesNotLoop() { + var syntax = table(new String[][]{{"x", "1"}}); + var atp = rowOf(valSubrow(), blankStarSubrow(Quantifier.zeroOrMore()), valSubrow(), + blankStarSubrow(Quantifier.zeroOrMore())); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + assertEquals(2, emptySubrows(result)); + assertTrue(AtpMatcher.match(atp, syntax).isPresent()); + } + + @Test + void emptySubrow_exactlyN_matchesEmptyOnce() { + var syntax = table(new String[][]{{"x", "1"}}); + var atp = rowOf(valSubrow(), blankStarSubrow(Quantifier.exactly(3)), valSubrow()); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + assertEquals(1, emptySubrows(result)); + } + + @Test + void emptySubrow_exactlyZero_consumesNothing() { + var syntax = table(new String[][]{{"x", "1"}}); + var atp = rowOf(valSubrow(), blankStarSubrow(Quantifier.exactly(0)), valSubrow()); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + assertEquals(2, result.matchedPairs().size()); + assertEquals(2, result.matchedSubrows().size()); // {0}: no iteration recorded at all + assertTrue(AtpMatcher.match(atp, syntax).isPresent()); + } + + @Test + void emptySubrow_stillConsumesBlanksWhenPresent() { + var syntax = table(new String[][]{{"x", "", "", "1"}}); + var atp = rowOf(valSubrow(), blankStarSubrow(Quantifier.oneOrMore()), valSubrow()); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + var blanks = result.matchedSubrows().get(1); + assertEquals(1, blanks.colStart()); + assertEquals(2, blanks.colEnd()); + assertEquals(1, emptySubrows(result)); // the greedy `+` ends with one empty iteration + assertTrue(AtpMatcher.match(atp, syntax).isPresent()); + } + + @Test + @Timeout(5) + void emptySubtable_allRowsOptional() { + var syntax = table(new String[][]{{"A"}}); + var atp = TablePattern.of( + SubtablePattern.of(Quantifier.oneOrMore(), + RowPattern.of(Quantifier.zeroOrMore(), + CellPattern.of(BLANK_COND, Quantifier.one(), null))), + SubtablePattern.of(RowPattern.of(CellPattern.of(AtomicContentSpec.val()))) + ); + + MatchResult result = SyntaxMatcher.match(atp, syntax); + assertTrue(result.success()); + assertTrue(result.matchedSubtables().get(0).isEmpty()); + assertEquals(0, result.matchedSubtables().get(0).height()); + assertTrue(AtpMatcher.match(atp, syntax).isPresent()); + } + @Test void backtracking_oneOrMoreThenFixed() { // 4 cells: oneOrMore should consume 3, leaving 1 for the fixed pattern diff --git a/src/test/java/ru/icc/regtab/conformance/ConformanceCorpus.java b/src/test/java/ru/icc/regtab/conformance/ConformanceCorpus.java index b146ca56..465f7cfd 100644 --- a/src/test/java/ru/icc/regtab/conformance/ConformanceCorpus.java +++ b/src/test/java/ru/icc/regtab/conformance/ConformanceCorpus.java @@ -58,6 +58,13 @@ public record Entry(String id, String rtl) {} [ [] [] [ATTR]+ ] [ [VAL: RT*->REC, (BW&STR)*->CONCAT("B", 'A', 1, 0)] [VAL] [VAL] [VAL: COL->AVP]{2} ]+ [ [VAL: COL->AVP, RT->REC, C2*->JOIN('k')] [VAL: COL->AVP] [VAL: COL->AVP, RT->REC] [VAL: COL->AVP] ]+ + """, + // Quantifier {n} with n < 2: {1} is equivalent to no quantifier, {0} to zero + // occurrences. Both are kept verbatim in the canonical form. Execution of + // zero-width subrows is pinned by semantic/subrow_zero_width_* and + // semantic/quantifier_exactly_*. + "quantifier_small_n", /* language=RTL */ """ + [ [VAL]{1} [VAL]{0} { [BLANK]* }{1} ] """ ); diff --git a/src/test/java/ru/icc/regtab/rtl/RtlCompilerTest.java b/src/test/java/ru/icc/regtab/rtl/RtlCompilerTest.java index 67868c17..953574f2 100644 --- a/src/test/java/ru/icc/regtab/rtl/RtlCompilerTest.java +++ b/src/test/java/ru/icc/regtab/rtl/RtlCompilerTest.java @@ -66,6 +66,22 @@ void parse_quantifiers() { assertEquals(Quantifier.exactly(3), cell(row, 0, 3).quantifier()); } + @Test + void parse_quantifierExactlySmallN() { + TablePattern p = compile("[ [SKIP]{1} [SKIP]{0} ]"); + var row = row(p, 0, 0); + assertEquals(Quantifier.exactly(1), cell(row, 0, 0).quantifier()); + assertEquals(Quantifier.exactly(0), cell(row, 0, 1).quantifier()); + } + + @Test + void parse_quantifierExactlyOverflow_isCompileErrorWithPosition() { + var e = assertThrows(RtlCompileException.class, () -> compile("[ [SKIP]{99999999999} ]")); + assertEquals(1, e.line()); + assertEquals(9, e.column()); + assertTrue(e.getMessage().contains("Invalid quantifier {99999999999}"), e.getMessage()); + } + @Test void parse_ctxProviderAvp() { TablePattern p = compile("[ [VAL : 'LABEL'->AVP] ]");