clarity-pattern-parser
Version:
Parsing Library for Typescript and Javascript.
793 lines (653 loc) • 28 kB
text/typescript
import { Sequence } from "../patterns/Sequence";
import { Literal } from "../patterns/Literal";
import { Options } from "../patterns/Options";
import { Pattern } from "../patterns/Pattern";
import { Reference } from "../patterns/Reference";
import { Regex } from "../patterns/Regex";
import { Repeat } from "../patterns/Repeat";
import { AutoComplete, AutoCompleteOptions } from "./AutoComplete";
import { Optional } from "../patterns/Optional";
function generateFlagFromList(flagNames: string[]) {
return flagNames.map(flagName => {
return new Literal('flag-name', flagName);
});
}
function generateFlagPattern(flagNames: string[]): Pattern {
const singleFlagOption = flagNames.length === 1 && flagNames[0];
if (singleFlagOption) {
return new Literal('flag-name', singleFlagOption);
}
const flagPattern = new Options('flags', generateFlagFromList(flagNames));
return flagPattern;
}
export function generateExpression(flagNames: string[]): Repeat {
if (flagNames.length === 0) {
// regex is purposefully impossible to satisfy
const noValidOptionsRegex = new Regex('[No Valid Options Exist]', '(?=a)^(?!a)');
// returning a "Repeat" so as to not break current implementations relying on a Repeat return
const invalidInputExpression = new Repeat(
'impossible_expression',
noValidOptionsRegex
);
return invalidInputExpression;
}
const openParen = new Literal('open-paren', '(');
const closeParen = new Literal('close-paren', ')');
const space = new Regex('[space]', '\\s');
const and = new Literal('sequence-literal', 'AND');
const or = new Literal('options-literal', 'OR');
const not = new Literal('not', 'NOT ');
const booleanOperator = new Options('booleanOperator', [and, or]);
const operatorWithSpaces = new Sequence('operator-with-spaces', [
space,
booleanOperator,
space,
]);
const flag = generateFlagPattern(flagNames);
const group = new Sequence('group', [
openParen,
new Reference('flag-expression'),
closeParen,
]);
const flagOptionsGroup = new Options('flag-or-group', [flag, group]);
const expressionBody = new Sequence('flag-body', [
new Optional("optional-not", not),
flagOptionsGroup,
]);
const flagExpression = new Repeat(
'flag-expression',
expressionBody,
{ divider: operatorWithSpaces, trimDivider: true }
);
return flagExpression;
}
describe("AutoComplete", () => {
test("No Text", () => {
const name = new Literal("name", "Name");
const autoComplete = new AutoComplete(name);
let result = autoComplete.suggestFor("");
expect(result.options[0].text).toBe("Name");
expect(result.options[0].startIndex).toBe(0);
expect(result.errorAtIndex).toBe(0);
expect(result.isComplete).toBeFalsy();
});
test("Full Pattern Match Simple", () => {
const john = new Literal("john", "John");
const space = new Literal("space", " ");
const doe = new Literal("doe", "Doe");
const smith = new Literal("smith", "Smith");
const name = new Sequence("name", [john, space, new Options("last-name", [smith, doe])]);
const autoComplete = new AutoComplete(name);
const result = autoComplete.suggestFor("John Doe");
expect(result.ast?.value).toBe("John Doe");
expect(result.options.length).toBe(0);
expect(result.errorAtIndex).toBeNull();
expect(result.isComplete).toBeTruthy();
expect(result.cursor).not.toBeNull();
});
test("More Than One Option", () => {
const john = new Literal("john", "John");
const space = new Literal("space", " ");
const doe = new Literal("doe", "Doe");
const smith = new Literal("smith", "Smith");
const name = new Sequence("name", [john, space, new Options("last-name", [smith, doe])]);
const text = "John "
const autoComplete = new AutoComplete(name);
const result = autoComplete.suggestFor(text);
const expectedOptions = [{
text: "Doe",
startIndex: 5
}, {
text: "Smith",
startIndex: 5
}];
expect(result.ast).toBeNull();
expect(result.options).toEqual(expectedOptions);
expect(result.errorAtIndex).toBe(text.length);
expect(result.isComplete).toBeFalsy();
expect(result.cursor).not.toBeNull();
});
test("Option should error at furthest match index", () => {
const john = new Literal("john", "John");
const space = new Literal("space", " ");
const doe = new Literal("doe", "Doe");
const smith = new Literal("smith", "Smith");
const name = new Sequence("name", [john, space, new Options("last-name", [smith, doe])]);
const text = "John Smi"
const autoComplete = new AutoComplete(name);
const result = autoComplete.suggestFor(text);
const expectedOptions = [{
text: "th",
startIndex: 8
}];
expect(result.ast).toBeNull();
expect(result.options).toEqual(expectedOptions);
expect(result.errorAtIndex).toBe(text.length);
expect(result.isComplete).toBeFalsy();
expect(result.cursor).not.toBeNull();
});
test("Root Regex Pattern suggests customTokens", () => {
const freeTextPattern = new Regex(
`free-text`,
'[(\\w)\\s]+'
);
const customTokensMap:Record<string, string[]> = {
'free-text': ['luke',"leia skywalker",'luke skywalker']
}
const autoComplete = new AutoComplete(freeTextPattern,{
customTokens:customTokensMap
});
const result = autoComplete.suggestFor("luke");
const expected = [
{ text: " skywalker", startIndex: 4 },
];
expect(result.ast?.value).toBe("luke");
expect(result.options).toEqual(expected);
expect(result.errorAtIndex).toBeNull()
expect(result.isComplete).toBeTruthy();
expect(result.cursor).not.toBeNull();
});
test("Sequence Regex Pattern suggests customTokens", () => {
const jediLiteral = new Literal("jedi", "jedi ");
const freeTextPattern = new Regex(
`free-text`,
'[(\\w)\\s]+'
);
const sequence = new Sequence('sequence', [jediLiteral,freeTextPattern])
const customTokensMap:Record<string, string[]> = {
'free-text': ['luke',"leia skywalker",'luke skywalker']
}
const autoComplete = new AutoComplete(sequence,{
customTokens:customTokensMap
});
const result = autoComplete.suggestFor("jedi luke sky");
const expected = [
{ text: "walker", startIndex: 13 },
];
expect(result.ast?.value).toBe("jedi luke sky");
expect(result.options).toEqual(expected);
expect(result.errorAtIndex).toBeNull()
expect(result.isComplete).toBeTruthy();
expect(result.cursor).not.toBeNull();
});
test("Full Pattern Match With Root Repeat", () => {
const john = new Literal("john", "John");
const space = new Literal("space", " ");
const doe = new Literal("doe", "Doe");
const smith = new Literal("smith", "Smith");
const name = new Sequence("name", [john, space, new Options("last-name", [smith, doe])]);
const divider = new Regex("divider", "\\s+,\\s+");
divider.setTokens([", "])
const text = "John Doe";
const autoComplete = new AutoComplete(new Repeat("last-names", name, { divider }));
const result = autoComplete.suggestFor(text);
const expectedOptions = [{
text: ", ",
startIndex: 8
}];
expect(result.ast?.value).toBe(text);
expect(result.options).toEqual(expectedOptions);
expect(result.errorAtIndex).toBeNull()
expect(result.isComplete).toBeTruthy();
expect(result.cursor).not.toBeNull();
});
test("Partial Simple", () => {
const name = new Literal("name", "Name");
const autoComplete = new AutoComplete(name);
// Use deprecated suggest for code coverage.
const result = autoComplete.suggestFor("Na");
const expectedOptions = [{
text: "me",
startIndex: 2
}];
expect(result.ast).toBeNull();
expect(result.options).toEqual(expectedOptions);
expect(result.errorAtIndex).toBe(2);
expect(result.isComplete).toBeFalsy();
expect(result.cursor).not.toBeNull();
});
test("Partial Match With Bad Characters", () => {
const name = new Literal("name", "Name");
const autoComplete = new AutoComplete(name);
const result = autoComplete.suggestFor("Ni");
const expectedOptions = [{
text: "ame",
startIndex: 1
}];
expect(result.ast).toBeNull();
expect(result.options).toEqual(expectedOptions);
expect(result.errorAtIndex).toBe(1);
expect(result.isComplete).toBeFalsy();
expect(result.cursor).not.toBeNull();
});
test("Complete", () => {
const name = new Literal("name", "Name");
const autoComplete = new AutoComplete(name);
const text = "Name"
const result = autoComplete.suggestFor(text);
expect(result.ast?.value).toBe(text);
expect(result.options).toEqual([]);
expect(result.errorAtIndex).toBeNull();
expect(result.isComplete).toBeTruthy();
expect(result.cursor).not.toBeNull();
});
test("Options AutoComplete on Composing Pattern", () => {
const autoCompleteOptions: AutoCompleteOptions = {
greedyPatternNames: ["space"],
customTokens: {
"last-name": ["Sparrow"]
}
};
const jack = new Literal("jack", "Jack");
const john = new Literal("john", "John");
const space = new Literal("space", " ");
const doe = new Literal("doe", "Doe");
const smith = new Literal("smith", "Smith");
const firstName = new Options("first-name", [jack, john]);
const lastName = new Options("last-name", [doe, smith]);
const fullName = new Sequence("full-name", [firstName, space, lastName]);
const text = "Jack";
const autoComplete = new AutoComplete(fullName, autoCompleteOptions);
const { options, ast, errorAtIndex } = autoComplete.suggestFor(text);
const expectedOptions = [
{ text: " Doe", startIndex: 4 },
{ text: " Smith", startIndex: 4 },
{ text: " Sparrow", startIndex: 4 },
];
const results = expectedOptions.map(o => text.slice(0, o.startIndex) + o.text);
const expectedResults = [
"Jack Doe",
"Jack Smith",
"Jack Sparrow",
]
expect(ast).toBeNull();
expect(errorAtIndex).toBe(4);
expect(options).toEqual(expectedOptions);
expect(results).toEqual(expectedResults);
});
test("Options AutoComplete on Root Pattern", () => {
const jack = new Literal("first-name", "Jack");
const john = new Literal("first-name", "John");
const names = new Options('names', [jack,john]);
const divider = new Literal('divider', ', ');
const repeat = new Repeat('name-list', names, { divider, trimDivider: true });
const text = ''
const autoCompleteOptions: AutoCompleteOptions = {
customTokens: {
'first-name': ["James"]
}
};
const autoComplete = new AutoComplete(repeat,autoCompleteOptions);
const suggestion = autoComplete.suggestFor(text)
const expectedOptions = [
{ text: "Jack", startIndex: 0 },
{ text: "John", startIndex: 0 },
{ text: "James", startIndex: 0 },
];
expect(suggestion.options).toEqual(expectedOptions)
})
test("Options AutoComplete On Leaf Pattern", () => {
const autoCompleteOptions: AutoCompleteOptions = {
greedyPatternNames: ["space"],
customTokens: {
"space": [" "]
}
};
const jack = new Literal("jack", "Jack");
const john = new Literal("john", "John");
const space = new Literal("space", " ");
const doe = new Literal("doe", "Doe");
const smith = new Literal("smith", "Smith");
const firstName = new Options("first-name", [jack, john]);
const lastName = new Options("last-name", [doe, smith]);
const fullName = new Sequence("full-name", [firstName, space, lastName]);
const text = "Jack";
const autoComplete = new AutoComplete(fullName, autoCompleteOptions);
const { options, ast, errorAtIndex } = autoComplete.suggestFor(text);
const expectedOptions = [
{ text: " Doe", startIndex: 4 },
{ text: " Smith", startIndex: 4 },
{ text: " Doe", startIndex: 4 },
{ text: " Smith", startIndex: 4 },
];
const results = expectedOptions.map(o => text.slice(0, o.startIndex) + o.text);
const expectedResults = [
"Jack Doe",
"Jack Smith",
"Jack Doe",
"Jack Smith",
]
expect(ast).toBeNull();
expect(errorAtIndex).toBe(4);
expect(options).toEqual(expectedOptions);
expect(results).toEqual(expectedResults)
});
test("Match On Different Pattern Roots", () => {
const start = new Literal("start", "John went to");
const a = new Literal("a", "a bank.");
const the = new Literal("the", "the store.");
const first = new Sequence("first", [start, a]);
const second = new Sequence("second", [start, the]);
const both = new Options("both", [first, second]);
const autoComplete = new AutoComplete(both);
const result = autoComplete.suggestFor("John went to a gas station.");
const expected = [
{ text: "the store.", startIndex: 12 },
{ text: "a bank.", startIndex: 12 }
];
expect(result.options).toEqual(expected);
});
test("Options on errors because of string ending, with match", () => {
const smalls = new Options("kahns", [
new Literal("large", "kahnnnnnn"),
new Literal("medium", "kahnnnnn"),
new Literal("small", "kahn"),
]);
const autoComplete = new AutoComplete(smalls);
const result = autoComplete.suggestFor("kahn");
const expected = [
{ text: "nnnnn", startIndex: 4 },
{ text: "nnnn", startIndex: 4 }
];
expect(result.options).toEqual(expected);
expect(result.isComplete).toBeTruthy();
});
test("Options on errors because of string ending, between matches", () => {
const smalls = new Options("kahns", [
new Literal("large", "kahnnnnnn"),
new Literal("medium", "kahnnnnn"),
new Literal("small", "kahn"),
]);
const autoComplete = new AutoComplete(smalls);
const result = autoComplete.suggestFor("kahnn");
const expected = [
{ text: "nnnn", startIndex: 5 },
{ text: "nnn", startIndex: 5 }
];
expect(result.options).toEqual(expected);
expect(result.isComplete).toBeFalsy();
});
test("Options on errors because of string ending, match middle", () => {
const smalls = new Options("kahns", [
new Literal("large", "kahnnnnnn"),
new Literal("medium", "kahnnnnn"),
new Literal("small", "kahn"),
]);
const autoComplete = new AutoComplete(smalls);
const result = autoComplete.suggestFor("kahnnnnn");
const expected = [
{ text: "n", startIndex: 8 },
];
expect(result.options).toEqual(expected);
expect(result.isComplete).toBeTruthy();
});
test("Options on errors because of string ending on a variety, with match", () => {
const smalls = new Options("kahns", [
new Literal("different-3", "kahnnnnnnn3"),
new Literal("different-21", "kahnnnnnn21"),
new Literal("different-22", "kahnnnnnn22"),
new Literal("different-2", "kahnnnnnn2"),
new Literal("different", "kahnnnnnn1"),
new Literal("large", "kahnnnnnn"),
new Literal("medium", "kahnnnnn"),
new Literal("small", "kahn"),
]);
const autoComplete = new AutoComplete(smalls);
const result = autoComplete.suggestFor("kahnnnnn");
const expected = [
{ text: "nn3", startIndex: 8 },
{ text: "n21", startIndex: 8 },
{ text: "n22", startIndex: 8 },
{ text: "n2", startIndex: 8 },
{ text: "n1", startIndex: 8 },
{ text: "n", startIndex: 8 },
];
expect(result.options).toEqual(expected);
expect(result.isComplete).toBeTruthy();
});
test("Options on errors because of string ending on different branches, with match", () => {
const smalls = new Options("kahns", [
new Sequence("kahn-combo-3", [new Literal("kah", "kah"), new Sequence('partial', [new Literal("n", "n"), new Literal("three", "3")])]),
new Sequence("kahn-combo", [new Literal("kahn", "kahn"), new Literal("one", "1")]),
new Sequence("kahn-combo-2", [new Literal("kahn", "kahn"), new Literal("two", "2")]),
new Literal("small", "kahn"),
]);
const autoComplete = new AutoComplete(smalls);
const result = autoComplete.suggestFor("kahn");
const expected = [
{ text: "2", startIndex: 4 },
{ text: "1", startIndex: 4 },
{ text: "3", startIndex: 4 },
];
expect(result.options).toEqual(expected);
expect(result.isComplete).toBeTruthy();
});
test("Remove Trailing Divider", () => {
const repeat = new Repeat("repeat", new Literal("a", "a"), { divider: new Literal("pipe", "|"), trimDivider: true });
const autoComplete = new AutoComplete(repeat);
const result = autoComplete.suggestFor("a|a|");
expect(result.options).toEqual([{ text: 'a', startIndex: 4 }]);
});
test("Expect Divider", () => {
const repeat = new Repeat("repeat", new Literal("a", "a"), { divider: new Literal("pipe", "|") });
const autoComplete = new AutoComplete(repeat);
const result = autoComplete.suggestFor("a|a");
expect(result.options).toEqual([{ text: '|', startIndex: 3 }]);
});
test("Repeat with bad second repeat", () => {
const repeat = new Repeat("repeat", new Literal("a", "a"), { divider: new Literal("pipe", "|"), trimDivider: true });
const autoComplete = new AutoComplete(repeat);
const result = autoComplete.suggestFor("a|b");
expect(result.options).toEqual([{ text: 'a', startIndex: 2 }]);
});
test("Repeat with bad trailing content", () => {
const flags = ["FlagA", "FlagB", "FlagC"];
const pattern = generateExpression(flags);
const result = new AutoComplete(pattern).suggestFor("FlagA AND FlagAlkjhgB");
expect(result.options).toEqual([]);
expect(result.ast?.value).toBe("FlagA AND FlagA");
expect(result.errorAtIndex).toBe(15);
});
test("Greedy Options", () => {
const john = new Literal("john", "John");
const doe = new Literal("doe", "Doe");
const jane = new Literal("jane", "Jane");
const smith = new Literal("smith", "Smith");
const space = new Literal("space", " ");
const firstName = new Options("first-name", [john, jane], true);
const lastName = new Options("last-name", [doe, smith], true);
const johnJohnson = new Literal("john-johnson", "John Johnson");
const johnStockton = new Literal("john-stockton", "John Stockton");
const fullName = new Sequence("full-name", [firstName, space, lastName]);
const names = new Options("names", [johnStockton, johnJohnson, fullName], true);
const autoComplete = new AutoComplete(names);
const results = autoComplete.suggestFor("John ");
const expected = [
{
text: "Doe",
startIndex: 5,
},
{
text: "Smith",
startIndex: 5,
},
{
text: "Stockton",
startIndex: 5,
},
{
text: "Johnson",
startIndex: 5,
},
];
expect(results.options).toEqual(expected);
expect(results.ast).toBeNull();
expect(results.errorAtIndex).toBe(5);
});
test("Dedup suggestions", () => {
const branchOne = new Sequence("branch-1", [new Literal("space", " "), new Literal("A", "A")]);
const branchTwo = new Sequence("branch-2", [new Literal("space", " "), new Literal("B", "B")]);
const eitherBranch = new Options("either-branch", [branchOne, branchTwo]);
const autoComplete = new AutoComplete(eitherBranch);
const results = autoComplete.suggestFor("");
const expected = [{
startIndex: 0,
text: " "
}];
expect(results.options).toEqual(expected);
});
test("Multiple Complex Branches", () => {
const branchOne = new Sequence("branch-1", [
new Literal("space-1-1", " "),
new Literal("space-1-2", " "),
new Options('branch-1-options', [
new Literal("AA", "AA"),
new Literal("AB", "AB"),
new Literal("BC", "BC"),
])
]);
const branchTwo = new Sequence("branch-2", [
new Literal("space-2-1", " "),
new Literal("space-2-2", " "),
new Options('branch-2-options', [
new Literal("BA", "BA"),
new Literal("BB", "BB")
])
]);
const eitherBranch = new Options("either-branch", [branchOne, branchTwo]);
const autoComplete = new AutoComplete(eitherBranch);
const results = autoComplete.suggestFor(" B");
const expected = [
{ startIndex: 3, text: "A" },
{ startIndex: 3, text: "B" },
{ startIndex: 3, text: "C" },
];
expect(results.options).toEqual(expected);
});
test("Recursion With Or", () => {
const ref = new Reference("names");
const names = new Options("names", [
ref,
new Literal("john", "John"),
new Literal("jane", "Jane")
]);
const autoComplete = new AutoComplete(names);
const suggestion = autoComplete.suggestFor("Jo");
expect(suggestion.options).toEqual([
{ text: 'hn', startIndex: 2 }
]);
expect(suggestion.error?.lastIndex).toBe(2);
});
test("Recursion With And", () => {
const firstNames = new Options("first-names", [
new Literal("john", "John"),
new Literal("jane", "Jane"),
]);
const lastNames = new Options("last-names", [
new Literal("doe", "Doe"),
new Literal("smith", "Smith"),
]);
const fullName = new Sequence("full-name", [
firstNames,
new Literal("space", " "),
lastNames
]);
const ref = new Reference("names");
const names = new Sequence("names", [
fullName,
ref,
lastNames,
]);
const autoComplete = new AutoComplete(names, {
greedyPatternNames: ["space"]
});
const suggestion = autoComplete.suggestFor("John");
expect(suggestion.options).toEqual([{
"text": " Doe",
"startIndex": 4
}, {
"text": " Smith",
"startIndex": 4
}]);
});
test("Repeat With Options", () => {
const john = new Literal("john", "John");
const jane = new Literal("jane", "Jane");
const names = new Options("names", [john, jane]);
const comma = new Regex("comma", "\\s*,\\s*");
comma.setTokens([", "]);
const list = new Repeat("names-list", names, {
divider: comma
});
const autoComplete = new AutoComplete(list);
const suggestion = autoComplete.suggestFor("John, ");
expect(suggestion).toBe(suggestion);
});
test("Repeat With Options With Options", () => {
const john = new Literal("john", "John");
const jane = new Literal("jane", "Jane");
const jack = new Literal("jack", "Jack");
const jill = new Literal("jill", "Jill");
const otherNames = new Options("names", [jack, jill]);
const names = new Options("names", [john, jane, otherNames]);
const comma = new Regex("comma", "\\s*,\\s*");
comma.setTokens([", "]);
const list = new Repeat("names-list", names, {
divider: comma,
trimDivider: true
});
const autoComplete = new AutoComplete(list, { greedyPatternNames: ["comma"] });
const suggestion = autoComplete.suggestFor("John");
expect(suggestion).toBe(suggestion);
});
test("Mid sequence suggestion", () => {
const operator = new Options("operator", [
new Literal("==", "=="),
new Literal("!=", "!="),
]);
const leftOperands = new Options("left-operands", [
new Literal("variable-1", "variable1"),
new Literal("variable-2", "variable2"),
]);
const rightOperands = new Options("right-operands", [
new Literal("variable-3", "variable3"),
new Literal("variable-4", "variable4"),
]);
const optionalSpace = new Optional("optional-sapce", new Regex("space", "\\s+"));
const statement = new Sequence("statement", [leftOperands, optionalSpace, operator, optionalSpace, rightOperands]);
const autoComplete = new AutoComplete(statement, { greedyPatternNames: ["space", "operator"], customTokens: { space: [" "] } }
);
const result = autoComplete.suggestFor("variable1 ==");
expect(result.options).toEqual([{
text: " ",
startIndex: 12,
},
{
text: "variable3",
startIndex: 12,
},
{
text: "variable4",
startIndex: 12,
}
]);
});
test("Calling AutoComplete -> _getAllOptions Does not mutate customTokensMap", () => {
const jediLuke = new Literal(`jedi`, 'luke');
const names = new Options('names', [jediLuke]);
const divider = new Literal('divider', ', ');
const pattern = new Repeat('name-list', names, { divider, trimDivider: true });
const customTokensMap:Record<string, string[]> = {
'jedi': ["leia"]
}
const copiedCustomTokensMap:Record<string, string[]> = JSON.parse(JSON.stringify(customTokensMap));
const autoCompleteOptions: AutoCompleteOptions = {
greedyPatternNames:['jedi'],
customTokens: customTokensMap
};
const autoComplete = new AutoComplete(pattern,autoCompleteOptions);
// provide a non-empty input to trigger the logic flow that hits _getAllOptions
autoComplete.suggestFor('l')
expect(customTokensMap).toEqual(copiedCustomTokensMap)
})
});