Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions ts/packages/actionGrammar/src/grammarMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?:
| {
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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--) {
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions ts/packages/actionGrammar/src/grammarRuleWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions ts/packages/actionGrammar/test/grammarMatcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,60 @@ describe("Grammar Matcher", () => {
]);
});
});
describe("Repeat GroupExpr", () => {
it("()* - zero matches", () => {
const g = `<Start> = hello (world)* -> true;`;
const grammar = loadGrammarRules("test.grammar", g);
expect(testMatchGrammar(grammar, "hello")).toStrictEqual([true]);
});
it("()* - one match", () => {
const g = `<Start> = hello (world)* -> true;`;
const grammar = loadGrammarRules("test.grammar", g);
expect(testMatchGrammar(grammar, "hello world")).toStrictEqual([
true,
]);
});
it("()* - two matches", () => {
const g = `<Start> = hello (world)* -> true;`;
const grammar = loadGrammarRules("test.grammar", g);
expect(
testMatchGrammar(grammar, "hello world world"),
).toStrictEqual([true]);
});
it("()+ - zero matches not accepted", () => {
const g = `<Start> = hello (world)+ -> true;`;
const grammar = loadGrammarRules("test.grammar", g);
expect(testMatchGrammar(grammar, "hello")).toStrictEqual([]);
});
it("()+ - one match", () => {
const g = `<Start> = hello (world)+ -> true;`;
const grammar = loadGrammarRules("test.grammar", g);
expect(testMatchGrammar(grammar, "hello world")).toStrictEqual([
true,
]);
});
it("()+ - two matches", () => {
const g = `<Start> = hello (world)+ -> true;`;
const grammar = loadGrammarRules("test.grammar", g);
expect(
testMatchGrammar(grammar, "hello world world"),
).toStrictEqual([true]);
});
it("()* - alternates in group", () => {
const g = `<Start> = 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 = `<Start> = 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 = `
Expand Down
9 changes: 9 additions & 0 deletions ts/packages/actionGrammar/test/grammarRuleWriter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ describe("Grammar Rule Writer", () => {
<other> = one | two | three;
`);
});
it("kleene star group", () => {
validateRoundTrip(`<test> = hello (world)* end;`);
});
it("kleene plus group", () => {
validateRoundTrip(`<test> = hello (world)+ end;`);
});
it("kleene star with alternates", () => {
validateRoundTrip(`<test> = hello (world | earth)* end;`);
});
it("spaces in expressions", () => {
validateRoundTrip(
`<test> = ${spaces}${escapedSpaces}${spaces}${escapedSpaces}${spaces};`,
Expand Down