Skip to content

Commit c3b8f6d

Browse files
Steve Ramageclaude
andcommitted
refactor: implement parse() in-place on the real combinators, drop grammar2
Replaces the standalone grammar2 PoC with the same list-of-successes idea grown directly on the existing combinators, per review feedback: the parallel package had a jarringly different surface and implied a rewrite of all 225 grammars. Instead, add one new method — Combinator.parse(value, offset): Sequence<Parse> — ALONGSIDE the existing SyntacticMatch/SemanticMatch, implemented on each of the 12 combinators next to its existing match logic. The caller (GrammarOptionValue) is untouched and still uses the old methods; nothing in the 225 grammar definitions changes. parse() can therefore be validated against the REAL production grammars before any migration decision. - Parse.kt: ParsedToken / Parse result types + validate() free function. One lenient pass folds the strict "semantic" check into a per-token `valid` flag, so it answers both syntactic (any full parse?) and semantic (a full parse with only valid tokens?) without two traversals. - Each combinator returns every way it can match, lazily; Alt offers all options (ordering no longer matters), ZeroOrMore/OneOrMore/Repeat offer every count. - ParseTest runs validate() against the actual ConfigParseAddressFamiliesOptionValue grammar and the real IPV6_ADDR combinator (15+ hand-ordered alternatives), an integer-range grammar, and the greedy Seq(ZeroOrMore("a"),"a") case — which it shows the old SemanticMatch still fails while parse() succeeds. Known limitation pinned in a test: SyntaxError `furthest` is best-effort and collapses to 0 when a trailing EOF() discards partial progress; precise localization needs the frontier/expected-set layer (the same machinery as completion, #343), deliberately out of scope here. Refs #467 #345 #343 #342 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 768dec7 commit c3b8f6d

17 files changed

Lines changed: 265 additions & 331 deletions

File tree

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/AlternativeCombinator.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ open class AlternativeCombinator(vararg val tokens: Combinator) : Combinator {
3838
return match(value, offset, Combinator::SemanticMatch)
3939
}
4040

41+
override fun parse(value: String, offset: Int): Sequence<Parse> =
42+
// Offer every alternative's matches, so the order of options no longer affects correctness.
43+
tokens.asSequence().flatMap { it.parse(value, offset) }
44+
4145
override fun toString(): String = toStringIndented(0)
4246

4347
override fun toStringIndented(indent: Int): String {

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Combinator.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,16 @@ interface Combinator {
3232
*/
3333
fun SemanticMatch(value : String, offset: Int): MatchResult
3434

35+
/**
36+
* List-of-successes matcher (#467). Returns EVERY way this combinator can consume [value]
37+
* starting at [offset], lazily; an empty sequence means no match.
38+
*
39+
* This lives alongside Syntactic/SemanticMatch and is a single lenient pass: each [ParsedToken]
40+
* carries a `valid` flag for the strict (semantic) check. Because every alternative is offered
41+
* rather than the first greedy one committed to, matching is complete — e.g.
42+
* Seq(ZeroOrMore("a"), "a") on "aa" matches, because ZeroOrMore offers the shorter match too.
43+
*/
44+
fun parse(value: String, offset: Int): Sequence<Parse>
45+
3546
fun toStringIndented(indent: Int): String
3647
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/EOF.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class EOF : Combinator {
1717
}
1818
}
1919

20+
override fun parse(value: String, offset: Int): Sequence<Parse> =
21+
if (offset == value.length) sequenceOf(Parse(offset, emptyList())) else emptySequence()
22+
2023
override fun toStringIndented(indent: Int): String {
2124
return "EOF"
2225
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/FlexibleLiteralChoiceTerminal.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ class FlexibleLiteralChoiceTerminal(vararg val choices: String) : TerminalCombin
9191
return NoMatch.copy(longestMatch = offset)
9292
}
9393

94+
override fun parse(value: String, offset: Int): Sequence<Parse> {
95+
// Lenient shape match (so a wrong token like AF_BOGUS still matches and can be highlighted),
96+
// valid only if the matched text is one of the exact choices.
97+
val m = syntaticMatch.matchAt(value, offset) ?: return emptySequence()
98+
val text = m.value
99+
val valid = choices.any { it == text }
100+
return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, valid))))
101+
}
102+
94103
override fun toString(): String {
95104
return if (choices.size == 1) {
96105
"Literal(\"${choices[0]}\")"

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/IntegerTerminal.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ class IntegerTerminal(private val minInclusive: Long,private val maxExclusive: L
2929
}
3030
}
3131

32+
override fun parse(value: String, offset: Int): Sequence<Parse> {
33+
val m = intRegex.matchAt(value, offset) ?: return emptySequence()
34+
val text = m.value
35+
// Lenient: any integer matches (so we can locate it); valid only if it is within range.
36+
val valid = text.toLongOrNull()?.let { it >= minInclusive && it < maxExclusive } ?: false
37+
return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, valid))))
38+
}
39+
3240
override fun toString(): String {
3341
return "Int($minInclusive,$maxExclusive)"
3442
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/LiteralChoiceTerminal.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ class LiteralChoiceTerminal(vararg var choices: String) : TerminalCombinator {
2424
return match(value, offset)
2525
}
2626

27+
override fun parse(value: String, offset: Int): Sequence<Parse> =
28+
// Offer every choice that matches here (e.g. both ":" and "::"); each is always strictly valid.
29+
choices.asSequence()
30+
.filter { value.startsWith(it, offset) }
31+
.map { Parse(offset + it.length, listOf(ParsedToken(offset, offset + it.length, it, this, valid = true))) }
32+
2733
override fun toString(): String {
2834
return if (choices.size == 1) {
2935
"Literal(\"${choices[0]}\")"

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/OneOrMore.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ class OneOrMore(val combinator : Combinator) : Combinator {
4040
return match(value, offset, combinator::SemanticMatch)
4141
}
4242

43+
override fun parse(value: String, offset: Int): Sequence<Parse> {
44+
// Same as ZeroOrMore, but the first repetition is mandatory (and must make progress).
45+
fun extend(from: Parse): Sequence<Parse> = sequence {
46+
yield(from)
47+
for (step in combinator.parse(value, from.end)) {
48+
if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens)))
49+
}
50+
}
51+
return combinator.parse(value, offset).filter { it.end > offset }.flatMap { extend(it) }
52+
}
53+
4354
override fun toString(): String = toStringIndented(0)
4455

