diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 75584d7fb..8d5c0e513 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -59,6 +59,7 @@ type ParentMatchState = { variable: string | undefined; valueIds: ValueIdNode | undefined | null; // null means we don't need any value parent: ParentMatchState | undefined; + repeatPartIndex?: number | undefined; // defined for ()* / )+ — holds the part index to loop back to }; type MatchState = { // Current context @@ -75,6 +76,8 @@ type MatchState = { nestedLevel: number; // for debugging + inRepeat?: boolean | undefined; // true when re-entering a repeat group after a successful match + index: number; pendingWildcard?: | { @@ -465,7 +468,11 @@ function finalizeMatch( results.push(matchResult); } -function finalizeNestedRule(state: MatchState, partial: boolean = false) { +function finalizeNestedRule( + state: MatchState, + pending?: MatchState[], + partial: boolean = false, +) { const parent = state.parent; if (parent !== undefined) { debugMatch(state, `finished nested`); @@ -512,6 +519,18 @@ function finalizeNestedRule(state: MatchState, partial: boolean = false) { state.name = parent.name; state.parts = parent.parts; state.partIndex = parent.partIndex; + + // For repeat parts ()* or )+: after each successful match, queue a state + // that tries to match the same group again. inRepeat suppresses the + // optional-skip push so we don't generate duplicate "done" states. + if (parent.repeatPartIndex !== undefined && pending !== undefined) { + pending.push({ + ...state, + partIndex: parent.repeatPartIndex, + inRepeat: true, + }); + } + return true; } @@ -706,7 +725,7 @@ function matchState(state: MatchState, request: string, pending: MatchState[]) { while (true) { const { parts, partIndex } = state; if (partIndex >= parts.length) { - if (!finalizeNestedRule(state)) { + if (!finalizeNestedRule(state, pending)) { // Finish matching this state. return true; } @@ -719,8 +738,9 @@ function matchState(state: MatchState, request: string, pending: MatchState[]) { `matching type=${JSON.stringify(part.type)} pendingWildcard=${JSON.stringify(state.pendingWildcard)}`, ); - if (part.optional) { - // queue up skipping optional + if (part.optional && !state.inRepeat) { + // queue up skipping optional (suppressed when re-entering a repeat + // group to avoid duplicating already-queued "done" states) const newState = { ...state, partIndex: state.partIndex + 1 }; if (part.variable) { addValue(newState, part.variable, undefined); @@ -759,6 +779,7 @@ function matchState(state: MatchState, request: string, pending: MatchState[]) { partIndex: state.partIndex + 1, valueIds: state.valueIds, parent: state.parent, + repeatPartIndex: part.repeat ? state.partIndex : undefined, }; // The nested rule needs to track values if the current rule is tracking value AND @@ -777,6 +798,7 @@ function matchState(state: MatchState, request: string, pending: MatchState[]) { state.valueIds = requireValue ? undefined : null; state.parent = parent; state.nestedLevel++; + state.inRepeat = undefined; // entering nested rules, clear repeat flag // queue up the other rules (backwards to search in the original order) for (let i = rules.length - 1; i > 0; i--) { @@ -832,7 +854,7 @@ function getGrammarCompletionProperty( } const wildcardPropertyNames: string[] = []; - while (finalizeNestedRule(temp, true)) {} + while (finalizeNestedRule(temp, undefined, true)) {} const match = createValue( temp.value, temp.valueIds, diff --git a/ts/packages/actionGrammar/src/grammarRuleWriter.ts b/ts/packages/actionGrammar/src/grammarRuleWriter.ts index ed92e89d9..5858703ee 100644 --- a/ts/packages/actionGrammar/src/grammarRuleWriter.ts +++ b/ts/packages/actionGrammar/src/grammarRuleWriter.ts @@ -186,12 +186,20 @@ function writeExpression( case "ruleReference": result.write(`<${expr.name}>`); break; - case "rules": + case "rules": { const rules = expr.rules; result.write("("); writeRules(result, rules, indent); - result.write(expr.optional ? ")?" : ")"); + const suffix = expr.repeat + ? expr.optional + ? ")*" + : ")+" + : expr.optional + ? ")?" + : ")"; + result.write(suffix); break; + } case "variable": result.write("$("); result.write(expr.name); diff --git a/ts/packages/actionGrammar/test/grammarMatcher.spec.ts b/ts/packages/actionGrammar/test/grammarMatcher.spec.ts index 5c3fcc0ae..4dbda0b89 100644 --- a/ts/packages/actionGrammar/test/grammarMatcher.spec.ts +++ b/ts/packages/actionGrammar/test/grammarMatcher.spec.ts @@ -355,6 +355,60 @@ describe("Grammar Matcher", () => { ]); }); }); + describe("Repeat GroupExpr", () => { + it("()* - zero matches", () => { + const g = ` = hello (world)* -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect(testMatchGrammar(grammar, "hello")).toStrictEqual([true]); + }); + it("()* - one match", () => { + const g = ` = hello (world)* -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect(testMatchGrammar(grammar, "hello world")).toStrictEqual([ + true, + ]); + }); + it("()* - two matches", () => { + const g = ` = hello (world)* -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect( + testMatchGrammar(grammar, "hello world world"), + ).toStrictEqual([true]); + }); + it("()+ - zero matches not accepted", () => { + const g = ` = hello (world)+ -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect(testMatchGrammar(grammar, "hello")).toStrictEqual([]); + }); + it("()+ - one match", () => { + const g = ` = hello (world)+ -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect(testMatchGrammar(grammar, "hello world")).toStrictEqual([ + true, + ]); + }); + it("()+ - two matches", () => { + const g = ` = hello (world)+ -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect( + testMatchGrammar(grammar, "hello world world"), + ).toStrictEqual([true]); + }); + it("()* - alternates in group", () => { + const g = ` = hello (world | earth)* -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect( + testMatchGrammar(grammar, "hello world earth world"), + ).toStrictEqual([true]); + }); + it("()+ - suffix after repeat", () => { + const g = ` = hello (world)+ end -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + expect( + testMatchGrammar(grammar, "hello world world end"), + ).toStrictEqual([true]); + }); + }); describe("Not matched", () => { it("string expr not separated", () => { const g = ` diff --git a/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts b/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts index 5b1bf0f95..259d2bf46 100644 --- a/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts +++ b/ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts @@ -40,6 +40,15 @@ describe("Grammar Rule Writer", () => { = one | two | three; `); }); + it("kleene star group", () => { + validateRoundTrip(` = hello (world)* end;`); + }); + it("kleene plus group", () => { + validateRoundTrip(` = hello (world)+ end;`); + }); + it("kleene star with alternates", () => { + validateRoundTrip(` = hello (world | earth)* end;`); + }); it("spaces in expressions", () => { validateRoundTrip( ` = ${spaces}${escapedSpaces}${spaces}${escapedSpaces}${spaces};`,