4556
override fun toStringIndented(indent: Int): String {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar
2+
3+
/*
4+
* List-of-successes matcher (GitHub #467, step 2).
5+
*
6+
* These types support a second matching method, `Combinator.parse()`, that lives ALONGSIDE the
7+
* existing SyntacticMatch / SemanticMatch on every combinator. Nothing here is wired into
8+
* GrammarOptionValue yet — the goal is to flesh the approach out on the real combinators and
9+
* validate it against the real grammars in tests before deciding to migrate the caller.
10+
*
11+
* Where the existing engine returns ONE greedy result and runs two near-identical passes, parse()
12+
* returns EVERY way a combinator can match (lazily), and folds the strict "semantic" check into a
13+
* `valid` flag on each token. So one lenient pass answers both questions, and greedy traps like
14+
* Seq(ZeroOrMore("a"), "a") on "aa" resolve themselves (see Combinator.parse docs).
15+
*/
16+
17+
/** A single terminal token, with the strict-validity verdict (the old "semantic" check) folded in. */
18+
data class ParsedToken(
19+
val start: Int,
20+
val end: Int,
21+
val text: String,
22+
val terminal: TerminalCombinator,
23+
val valid: Boolean,
24+
)
25+
26+
/** One way a combinator consumed input from some offset: it ended at [end], producing [tokens]. */
27+
data class Parse(val end: Int, val tokens: List<ParsedToken>)
28+
29+
/** The outcome of validating a whole value against a grammar via parse(). */
30+
sealed interface ParseOutcome {
31+
/** Some path consumed the whole value with every token strictly valid. */
32+
object Valid : ParseOutcome
33+
34+
/** A path consumed the whole value, but a token is not strictly valid (well-formed but wrong). */
35+
data class SemanticError(val badToken: ParsedToken) : ParseOutcome
36+
37+
/** No path consumed the whole value. [furthest] is how far any path got (for error localization). */
38+
data class SyntaxError(val furthest: Int) : ParseOutcome
39+
}
40+
41+
/** Every way [this] grammar can consume the entire [value]. */
42+
fun Combinator.fullParses(value: String): Sequence<Parse> =
43+
parse(value, 0).filter { it.end == value.length }
44+
45+
/**
46+
* One lenient parse answers both questions the old two passes did:
47+
* - syntactic ("could be this, color it"): did any path consume the whole value?
48+
* - semantic ("actually valid"): did any such path use only valid tokens?
49+
*/
50+
fun Combinator.validate(value: String): ParseOutcome {
51+
var firstBad: ParsedToken? = null
52+
for (p in fullParses(value)) {
53+
val bad = p.tokens.firstOrNull { !it.valid }
54+
if (bad == null) return ParseOutcome.Valid // short-circuit on the first fully-valid full parse
55+
if (firstBad == null) firstBad = bad
56+
}
57+
if (firstBad != null) return ParseOutcome.SemanticError(firstBad)
58+
val furthest = parse(value, 0).maxOfOrNull { it.end } ?: 0
59+
return ParseOutcome.SyntaxError(furthest)
60+
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/RegexTerminal.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ class RegexTerminal(syntaticMatchStr : String, semanticMatchStr: String ) : Term
1818
return MatchResult(listOf(matchResult.value), offset + matchResult.value.length, listOf(this), offset + matchResult.value.length)
1919
}
2020

21+
override fun parse(value: String, offset: Int): Sequence<Parse> {
22+
// The syntactic regex gives the lenient span; valid iff the semantic regex matches that same span.
23+
val syn = syntaticMatch.matchAt(value, offset) ?: return emptySequence()
24+
val text = syn.value
25+
val valid = semanticMatch.matchAt(value, offset)?.value == text
26+
return sequenceOf(Parse(offset + text.length, listOf(ParsedToken(offset, offset + text.length, text, this, valid))))
27+
}
28+
2129
override fun toString(): String {
2230
return "Regex(\"${semanticMatch.pattern}\")"
2331
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Repeat.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,20 @@ class Repeat(val combinator : Combinator, val minInclusive: Int, val maxExclusiv
6262
return match(value, offset, combinator::SemanticMatch)
6363
}
6464

65+
override fun parse(value: String, offset: Int): Sequence<Parse> {
66+
// Offer every repetition count in [minInclusive, maxExclusive] (maxExclusive is the cap on the
67+
// count, mirroring the existing match() loop). Yield only once enough repetitions have happened.
68+
fun extend(from: Parse, count: Int): Sequence<Parse> = sequence {
69+
if (count >= minInclusive) yield(from)
70+
if (count < maxExclusive) {
71+
for (step in combinator.parse(value, from.end)) {
72+
if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens), count + 1))
73+
}
74+
}
75+
}
76+
return extend(Parse(offset, emptyList()), 0)
77+
}
78+
6579
override fun toString(): String = toStringIndented(0)
6680

6781
override fun toStringIndented(indent: Int): String {

0 commit comments

Comments
 (0)