Rename folders

This commit is contained in:
Christopher Schleiden
2023-02-22 15:52:40 -08:00
parent 16cc4d9bda
commit 2a3d63551f
469 changed files with 0 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import {ExpressionData} from "./data";
import {Token} from "./lexer";
export interface ExprVisitor<R> {
visitLiteral(literal: Literal): R;
visitUnary(unary: Unary): R;
visitBinary(binary: Binary): R;
visitLogical(binary: Logical): R;
visitGrouping(grouping: Grouping): R;
visitContextAccess(contextAccess: ContextAccess): R;
visitIndexAccess(indexAccess: IndexAccess): R;
visitFunctionCall(functionCall: FunctionCall): R;
}
export abstract class Expr {
abstract accept<R>(v: ExprVisitor<R>): R;
}
export class Literal extends Expr {
constructor(public literal: ExpressionData, public token: Token) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitLiteral(this);
}
}
export class Unary extends Expr {
constructor(public operator: Token, public expr: Expr) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitUnary(this);
}
}
export class FunctionCall extends Expr {
constructor(public functionName: Token, public args: Expr[]) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitFunctionCall(this);
}
}
export class Binary extends Expr {
constructor(public left: Expr, public operator: Token, public right: Expr) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitBinary(this);
}
}
export class Logical extends Expr {
constructor(public operator: Token, public args: Expr[]) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitLogical(this);
}
}
export class Grouping extends Expr {
constructor(public group: Expr) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitGrouping(this);
}
}
export class ContextAccess extends Expr {
constructor(public name: Token) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitContextAccess(this);
}
}
export class IndexAccess extends Expr {
constructor(public expr: Expr, public index: Expr) {
super();
}
accept<R>(v: ExprVisitor<R>): R {
return v.visitIndexAccess(this);
}
}
export class Star extends Expr {
accept<R>(v: ExprVisitor<R>): R {
throw new Error("Method not implemented.");
}
}
+251
View File
@@ -0,0 +1,251 @@
import {complete, CompletionItem, trimTokenVector} from "./completion";
import {DescriptionDictionary} from "./completion/descriptionDictionary";
import {BooleanData} from "./data/boolean";
import {Dictionary} from "./data/dictionary";
import {StringData} from "./data/string";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, TokenType} from "./lexer";
const testContext = new Dictionary(
{
key: "env",
value: new Dictionary(
{
key: "FOO",
value: new StringData("")
},
{
key: "BAR_TEST",
value: new StringData("")
}
)
},
{
key: "github",
value: new DescriptionDictionary(
{
key: "actor",
value: new StringData(""),
description: "The name of the person or app that initiated the workflow. For example, octocat."
},
{
key: "inputs",
value: new DescriptionDictionary({
key: "name",
value: new StringData("monalisa"),
description: "The name of a person"
})
}
)
},
{
key: "secrets",
value: new Dictionary({
key: "AWS_TOKEN",
value: new BooleanData(true)
})
},
{
key: "vars",
value: new Dictionary({
key: "VAR_NAME",
value: new BooleanData(true)
})
},
{
key: "hashFiles(1,255)",
value: new Dictionary()
}
);
const testFunctions: FunctionInfo[] = [];
const testComplete = (input: string, functions?: Map<string, FunctionDefinition>): CompletionItem[] => {
const pos = input.indexOf("|");
input = input.replace("|", "");
const results = complete(input.slice(0, pos >= 0 ? pos : input.length), testContext, testFunctions, functions);
return results;
};
function completionItems(...labels: string[]): CompletionItem[] {
return labels.map(label => ({label, function: false}));
}
describe("auto-complete", () => {
describe("top-level", () => {
it("includes built-in functions", () => {
const expected: CompletionItem = {
label: "toJson",
description:
"`toJSON(value)`\n\nReturns a pretty-print JSON representation of `value`. You can use this function to debug the information provided in contexts.",
function: true
};
expect(testComplete("to")).toContainEqual(expected);
expect(testComplete("toJs")).toContainEqual(expected);
expect(testComplete("1 == toJS")).toContainEqual(expected);
expect(testComplete("1 == (toJS")).toContainEqual(expected);
expect(testComplete("toJS| == 1")).toContainEqual(expected);
expect(testComplete("(toJS| == (foo.bar)")).toContainEqual(expected);
expect(testComplete("(((toJS| == (foo.bar)")).toContainEqual(expected);
});
it("removes parentheses from passed in function context", () => {
expect(testComplete("|")).toContainEqual({
label: "hashFiles",
function: true
});
});
});
describe("in multi-line expressions", () => {
it("includes built-in functions", () => {
expect(testComplete("1 == (\nto").map(x => x.label)).toContainEqual("toJson");
});
});
describe("functions", () => {
it("uses provided function definitions", () => {
expect(
testComplete(
"fromJson('invalid').|",
new Map(
Object.entries({
fromjson: {
...wellKnownFunctions.fromjson,
call: () =>
new Dictionary({
key: "foo",
value: new StringData("bar")
})
}
})
)
)
).toEqual<CompletionItem[]>([{label: "foo", function: false}]);
});
});
describe("for contexts", () => {
it("provides suggestions for top-level context", () => {
const expected = completionItems("BAR_TEST", "FOO");
expect(testComplete("env.X")).toEqual(expected);
expect(testComplete("1 == env.F")).toEqual(expected);
expect(testComplete("env.")).toEqual(expected);
expect(testComplete("env.FOO")).toEqual(expected);
expect(testComplete("(env).")).toEqual(expected);
});
it("provides suggestions for nested context", () => {
const expected: CompletionItem[] = [
{
label: "name",
function: false,
description: "The name of a person"
}
];
expect(testComplete("github.inputs.|")).toEqual(expected);
expect(testComplete("(github).inputs.|")).toEqual(expected);
expect(testComplete("(github.inputs).|")).toEqual(expected);
expect(testComplete("'test' == github.inputs.|")).toEqual(expected);
expect(testComplete("github.inputs.| == 'monalisa'")).toEqual(expected);
});
it("provides suggestions for secrets", () => {
const expected = completionItems("AWS_TOKEN");
expect(testComplete("secrets.A")).toEqual(expected);
expect(testComplete("1 == secrets.F")).toEqual(expected);
expect(testComplete("toJSON(secrets.")).toEqual(expected);
});
it("provides suggestions for variables", () => {
const expected = completionItems("VAR_NAME");
expect(testComplete("vars.V")).toEqual(expected);
expect(testComplete("1 == vars.F")).toEqual(expected);
expect(testComplete("toJSON(vars.")).toEqual(expected);
});
it("provides suggestions for contexts in function call", () => {
expect(testComplete("toJSON(env.|)")).toEqual(completionItems("BAR_TEST", "FOO"));
expect(testComplete("toJSON(secrets.")).toEqual(completionItems("AWS_TOKEN"));
});
describe("with descriptions", () => {
it("top-level", () => {
expect(testComplete("github.")).toContainEqual<CompletionItem>({
label: "actor",
function: false,
description: "The name of the person or app that initiated the workflow. For example, octocat."
});
});
it("nested", () => {
expect(testComplete("github.inputs.")).toContainEqual<CompletionItem>({
label: "name",
function: false,
description: "The name of a person"
});
});
});
});
});
describe("trimTokenVector", () => {
test.each<{
input: string;
expected: TokenType[];
}>([
{
input: "env.",
expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.EOF]
},
{
input: "github.act",
expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.IDENTIFIER, TokenType.EOF]
},
{
input: "1 == github.act",
expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.IDENTIFIER, TokenType.EOF]
},
{
input: "github.mona == github.act",
expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.IDENTIFIER, TokenType.EOF]
},
{
input: "github.mona == (github).act",
expected: [
TokenType.LEFT_PAREN,
TokenType.IDENTIFIER,
TokenType.RIGHT_PAREN,
TokenType.DOT,
TokenType.IDENTIFIER,
TokenType.EOF
]
},
{
input: "github.mona == (github.",
expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.EOF]
},
{
input: "github['test'].",
expected: [
TokenType.IDENTIFIER,
TokenType.LEFT_BRACKET,
TokenType.STRING,
TokenType.RIGHT_BRACKET,
TokenType.DOT,
TokenType.EOF
]
}
])("$input", ({input, expected}) => {
const l = new Lexer(input);
const lr = l.lex();
const tv = trimTokenVector(lr.tokens);
expect(tv.map(x => x.type)).toEqual(expected);
});
});
+171
View File
@@ -0,0 +1,171 @@
import {DescriptionPair} from "./completion/descriptionDictionary";
import {Dictionary, isDictionary} from "./data/dictionary";
import {ExpressionData} from "./data/expressiondata";
import {Evaluator} from "./evaluator";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, Token, TokenType} from "./lexer";
import {Parser} from "./parser";
export type CompletionItem = {
label: string;
description?: string;
function: boolean;
};
/**
* Complete returns a list of completion items for the given expression.
* The main functionality is auto-completing functions and context access:
* We can only provide assistance if the input is in one of the following forms (with | denoting the cursor position):
* - context.path.inp| or context.path['inp| -- auto-complete context access
* - context.path.| or context.path['| -- auto-complete context access
* - toJS| -- auto-complete function call or top-level
* - | -- auto-complete function call or top-level context access
*
* @param input Input expression
* @param context Context available for the expression
* @param extensionFunctions List of functions available
* @param functions Optional map of functions to use during evaluation
* @returns Array of completion items
*/
export function complete(
input: string,
context: Dictionary,
extensionFunctions: FunctionInfo[],
functions?: Map<string, FunctionDefinition>
): CompletionItem[] {
// Lex
const lexer = new Lexer(input);
const lexResult = lexer.lex();
// Find interesting part of the tokenVector. For example, for an expression like `github.actor == env.actor.log|`, we are
// only interested in the `env.actor.log` part for auto-completion
const tokenInputVector = trimTokenVector(lexResult.tokens);
// Start by skipping the EOF token
let tokenIdx = tokenInputVector.length - 2;
if (tokenIdx >= 0) {
switch (tokenInputVector[tokenIdx].type) {
// If there is a (partial) identifier under the cursor, ignore that
case TokenType.IDENTIFIER:
tokenIdx--;
break;
case TokenType.STRING:
// TODO: Support string for `context.name['test|`
return [];
}
}
if (tokenIdx < 0) {
// Vector only contains the EOF token. Suggest functions and root context access
const result = contextKeys(context);
// Merge with functions
result.push(...functionItems(extensionFunctions));
return result;
}
// Determine path that led to the last token
// Use parser & evaluator to determine context to complete.
const pathTokenVector = tokenInputVector.slice(0, tokenIdx);
// Include the original EOF token to make the parser happy
pathTokenVector.push(tokenInputVector[tokenInputVector.length - 1]);
const p = new Parser(
pathTokenVector,
context.pairs().map(x => x.key),
extensionFunctions
);
const expr = p.parse();
const ev = new Evaluator(expr, context, functions);
const result = ev.evaluate();
return contextKeys(result);
}
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
const result: CompletionItem[] = [];
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
result.push({
label: fdef.name,
description: fdef.description,
function: true
});
}
// Sort functions
result.sort((a, b) => a.label.localeCompare(b.label));
return result;
}
function contextKeys(context: ExpressionData): CompletionItem[] {
if (isDictionary(context)) {
return (
context
.pairs()
.map(x => completionItemFromContext(x))
// Sort contexts
.sort((a, b) => a.label.localeCompare(b.label))
);
}
return [];
}
function completionItemFromContext(pair: DescriptionPair): CompletionItem {
const context = pair.key.toString();
const parenIndex = context.indexOf("(");
const isFunc = parenIndex >= 0 && context.indexOf(")") >= 0;
return {
label: isFunc ? context.substring(0, parenIndex) : context,
description: pair.description,
function: isFunc
};
}
export function trimTokenVector(tokenVector: Token[]): Token[] {
let tokenIdx = tokenVector.length;
let openParen = 0;
while (tokenIdx > 0) {
const token = tokenVector[tokenIdx - 1];
switch (token.type) {
case TokenType.LEFT_PAREN:
if (openParen == 0) {
// Encountered an open parenthesis without a closing first, stop here
break;
}
openParen--;
tokenIdx--;
continue;
case TokenType.RIGHT_PAREN:
openParen++;
tokenIdx--;
continue;
case TokenType.IDENTIFIER:
case TokenType.DOT:
case TokenType.EOF:
case TokenType.LEFT_BRACKET:
case TokenType.RIGHT_BRACKET:
case TokenType.STRING:
tokenIdx--;
continue;
}
break;
}
// Only keep the part of the token vector we're interested in
return tokenVector.slice(tokenIdx);
}
@@ -0,0 +1,31 @@
import {StringData} from "../data";
import {DescriptionDictionary} from "./descriptionDictionary";
describe("description dictionary", () => {
it("pairs contains all values", () => {
const d = new DescriptionDictionary();
d.add("ABC", new StringData("val"));
expect(d.pairs()).toEqual([{key: "ABC", value: new StringData("val")}]);
});
it("does not add duplicate entries", () => {
const d = new DescriptionDictionary();
d.add("ABC", new StringData("val1"));
d.add("ABC", new StringData("val2"));
d.add("abc", new StringData("val3"));
expect(d.pairs()).toEqual([{key: "ABC", value: new StringData("val1")}]);
});
it("can set optional descriptions", () => {
const d = new DescriptionDictionary();
d.add("ABC", new StringData("val"), "desc");
d.add("DEF", new StringData("val"));
expect(d.pairs()).toEqual([
{key: "ABC", value: new StringData("val"), description: "desc"},
{key: "DEF", value: new StringData("val")}
]);
});
});
@@ -0,0 +1,42 @@
import {Dictionary} from "../data/dictionary";
import {ExpressionData, Kind, Pair} from "../data/expressiondata";
export type DescriptionPair = Pair & {description?: string};
export function isDescriptionDictionary(x: ExpressionData): x is DescriptionDictionary {
return x.kind === Kind.Dictionary && x instanceof DescriptionDictionary;
}
export class DescriptionDictionary extends Dictionary {
private readonly descriptions = new Map<string, string>();
public complete: boolean = true;
constructor(...pairs: DescriptionPair[]) {
super();
for (const p of pairs) {
this.add(p.key, p.value, p.description);
}
}
override add(key: string, value: ExpressionData, description?: string): void {
if (this.get(key) !== undefined) {
// Key already added, ignore
return;
}
super.add(key, value);
if (description) {
this.descriptions.set(key, description);
}
}
override pairs(): DescriptionPair[] {
const pairs = super.pairs();
return pairs.map(p => ({...p, description: this.descriptions.get(p.key)}));
}
getDescription(key: string): string | undefined {
return this.descriptions.get(key);
}
}
+35
View File
@@ -0,0 +1,35 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
export class Array implements ExpressionDataInterface {
private v: ExpressionData[] = [];
constructor(...data: ExpressionData[]) {
for (const d of data) {
this.add(d);
}
}
public readonly kind = Kind.Array;
public primitive = false;
coerceString(): string {
return kindStr(this.kind);
}
number(): number {
return NaN;
}
add(value: ExpressionData) {
this.v.push(value);
}
get(index: number): ExpressionData {
return this.v[index];
}
values(): ExpressionData[] {
return this.v;
}
}
+25
View File
@@ -0,0 +1,25 @@
import {ExpressionDataInterface, Kind} from "./expressiondata";
export class BooleanData implements ExpressionDataInterface {
constructor(public readonly value: boolean) {}
public readonly kind = Kind.Boolean;
public primitive = true;
coerceString(): string {
if (this.value) {
return "true";
}
return "false";
}
number(): number {
if (this.value) {
return 1;
}
return 0;
}
}
+19
View File
@@ -0,0 +1,19 @@
import {Dictionary} from "./dictionary";
import {StringData} from "./string";
describe("dictionary", () => {
it("pairs contains all values", () => {
const d = new Dictionary();
d.add("ABC", new StringData("val"));
expect(d.pairs()).toEqual([{key: "ABC", value: new StringData("val")}]);
});
it("does not add duplicate entries", () => {
const d = new Dictionary();
d.add("ABC", new StringData("val1"));
d.add("abc", new StringData("val2"));
expect(d.pairs()).toEqual([{key: "ABC", value: new StringData("val1")}]);
});
});
+62
View File
@@ -0,0 +1,62 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
export class Dictionary implements ExpressionDataInterface {
private keys: string[] = [];
private v: ExpressionData[] = [];
private indexMap: {[key: string]: number} = {};
constructor(...pairs: Pair[]) {
for (const p of pairs) {
this.add(p.key, p.value);
}
}
public readonly kind = Kind.Dictionary;
public primitive = false;
coerceString(): string {
return kindStr(this.kind);
}
number(): number {
return NaN;
}
add(key: string, value: ExpressionData) {
if (key.toLowerCase() in this.indexMap) {
return;
}
this.keys.push(key);
this.v.push(value);
this.indexMap[key.toLowerCase()] = this.v.length - 1;
}
get(key: string): ExpressionData | undefined {
const index = this.indexMap[key.toLowerCase()];
if (index === undefined) {
return undefined;
}
return this.v[index];
}
values(): ExpressionData[] {
return this.v;
}
pairs(): Pair[] {
const result: Pair[] = [];
for (const key of this.keys) {
result.push({key, value: this.v[this.indexMap[key.toLowerCase()]]});
}
return result;
}
}
export function isDictionary(x: ExpressionData): x is Dictionary {
return x.kind === Kind.Dictionary;
}
+51
View File
@@ -0,0 +1,51 @@
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {Array} from "./array";
import {StringData} from "./string";
import {NumberData} from "./number";
import {BooleanData} from "./boolean";
export enum Kind {
String = 0,
Array,
Dictionary,
Boolean,
Number,
CaseSensitiveDictionary,
Null
}
export function kindStr(k: Kind): string {
switch (k) {
case Kind.Array:
return "Array";
case Kind.Boolean:
return "Boolean";
case Kind.Null:
return "Null";
case Kind.Number:
return "Number";
case Kind.Dictionary:
return "Object";
case Kind.String:
return "String";
}
return "unknown";
}
export interface ExpressionDataInterface {
kind: Kind;
primitive: boolean;
coerceString(): string;
number(): number;
}
export type ExpressionData = Array | Dictionary | StringData | BooleanData | NumberData | Null;
export type Pair = {
key: string;
value: ExpressionData;
};
+9
View File
@@ -0,0 +1,9 @@
export {Array} from "./array";
export {BooleanData} from "./boolean";
export {Dictionary} from "./dictionary";
export {ExpressionData, Kind} from "./expressiondata";
export {Null} from "./null";
export {NumberData} from "./number";
export {replacer} from "./replacer";
export {reviver} from "./reviver";
export {StringData} from "./string";
+17
View File
@@ -0,0 +1,17 @@
import {ExpressionData, ExpressionDataInterface, Kind} from "./expressiondata";
export class Null implements ExpressionDataInterface {
constructor() {}
public readonly kind = Kind.Null;
public primitive = true;
coerceString(): string {
return "";
}
number(): number {
return 0;
}
}
+23
View File
@@ -0,0 +1,23 @@
import {ExpressionDataInterface, Kind} from "./expressiondata";
export class NumberData implements ExpressionDataInterface {
constructor(public readonly value: number) {}
public readonly kind = Kind.Number;
public primitive = true;
coerceString(): string {
if (this.value === -0) {
return "0";
}
// Workaround to limit the precision to at most 15 digits. Format the number to a string, then parse
// it back to a number to remove trailing zeroes to prevent numbers to be converted to 1.200000000...
return (+this.value.toFixed(15)).toString();
}
number(): number {
return this.value;
}
}
+24
View File
@@ -0,0 +1,24 @@
import {Array} from "./array";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {NumberData} from "./number";
import {replacer} from "./replacer";
import {StringData} from "./string";
describe("replacer", () => {
it("null", () => {
expect(JSON.stringify(new Null(), replacer, " ")).toEqual("null");
});
it("array", () => {
expect(JSON.stringify(new Array(new StringData("a"), new StringData("b")), replacer, " ")).toEqual(
'[\n "a",\n "b"\n]'
);
});
it("dictionary", () => {
expect(JSON.stringify(new Dictionary({key: "a", value: new NumberData(42)}), replacer, " ")).toEqual(
'{\n "a": 42\n}'
);
});
});
+46
View File
@@ -0,0 +1,46 @@
import {Array} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {NumberData} from "./number";
import {StringData} from "./string";
/**
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
*
* See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#replacer
*/
export function replacer(key: string, value: any): any {
if (value instanceof Null) {
return null;
}
if (value instanceof BooleanData) {
return value.value;
}
if (value instanceof NumberData) {
return value.number();
}
if (value instanceof StringData) {
return value.coerceString();
}
if (value instanceof Array) {
return value.values();
}
if (value instanceof Dictionary) {
const pairs = value.pairs();
const r: any = {};
for (const p of pairs) {
r[p.key] = p.value;
}
return r;
}
return value;
}
+81
View File
@@ -0,0 +1,81 @@
import {Array} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata";
import {Null} from "./null";
import {NumberData} from "./number";
import {reviver} from "./reviver";
import {StringData} from "./string";
describe("reviver", () => {
const tests: {
name: string;
data: string;
want: ExpressionData;
}[] = [
{
name: "null",
data: "null",
want: new Null()
},
{
name: "number",
data: "1",
want: new NumberData(1)
},
{
name: "string",
data: `"a"`,
want: new StringData("a")
},
{
name: "true boolean",
data: "true",
want: new BooleanData(true)
},
{
name: "false boolean",
data: "false",
want: new BooleanData(false)
},
{
name: "array",
data: `[1,2,3]`,
want: new Array(new NumberData(1), new NumberData(2), new NumberData(3))
},
{
name: "nested array",
data: `[1,2,[3,4],5]`,
want: new Array(
new NumberData(1),
new NumberData(2),
new Array(new NumberData(3), new NumberData(4)),
new NumberData(5)
)
},
{
name: "complex array",
data: `[{"a":[true,2]},{"b":"three"},{"c":null}]`,
want: new Array(
new Dictionary({
key: "a",
value: new Array(new BooleanData(true), new NumberData(2))
}),
new Dictionary({key: "b", value: new StringData("three")}),
new Dictionary({key: "c", value: new Null()})
)
},
{
name: "dictionary",
data: `{ "object1": {} }`,
want: new Dictionary({
key: "object1",
value: new Dictionary()
})
}
];
test.each(tests)("$name", ({data, want}: {data: string; want: ExpressionData}) => {
expect(JSON.parse(data, reviver)).toEqual(want);
});
});
+49
View File
@@ -0,0 +1,49 @@
import {Array as dArray} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {ExpressionData, Pair} from "./expressiondata";
import {Null} from "./null";
import {NumberData} from "./number";
import {StringData} from "./string";
/**
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
*
* See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#reviver
*/
export function reviver(key: string, val: any): ExpressionData {
if (val === null) {
return new Null();
}
if (typeof val === "string") {
return new StringData(val);
}
if (typeof val === "number") {
return new NumberData(val);
}
if (typeof val === "boolean") {
return new BooleanData(val as boolean);
}
if (Array.isArray(val)) {
return new dArray(...val);
}
if (typeof val === "object") {
return new Dictionary(
...Object.keys(val).map(
k =>
({
key: k,
value: val[k]
} as Pair)
)
);
}
// Pass through value
return val;
}
+17
View File
@@ -0,0 +1,17 @@
import {ExpressionDataInterface, Kind} from "./expressiondata";
export class StringData implements ExpressionDataInterface {
constructor(public readonly value: string) {}
public readonly kind = Kind.String;
public primitive = true;
coerceString(): string {
return this.value;
}
number(): number {
return Number(this.value);
}
}
+54
View File
@@ -0,0 +1,54 @@
import {Pos, Token, tokenString} from "./lexer";
export const MAX_PARSER_DEPTH = 50;
export const MAX_EXPRESSION_LENGTH = 21000;
export enum ErrorType {
ErrorUnexpectedSymbol,
ErrorUnrecognizedNamedValue,
ErrorUnexpectedEndOfExpression,
ErrorExceededMaxDepth,
ErrorExceededMaxLength,
ErrorTooFewParameters,
ErrorTooManyParameters,
ErrorUnrecognizedContext,
ErrorUnrecognizedFunction
}
export class ExpressionError extends Error {
constructor(private typ: ErrorType, private tok: Token) {
super(`${errorDescription(typ)}: '${tokenString(tok)}'`);
this.pos = this.tok.range.start;
}
public pos: Pos;
}
function errorDescription(typ: ErrorType): string {
switch (typ) {
case ErrorType.ErrorUnexpectedEndOfExpression:
return "Unexpected end of expression";
case ErrorType.ErrorUnexpectedSymbol:
return "Unexpected symbol";
case ErrorType.ErrorUnrecognizedNamedValue:
return "Unrecognized named-value";
case ErrorType.ErrorExceededMaxDepth:
return `Exceeded max expression depth ${MAX_PARSER_DEPTH}`;
case ErrorType.ErrorExceededMaxLength:
return `Exceeded max expression length ${MAX_EXPRESSION_LENGTH}`;
case ErrorType.ErrorTooFewParameters:
return "Too few parameters supplied";
case ErrorType.ErrorTooManyParameters:
return "Too many parameters supplied";
case ErrorType.ErrorUnrecognizedContext:
return "Unrecognized named-value";
case ErrorType.ErrorUnrecognizedFunction:
return "Unrecognized function";
default: // Should never reach here.
return "Unknown error";
}
}
export class ExpressionEvaluationError extends Error {}
+42
View File
@@ -0,0 +1,42 @@
import * as data from "./data";
import {ExpressionEvaluationError} from "./errors";
import {Evaluator} from "./evaluator";
import {Lexer} from "./lexer";
import {Parser} from "./parser";
describe("evaluator", () => {
const lexAndParse = (input: string) => {
const lexer = new Lexer(input);
const result = lexer.lex();
// Parse
const parser = new Parser(result.tokens, ["foo"], []);
const expr = parser.parse();
return expr;
};
it("basic evaluation", () => {
const expr = lexAndParse("foo['']");
// Evaluate expression
const evaluator = new Evaluator(
expr,
new data.Dictionary({
key: "foo",
value: new data.Dictionary({key: "bar", value: new data.NumberData(42)})
})
);
const eresult = evaluator.evaluate();
expect(eresult.kind).toBe(data.Kind.Null);
});
it("handle runtime errors", () => {
const expr = lexAndParse("fromJson('test') == 123");
const evaluator = new Evaluator(expr, new data.Dictionary());
expect(() => evaluator.evaluate()).toThrowError(
new ExpressionEvaluationError("Error parsing JSON when evaluating fromJson")
);
});
});
+237
View File
@@ -0,0 +1,237 @@
import {
Binary,
ContextAccess,
Expr,
ExprVisitor,
FunctionCall,
Grouping,
IndexAccess,
Literal,
Logical,
Star,
Unary
} from "./ast";
import * as data from "./data";
import {FilteredArray} from "./filtered_array";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition} from "./funcs/info";
import {idxHelper} from "./idxHelper";
import {TokenType} from "./lexer";
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
export class Evaluator implements ExprVisitor<data.ExpressionData> {
/**
* Creates a new evaluator
* @param n Parsed expression to evaluate
* @param context Context data to use
* @param functions Optional map of function implementations. If given, these will be preferred over the built-in functions.
*/
constructor(private n: Expr, private context: data.Dictionary, private functions?: Map<string, FunctionDefinition>) {}
public evaluate(): data.ExpressionData {
return this.eval(this.n);
}
private eval(n: Expr): data.ExpressionData {
return n.accept(this);
}
visitLiteral(literal: Literal): data.ExpressionData {
return literal.literal;
}
visitUnary(unary: Unary): data.ExpressionData {
const r = this.eval(unary.expr);
if (unary.operator.type === TokenType.BANG) {
return new data.BooleanData(falsy(r));
}
throw new Error(`unknown unary operator: ${unary.operator.lexeme}`);
}
visitBinary(binary: Binary): data.ExpressionData {
const left = this.eval(binary.left);
const right = this.eval(binary.right);
switch (binary.operator.type) {
case TokenType.EQUAL_EQUAL:
return new data.BooleanData(equals(left, right));
case TokenType.BANG_EQUAL:
return new data.BooleanData(!equals(left, right));
case TokenType.GREATER:
return new data.BooleanData(greaterThan(left, right));
case TokenType.GREATER_EQUAL:
return new data.BooleanData(equals(left, right) || greaterThan(left, right));
case TokenType.LESS:
return new data.BooleanData(lessThan(left, right));
case TokenType.LESS_EQUAL:
return new data.BooleanData(equals(left, right) || lessThan(left, right));
}
throw new Error(`unknown binary operator: ${binary.operator.lexeme}`);
}
visitLogical(logical: Logical): data.ExpressionData {
let result: data.ExpressionData;
for (const arg of logical.args) {
result = this.eval(arg);
// Break?
if (
(logical.operator.type === TokenType.AND && falsy(result)) ||
(logical.operator.type === TokenType.OR && truthy(result))
) {
break;
}
}
// result is always assigned before we return here
return result!;
}
visitGrouping(grouping: Grouping): data.ExpressionData {
return this.eval(grouping.group);
}
visitContextAccess(contextAccess: ContextAccess): data.ExpressionData {
const r = this.context.get(contextAccess.name.lexeme)!;
return r;
}
visitIndexAccess(ia: IndexAccess): data.ExpressionData {
let idx: idxHelper;
if (ia.index instanceof Star) {
idx = new idxHelper(true, undefined);
} else {
let idxResult: data.ExpressionData;
try {
idxResult = this.eval(ia.index);
} catch (e) {
throw new Error(`could not evaluate index for index access: ${e}`, {cause: e});
}
idx = new idxHelper(false, idxResult);
}
const objResult = this.eval(ia.expr);
let result: data.ExpressionData;
switch (objResult.kind) {
case data.Kind.Array: {
const tobjResult = objResult as data.Array;
if (tobjResult instanceof FilteredArray) {
result = filteredArrayAccess(tobjResult as FilteredArray, idx);
} else {
result = arrayAccess(tobjResult, idx);
}
break;
}
case data.Kind.Dictionary: {
const tobjResult = objResult as data.Dictionary;
result = objectAccess(tobjResult, idx);
break;
}
default:
if (idx.star) {
result = new FilteredArray();
} else {
result = new data.Null();
}
}
return result;
}
visitFunctionCall(functionCall: FunctionCall): data.ExpressionData {
// Evaluate arguments
const args = functionCall.args.map(arg => this.eval(arg));
// Get function definitions
const functionName = functionCall.functionName.lexeme.toLowerCase();
const f = this.functions?.get(functionName) || wellKnownFunctions[functionName];
return f.call(...args);
}
}
function filteredArrayAccess(fa: FilteredArray, idx: idxHelper): data.ExpressionData {
const result = new FilteredArray();
for (const item of fa.values()) {
// Check the type of the nested item
switch (item.kind) {
case data.Kind.Dictionary: {
const ti = item as data.Dictionary;
if (idx.star) {
for (const v of ti.values()) {
result.add(v);
}
} else if (idx.str !== undefined) {
const v = ti.get(idx.str);
if (v !== undefined) {
result.add(v);
}
}
break;
}
case data.Kind.Array: {
const ti = item as data.Array;
if (idx.star) {
for (const v of ti.values()) {
result.add(v);
}
} else if (idx.int !== undefined && idx.int < ti.values().length) {
result.add(ti.get(idx.int));
}
break;
}
}
}
return result;
}
function arrayAccess(a: data.Array, idx: idxHelper): data.ExpressionData {
if (idx.star) {
const fa = new FilteredArray();
for (const item of a.values()) {
fa.add(item);
}
return fa;
}
if (idx.int !== undefined && idx.int < a.values().length) {
return a.get(idx.int);
}
return new data.Null();
}
function objectAccess(obj: data.Dictionary, idx: idxHelper): data.ExpressionData {
if (idx.star) {
const fa = new FilteredArray(...obj.values());
return fa;
}
if (idx.str !== undefined) {
const r = obj.get(idx.str);
if (r !== undefined) {
return r;
}
}
return new data.Null();
}
+3
View File
@@ -0,0 +1,3 @@
import * as data from "./data";
export class FilteredArray extends data.Array {}
+56
View File
@@ -0,0 +1,56 @@
import {ErrorType, ExpressionError} from "./errors";
import {contains} from "./funcs/contains";
import {endswith} from "./funcs/endswith";
import {format} from "./funcs/format";
import {fromjson} from "./funcs/fromjson";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {join} from "./funcs/join";
import {startswith} from "./funcs/startswith";
import {tojson} from "./funcs/tojson";
import {Token} from "./lexer";
export type ParseContext = {
allowUnknownKeywords: boolean;
extensionContexts: Map<string, boolean>;
extensionFunctions: Map<string, FunctionInfo>;
};
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
contains: contains,
endswith: endswith,
format: format,
fromjson: fromjson,
join: join,
startswith: startswith,
tojson: tojson
};
// validateFunction returns the function definition for the given function name.
// If the function does not exist or an incorrect number of arguments is provided,
// an error is returned.
export function validateFunction(context: ParseContext, identifier: Token, argCount: number) {
// Expression function names are case-insensitive.
const name = identifier.lexeme.toLowerCase();
let f: FunctionInfo | undefined;
f = wellKnownFunctions[name];
if (!f) {
f = context.extensionFunctions.get(name);
if (!f) {
if (!context.allowUnknownKeywords) {
throw new ExpressionError(ErrorType.ErrorUnrecognizedFunction, identifier);
}
// Skip argument validation for unknown functions
return;
}
}
if (argCount < f.minArgs) {
throw new ExpressionError(ErrorType.ErrorTooFewParameters, identifier);
}
if (argCount > f.maxArgs) {
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
}
}
+36
View File
@@ -0,0 +1,36 @@
import {Array, BooleanData, ExpressionData, Kind} from "../data";
import {equals} from "../result";
import {FunctionDefinition} from "./info";
export const contains: FunctionDefinition = {
name: "contains",
description:
"`contains( search, item )`\n\nReturns `true` if `search` contains `item`. If `search` is an array, this function returns `true` if the `item` is an element in the array. If `search` is a string, this function returns `true` if the `item` is a substring of `search`. This function is not case sensitive. Casts values to a string.",
minArgs: 2,
maxArgs: 2,
call: (...args: ExpressionData[]): ExpressionData => {
const left = args[0];
const right = args[1];
if (left.primitive) {
const ls = left.coerceString();
if (right.primitive) {
const rs = right.coerceString();
return new BooleanData(ls.toLowerCase().includes(rs.toLowerCase()));
}
} else if (left.kind === Kind.Array) {
const la = left as Array;
if (la.values().length === 0) {
return new BooleanData(false);
}
for (const v of la.values()) {
if (equals(right, v)) {
return new BooleanData(true);
}
}
}
return new BooleanData(false);
}
};
+27
View File
@@ -0,0 +1,27 @@
import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info";
export const endswith: FunctionDefinition = {
name: "endsWith",
description:
"`endsWith( searchString, searchValue )`\n\nReturns `true` if `searchString` ends with `searchValue`. This function is not case sensitive. Casts values to a string.",
minArgs: 2,
maxArgs: 2,
call: (...args: ExpressionData[]): ExpressionData => {
const left = args[0];
if (!left.primitive) {
return new BooleanData(false);
}
const right = args[1];
if (!right.primitive) {
return new BooleanData(false);
}
const ls = toUpperSpecial(left.coerceString());
const rs = toUpperSpecial(right.coerceString());
return new BooleanData(ls.endsWith(rs));
}
};
+11
View File
@@ -0,0 +1,11 @@
import {Null, NumberData, StringData} from "../data";
import {format} from "./format";
describe("format", () => {
it("null", () => {
expect(format.call(new StringData("{0}"), new Null())).toEqual(new StringData(""));
});
it("number", () => {
expect(format.call(new StringData("{0}"), new NumberData(42))).toEqual(new StringData("42"));
});
});
+120
View File
@@ -0,0 +1,120 @@
import {ExpressionData, StringData} from "../data";
import {FunctionDefinition} from "./info";
export const format: FunctionDefinition = {
name: "format",
description:
"`format( string, replaceValue0, replaceValue1, ..., replaceValueN)`\n\nReplaces values in the `string`, with the variable `replaceValueN`. Variables in the `string` are specified using the `{N}` syntax, where `N` is an integer. You must specify at least one `replaceValue` and `string`. There is no maximum for the number of variables (`replaceValueN`) you can use. Escape curly braces using double braces.",
minArgs: 1,
maxArgs: 255 /*MAX_ARGUMENTS*/,
call: (...args: ExpressionData[]): ExpressionData => {
const fs = args[0].coerceString();
const result: string[] = [];
let index = 0;
while (index < fs.length) {
const lbrace = fs.indexOf("{", index);
let rbrace = fs.indexOf("}", index);
// Left brace
if (lbrace >= 0 && (rbrace < 0 || rbrace > lbrace)) {
// Escaped left brace
if (safeCharAt(fs, lbrace + 1) === "{") {
result.push(fs.substr(index, lbrace - index + 1));
index = lbrace + 2;
continue;
}
// Left brace, number, optional format specifiers, right brace
if (rbrace > lbrace + 1) {
const argIndex = readArgIndex(fs, lbrace + 1);
if (argIndex.success) {
// Check parameter count
if (1 + argIndex.result > args.length - 1) {
throw new Error(`The following format string references more arguments than were supplied: ${fs}`);
}
// Append the portion before the left brace
if (lbrace > index) {
result.push(fs.substr(index, lbrace - index));
}
// Append the arg
result.push(`${args[1 + argIndex.result].coerceString()}`);
index = rbrace + 1;
continue;
}
}
throw new Error(`The following format string is invalid: ${fs}`);
}
// Right brace
else if (rbrace >= 0) {
// Escaped right brace
if (safeCharAt(fs, rbrace + 1) === "}") {
result.push(fs.substr(index, rbrace - index + 1));
index = rbrace + 2;
} else {
throw new Error(`The following format string is invalid: ${fs}`);
}
}
// Last segment
else {
result.push(fs.substr(index));
break;
}
}
return new StringData(result.join(""));
}
};
function safeCharAt(string: string, index: number): string {
if (string.length > index) {
return string[index];
}
return "\0";
}
function readArgIndex(string: string, startIndex: number): ArgIndex {
// Count the number of digits
let length = 0;
while (true) {
const nextChar = safeCharAt(string, startIndex + length);
if (nextChar >= "0" && nextChar <= "9") {
length++;
} else {
break;
}
}
// Validate at least one digit
if (length < 1) {
return <ArgIndex>{
success: false
};
}
// Parse the number
const endIndex = startIndex + length - 1;
const result = parseInt(string.substr(startIndex, length));
return <ArgIndex>{
success: !isNaN(result),
result: result,
endIndex: endIndex
};
}
interface ArgIndex {
success: boolean;
result: number;
endIndex: number;
}
interface FormatSpecifiers {
success: boolean;
result: string;
rbrace: number;
}
+26
View File
@@ -0,0 +1,26 @@
import {ExpressionData} from "../data";
import {reviver} from "../data/reviver";
import {ExpressionEvaluationError} from "../errors";
import {FunctionDefinition} from "./info";
export const fromjson: FunctionDefinition = {
name: "fromJson",
description:
"`fromJSON(value)`\n\nReturns a JSON object or JSON data type for `value`. You can use this function to provide a JSON object as an evaluated expression or to convert environment variables from a string.",
minArgs: 1,
maxArgs: 1,
call: (...args: ExpressionData[]): ExpressionData => {
const input = args[0];
const is = input.coerceString();
if (is.trim() === "") {
throw new Error("empty input");
}
try {
return JSON.parse(is, reviver);
} catch (e) {
throw new ExpressionEvaluationError("Error parsing JSON when evaluating fromJson", {cause: e});
}
}
};
+14
View File
@@ -0,0 +1,14 @@
import {ExpressionData} from "../data";
export interface FunctionInfo {
name: string;
description?: string;
minArgs: number;
maxArgs: number;
}
export interface FunctionDefinition extends FunctionInfo {
call: (...args: ExpressionData[]) => ExpressionData;
}
+35
View File
@@ -0,0 +1,35 @@
import {Array, ExpressionData, Kind, StringData} from "../data";
import {FunctionDefinition} from "./info";
export const join: FunctionDefinition = {
name: "join",
description:
"`join( array, optionalSeparator )`\n\nThe value for `array` can be an array or a string. All values in `array` are concatenated into a string. If you provide `optionalSeparator`, it is inserted between the concatenated values. Otherwise, the default separator `,` is used. Casts values to a string.",
minArgs: 1,
maxArgs: 2,
call: (...args: ExpressionData[]): ExpressionData => {
// Primitive
if (args[0].primitive) {
return new StringData(args[0].coerceString());
}
// Array
if (args[0].kind === Kind.Array) {
// Separator
let separator = ",";
if (args.length > 1 && args[1].primitive) {
separator = args[1].coerceString();
}
// Convert items to strings
return new StringData(
(args[0] as Array)
.values()
.map(item => item.coerceString())
.join(separator)
);
}
return new StringData("");
}
};
+27
View File
@@ -0,0 +1,27 @@
import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info";
export const startswith: FunctionDefinition = {
name: "startsWith",
description:
"`startsWith( searchString, searchValue )`\n\nReturns `true` when `searchString` starts with `searchValue`. This function is not case sensitive. Casts values to a string.",
minArgs: 2,
maxArgs: 2,
call: (...args: ExpressionData[]): ExpressionData => {
const left = args[0];
if (!left.primitive) {
return new BooleanData(false);
}
const right = args[1];
if (!right.primitive) {
return new BooleanData(false);
}
const ls = toUpperSpecial(left.coerceString());
const rs = toUpperSpecial(right.coerceString());
return new BooleanData(ls.startsWith(rs));
}
};
+14
View File
@@ -0,0 +1,14 @@
import {ExpressionData, StringData} from "../data";
import {replacer} from "../data/replacer";
import {FunctionDefinition} from "./info";
export const tojson: FunctionDefinition = {
name: "toJson",
description:
"`toJSON(value)`\n\nReturns a pretty-print JSON representation of `value`. You can use this function to debug the information provided in contexts.",
minArgs: 1,
maxArgs: 1,
call: (...args: ExpressionData[]): ExpressionData => {
return new StringData(JSON.stringify(args[0], replacer, " "));
}
};
+20
View File
@@ -0,0 +1,20 @@
import {ExpressionData} from "./data";
export class idxHelper {
public readonly str: string | undefined;
public readonly int: number | undefined;
constructor(public readonly star: boolean, idx: ExpressionData | undefined) {
if (!star) {
if (idx!.primitive) {
this.str = idx!.coerceString();
}
let f = idx!.number();
if (!isNaN(f) && isFinite(f) && f >= 0) {
f = Math.floor(f);
this.int = f;
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export {Expr} from "./ast";
export {complete, CompletionItem} from "./completion";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
export * as data from "./data";
export {ExpressionError, ExpressionEvaluationError} from "./errors";
export {Evaluator} from "./evaluator";
export {wellKnownFunctions} from "./funcs";
export {Lexer, Result} from "./lexer";
export {Parser} from "./parser";
+201
View File
@@ -0,0 +1,201 @@
import {Lexer, Token, TokenType} from "./lexer";
describe("lexer", () => {
const tests: {
input: string;
tokenTypes: TokenType[];
tokens?: Token[];
}[] = [
{input: "<", tokenTypes: [TokenType.LESS]},
{input: ">", tokenTypes: [TokenType.GREATER]},
{input: "!=", tokenTypes: [TokenType.BANG_EQUAL]},
{input: "==", tokenTypes: [TokenType.EQUAL_EQUAL]},
{input: "<=", tokenTypes: [TokenType.LESS_EQUAL]},
{input: ">=", tokenTypes: [TokenType.GREATER_EQUAL]},
{input: "&&", tokenTypes: [TokenType.AND]},
{input: "||", tokenTypes: [TokenType.OR]},
// Numbers
{input: "12", tokenTypes: [TokenType.NUMBER]},
{input: "12.0", tokenTypes: [TokenType.NUMBER]},
{input: "0", tokenTypes: [TokenType.NUMBER]},
{input: "-0", tokenTypes: [TokenType.NUMBER]},
{input: "-12.0", tokenTypes: [TokenType.NUMBER]},
// Strings
{input: "'It''s okay'", tokenTypes: [TokenType.STRING]},
{
input: "format('{0} == ''queued''', needs)",
tokenTypes: [
TokenType.IDENTIFIER,
TokenType.LEFT_PAREN,
TokenType.STRING,
TokenType.COMMA,
TokenType.IDENTIFIER,
TokenType.RIGHT_PAREN
]
},
// Arrays
{
input: "[1,2]",
tokenTypes: [TokenType.LEFT_BRACKET, TokenType.NUMBER, TokenType.COMMA, TokenType.NUMBER, TokenType.RIGHT_BRACKET]
},
// Simple expressions
{
input: "1 == 2",
tokenTypes: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER]
},
{
input: "1== 1",
tokenTypes: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER]
},
{
input: "1< 1",
tokenTypes: [TokenType.NUMBER, TokenType.LESS, TokenType.NUMBER]
},
// Identifiers
{
input: "github",
tokenTypes: [TokenType.IDENTIFIER],
tokens: [
{
type: TokenType.IDENTIFIER,
lexeme: "github",
range: {
start: {
line: 0,
column: 0
},
end: {
line: 0,
column: 6
}
}
}
]
},
// Keywords
{
input: "true",
tokenTypes: [TokenType.TRUE],
tokens: [
{
type: TokenType.TRUE,
lexeme: "true",
range: {
start: {
line: 0,
column: 0
},
end: {
line: 0,
column: 4
}
}
}
]
},
{
input: "false",
tokenTypes: [TokenType.FALSE],
tokens: [
{
type: TokenType.FALSE,
lexeme: "false",
range: {
start: {
line: 0,
column: 0
},
end: {
line: 0,
column: 5
}
}
}
]
},
{
input: "null",
tokenTypes: [TokenType.NULL],
tokens: [
{
type: TokenType.NULL,
lexeme: "null",
range: {
start: {
line: 0,
column: 0
},
end: {
line: 0,
column: 4
}
}
}
]
},
{
input: "github\n ==",
tokenTypes: [TokenType.IDENTIFIER, TokenType.EQUAL_EQUAL],
tokens: [
{
type: TokenType.IDENTIFIER,
lexeme: "github",
range: {
start: {
line: 0,
column: 0
},
end: {
line: 0,
column: 6
}
}
},
{
type: TokenType.EQUAL_EQUAL,
lexeme: "==",
range: {
start: {
line: 1,
column: 1
},
end: {
line: 1,
column: 3
}
}
}
]
}
];
test.each(tests)(
"$input",
({input, tokenTypes, tokens}: {input: string; tokenTypes: TokenType[]; tokens?: Token[]}) => {
const l = new Lexer(input);
const r = l.lex();
const got = r.tokens.map(t => t.type);
tokenTypes.push(TokenType.EOF);
expect(got).toEqual(tokenTypes);
if (tokens) {
// Ignore the last EOF token
expect(r.tokens.slice(0, r.tokens.length - 1)).toEqual(tokens);
}
}
);
});
+417
View File
@@ -0,0 +1,417 @@
import {StringData} from "./data";
import {MAX_EXPRESSION_LENGTH} from "./errors";
export enum TokenType {
UNKNOWN,
LEFT_PAREN,
RIGHT_PAREN,
LEFT_BRACKET,
RIGHT_BRACKET,
COMMA,
DOT,
BANG,
BANG_EQUAL,
EQUAL_EQUAL,
GREATER,
GREATER_EQUAL,
LESS,
LESS_EQUAL,
AND,
OR,
STAR,
NUMBER,
STRING,
IDENTIFIER,
TRUE,
FALSE,
NULL,
EOF
}
export type Pos = {
line: number;
column: number;
};
export type Range = {
start: Pos;
end: Pos;
};
export type Token = {
type: TokenType;
lexeme: string;
value?: string | number | boolean;
range: Range;
};
export function tokenString(tok: Token): string {
switch (tok.type) {
case TokenType.EOF:
return "EOF";
case TokenType.NUMBER:
return tok.lexeme;
case TokenType.STRING:
return tok.value!.toString();
default:
return tok.lexeme;
}
}
export type Result = {
tokens: Token[];
};
export class Lexer {
private start = 0;
private offset = 0;
private line = 0;
private lastLineOffset = 0;
private tokens: Token[] = [];
constructor(private input: string) {}
lex(): Result {
if (this.input.length > MAX_EXPRESSION_LENGTH) {
throw new Error("ErrorExceededMaxLength");
}
while (!this.atEnd()) {
this.start = this.offset;
const c = this.next();
switch (c) {
case "(":
this.addToken(TokenType.LEFT_PAREN);
break;
case ")":
this.addToken(TokenType.RIGHT_PAREN);
break;
case "[":
this.addToken(TokenType.LEFT_BRACKET);
break;
case "]":
this.addToken(TokenType.RIGHT_BRACKET);
break;
case ",":
this.addToken(TokenType.COMMA);
break;
case ".":
if (
this.previous() != TokenType.IDENTIFIER &&
this.previous() != TokenType.RIGHT_BRACKET &&
this.previous() != TokenType.RIGHT_PAREN &&
this.previous() != TokenType.STAR
) {
this.consumeNumber();
} else {
this.addToken(TokenType.DOT);
}
break;
case "-":
case "+":
this.consumeNumber();
break;
case "!":
this.addToken(this.match("=") ? TokenType.BANG_EQUAL : TokenType.BANG);
break;
case "=":
if (!this.match("=")) {
// Illegal; continue reading until we hit a boundary character and return an error
this.consumeIdentifier();
break;
}
this.addToken(TokenType.EQUAL_EQUAL);
break;
case "<":
this.addToken(this.match("=") ? TokenType.LESS_EQUAL : TokenType.LESS);
break;
case ">":
this.addToken(this.match("=") ? TokenType.GREATER_EQUAL : TokenType.GREATER);
break;
case "&":
if (!this.match("&")) {
// Illegal; continue reading until we hit a boundary character and return an error
this.consumeIdentifier();
break;
}
this.addToken(TokenType.AND);
break;
case "|":
if (!this.match("|")) {
// Illegal; continue reading until we hit a boundary character and return an error
this.consumeIdentifier();
break;
}
this.addToken(TokenType.OR);
break;
case "*":
this.addToken(TokenType.STAR);
break;
// Ignore whitespace.
case " ":
case "\r":
case "\t":
break;
case "\n":
++this.line;
this.lastLineOffset = this.offset;
break;
case "'":
this.consumeString();
break;
default:
switch (true) {
case isDigit(c):
this.consumeNumber();
break;
default:
this.consumeIdentifier();
break;
}
}
}
this.tokens.push({
type: TokenType.EOF,
lexeme: "",
range: this.range()
});
return {
tokens: this.tokens
};
}
private pos(): Pos {
return {
line: this.line,
column: this.start - this.lastLineOffset
};
}
private endPos(): Pos {
return {
line: this.line,
column: this.offset - this.lastLineOffset
};
}
private range(): Range {
return {
start: this.pos(),
end: this.endPos()
};
}
private atEnd(): boolean {
return this.offset >= this.input.length;
}
private peek(): string {
if (this.atEnd()) {
return "\0";
}
return this.input[this.offset];
}
private peekNext(): string {
if (this.offset + 1 >= this.input.length) {
return "\0";
}
return this.input[this.offset + 1];
}
private previous(): TokenType {
const l = this.tokens.length;
if (l == 0) {
return TokenType.EOF;
}
return this.tokens[l - 1].type;
}
private next(): string {
return this.input[this.offset++];
}
private match(expected: string): boolean {
if (this.atEnd()) {
return false;
}
if (this.input[this.offset] !== expected) {
return false;
}
this.offset++;
return true;
}
private addToken(type: TokenType, value?: string | number | boolean) {
this.tokens.push({
type,
lexeme: this.input.substring(this.start, this.offset),
range: this.range(),
value
});
}
private consumeNumber() {
while (!this.atEnd() && (!isBoundary(this.peek()) || this.peek() == ".")) {
this.next();
}
const lexeme = this.input.substring(this.start, this.offset);
const value = new StringData(lexeme).number();
if (isNaN(value)) {
throw new Error(
`Unexpected symbol: '${lexeme}'. Located at position ${this.start + 1} within expression: ${this.input}`
);
}
this.addToken(TokenType.NUMBER, value);
}
private consumeString() {
while ((this.peek() !== "'" || this.peekNext() === "'") && !this.atEnd()) {
if (this.peek() === "\n") this.line++;
if (this.peek() === "'" && this.peekNext() === "'") {
// Escaped "'", consume
this.next();
}
this.next();
}
if (this.atEnd()) {
// Unterminated string
throw new Error(
`Unexpected symbol: '${this.input.substring(this.start)}'. Located at position ${
this.start + 1
} within expression: ${this.input}`
);
}
// Closing '
this.next();
// Trim the surrounding quotes.
let value = this.input.substring(this.start + 1, this.offset - 1);
value = value.replace("''", "'");
this.addToken(TokenType.STRING, value);
}
private consumeIdentifier() {
while (!this.atEnd() && !isBoundary(this.peek())) {
this.next();
}
let tokenType = TokenType.IDENTIFIER;
let tokenValue = undefined;
const lexeme = this.input.substring(this.start, this.offset);
if (this.previous() != TokenType.DOT) {
switch (lexeme) {
case "true":
tokenType = TokenType.TRUE;
break;
case "false":
tokenType = TokenType.FALSE;
break;
case "null":
tokenType = TokenType.NULL;
break;
case "NaN":
tokenType = TokenType.NUMBER;
tokenValue = NaN;
break;
case "Infinity":
tokenType = TokenType.NUMBER;
tokenValue = Infinity;
break;
}
}
if (!isLegalIdentifier(lexeme)) {
throw new Error(
`Unexpected symbol: '${lexeme}'. Located at position ${this.start + 1} within expression: ${this.input}`
);
}
this.addToken(tokenType, tokenValue);
}
}
function isDigit(c: string) {
return c >= "0" && c <= "9";
}
function isBoundary(c: string): boolean {
switch (c) {
case "(":
case "[":
case ")":
case "]":
case ",":
case ".":
case "!":
case ">":
case "<":
case "=":
case "&":
case "|":
return true;
}
return /\s/.test(c);
}
function isLegalIdentifier(str: string): boolean {
if (str == "") {
return false;
}
const first = str[0];
if ((first >= "a" && first <= "z") || (first >= "A" && first <= "Z") || first == "_") {
for (const c of str.substring(1).split("")) {
if ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" || c == "-") {
// OK
} else {
return false;
}
}
return true;
}
return false;
}
+356
View File
@@ -0,0 +1,356 @@
import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
import * as data from "./data";
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
import {ParseContext, validateFunction} from "./funcs";
import {FunctionInfo} from "./funcs/info";
import {Token, TokenType} from "./lexer";
export class Parser {
private extContexts: Map<string, boolean>;
private extFuncs: Map<string, FunctionInfo>;
private offset: number = 0;
private depth: number = 0;
private context: ParseContext;
/**
* Constructs a new parser for the given tokens
*
* @param tokens Tokens to build a parse tree from
* @param extensionContexts Available context names
* @param extensionFunctions Available functions (beyond the built-in ones)
*/
constructor(private tokens: Token[], extensionContexts: string[], extensionFunctions: FunctionInfo[]) {
this.extContexts = new Map<string, boolean>();
this.extFuncs = new Map();
for (const contextName of extensionContexts) {
this.extContexts.set(contextName.toLowerCase(), true);
}
for (const {name, func} of extensionFunctions.map(x => ({
name: x.name,
func: x
}))) {
this.extFuncs.set(name.toLowerCase(), func);
}
this.context = {
allowUnknownKeywords: false,
extensionContexts: this.extContexts,
extensionFunctions: this.extFuncs
};
}
public parse(): Expr {
let result!: Expr;
// No tokens
if (this.atEnd()) {
return result;
}
result = this.expression();
if (!this.atEnd()) {
throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek());
}
return result;
}
private expression(): Expr {
this.incrDepth();
try {
return this.logicalOr();
} finally {
this.decrDepth();
}
}
private logicalOr(): Expr {
// && is higher precedence than ||
let expr = this.logicalAnd();
if (this.check(TokenType.OR)) {
// Track depth
this.incrDepth();
try {
const logical = new Logical(this.peek(), [expr]);
expr = logical;
while (this.match(TokenType.OR)) {
const right = this.logicalAnd();
logical.args.push(right);
}
} finally {
this.decrDepth();
}
}
return expr;
}
private logicalAnd(): Expr {
// == and != are higher precedence than &&
let expr = this.equality();
if (this.check(TokenType.AND)) {
// Track depth
this.incrDepth();
try {
const logical = new Logical(this.peek(), [expr]);
expr = logical;
while (this.match(TokenType.AND)) {
const right = this.equality();
logical.args.push(right);
}
} finally {
this.decrDepth();
}
}
return expr;
}
private equality(): Expr {
// >, >=, <, <= are higher precedence than == and !=
let expr = this.comparison();
while (this.match(TokenType.BANG_EQUAL, TokenType.EQUAL_EQUAL)) {
const operator = this.previous();
const right = this.comparison();
expr = new Binary(expr, operator, right);
}
return expr;
}
private comparison(): Expr {
// ! is higher precedence than >, >=, <, <=
let expr = this.unary();
while (this.match(TokenType.GREATER, TokenType.GREATER_EQUAL, TokenType.LESS, TokenType.LESS_EQUAL)) {
const operator = this.previous();
const right = this.unary();
expr = new Binary(expr, operator, right);
}
return expr;
}
private unary(): Expr {
if (this.match(TokenType.BANG)) {
// Track depth
this.incrDepth();
const operator = this.previous();
const unary = this.unary();
try {
return new Unary(operator, unary);
} finally {
this.decrDepth();
}
}
return this.index();
}
private index(): Expr {
let expr = this.call();
let depthIncreased = 0;
if (expr instanceof Grouping || expr instanceof FunctionCall || expr instanceof ContextAccess) {
let cont = true;
while (cont) {
switch (true) {
case this.match(TokenType.LEFT_BRACKET):
let indexExpr: Expr;
if (this.match(TokenType.STAR)) {
indexExpr = new Star();
} else {
indexExpr = this.expression();
}
this.consume(TokenType.RIGHT_BRACKET, ErrorType.ErrorUnexpectedSymbol);
// Track depth
this.incrDepth();
depthIncreased++;
expr = new IndexAccess(expr, indexExpr);
break;
case this.match(TokenType.DOT):
// Track depth
this.incrDepth();
depthIncreased++;
if (this.match(TokenType.IDENTIFIER)) {
let property = this.previous();
expr = new IndexAccess(expr, new Literal(new data.StringData(property.lexeme), property));
} else if (this.match(TokenType.STAR)) {
expr = new IndexAccess(expr, new Star());
} else {
throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek());
}
break;
default:
cont = false;
}
}
}
for (let i = 0; i < depthIncreased; i++) {
this.decrDepth();
}
return expr;
}
private call(): Expr {
if (!this.check(TokenType.IDENTIFIER)) {
return this.primary();
}
const identifier = this.next();
if (!this.match(TokenType.LEFT_PAREN)) {
if (!this.extContexts.has(identifier.lexeme.toLowerCase())) {
throw this.buildError(ErrorType.ErrorUnrecognizedContext, identifier);
}
return new ContextAccess(identifier);
}
// Function call
const args: Expr[] = [];
// Arguments
while (!this.match(TokenType.RIGHT_PAREN)) {
const aexp = this.expression();
args.push(aexp);
if (!this.check(TokenType.RIGHT_PAREN)) {
this.consume(TokenType.COMMA, ErrorType.ErrorUnexpectedSymbol);
}
}
validateFunction(this.context, identifier, args.length);
return new FunctionCall(identifier, args);
}
private primary(): Expr {
switch (true) {
case this.match(TokenType.FALSE):
return new Literal(new data.BooleanData(false), this.previous());
case this.match(TokenType.TRUE):
return new Literal(new data.BooleanData(true), this.previous());
case this.match(TokenType.NULL):
return new Literal(new data.Null(), this.previous());
case this.match(TokenType.NUMBER):
return new Literal(new data.NumberData(this.previous().value as number), this.previous());
case this.match(TokenType.STRING):
return new Literal(new data.StringData(this.previous().value as string), this.previous());
case this.match(TokenType.LEFT_PAREN):
const expr = this.expression();
if (this.atEnd()) {
throw this.buildError(ErrorType.ErrorUnexpectedEndOfExpression, this.previous()); // Back up to get the last token before the EOF
}
this.consume(TokenType.RIGHT_PAREN, ErrorType.ErrorUnexpectedSymbol);
return new Grouping(expr);
case this.atEnd():
throw this.buildError(ErrorType.ErrorUnexpectedEndOfExpression, this.previous()); // Back up to get the last token before the EOF
}
throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek());
}
// match consumes the next token if it matches any of the given types
private match(...tokenTypes: TokenType[]): boolean {
for (const tokenType of tokenTypes) {
if (this.check(tokenType)) {
this.next();
return true;
}
}
return false;
}
// check peeks whether the next token is of the given type
private check(tokenType: TokenType): boolean {
if (this.atEnd()) {
return false;
}
return this.peek().type == tokenType;
}
// atEnd peeks whether the next token is EOF
private atEnd(): boolean {
return this.peek().type == TokenType.EOF;
}
private next(): Token {
if (!this.atEnd()) {
this.offset++;
}
return this.previous();
}
private peek(): Token {
return this.tokens[this.offset];
}
// previous returns the previous token
private previous(): Token {
return this.tokens[this.offset - 1];
}
// consume attempts to consume the next token if it matches the given type. It returns an error of
// the given ParseErrorKind otherwise.
private consume(tokenType: TokenType, errorType: ErrorType) {
if (this.check(tokenType)) {
this.next();
return;
}
throw this.buildError(errorType, this.peek());
}
private incrDepth() {
this.depth++;
if (this.depth > MAX_PARSER_DEPTH) {
throw this.buildError(ErrorType.ErrorExceededMaxDepth, this.peek());
}
}
private decrDepth() {
this.depth--;
}
private buildError(errType: ErrorType, token: Token): Error {
return new ExpressionError(errType, token);
}
}
+98
View File
@@ -0,0 +1,98 @@
import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
import {coerceTypes, toUpperSpecial} from "./result";
describe("coerceTypes", () => {
const tests: {
name: string;
data: {
l: ExpressionData;
r: ExpressionData;
};
wantL: ExpressionData;
wantR: ExpressionData;
}[] = [
{
name: "number-bool",
data: {l: new NumberData(1), r: new BooleanData(true)},
wantL: new NumberData(1),
wantR: new NumberData(1)
},
{
name: "number-bool-false",
data: {l: new NumberData(1), r: new BooleanData(false)},
wantL: new NumberData(1),
wantR: new NumberData(0)
},
{
name: "bool-number-false",
data: {l: new BooleanData(false), r: new NumberData(1)},
wantL: new NumberData(0),
wantR: new NumberData(1)
},
{
name: "number-number",
data: {l: new NumberData(1), r: new NumberData(2)},
wantL: new NumberData(1),
wantR: new NumberData(2)
},
{
name: "string-string",
data: {l: new StringData("a"), r: new StringData("b")},
wantL: new StringData("a"),
wantR: new StringData("b")
},
{
name: "string-number",
data: {l: new StringData("a"), r: new NumberData(1)},
wantL: new NumberData(NaN),
wantR: new NumberData(1)
},
{
name: "number-string",
data: {l: new NumberData(1), r: new StringData("a")},
wantL: new NumberData(1),
wantR: new NumberData(NaN)
},
{
name: "bool-bool",
data: {l: new BooleanData(false), r: new BooleanData(true)},
wantL: new BooleanData(false),
wantR: new BooleanData(true)
}
];
test.each(tests)(
"$name",
({
data,
wantL,
wantR
}: {
data: {l: ExpressionData; r: ExpressionData};
wantL: ExpressionData;
wantR: ExpressionData;
}) => {
const [gotL, gotR] = coerceTypes(data.l, data.r);
expect(gotL).toEqual(wantL);
expect(gotR).toEqual(wantR);
}
);
});
describe("toUpperSpecial", () => {
const tests: {input: string; want: string}[] = [
{input: "", want: ""},
{input: "abc", want: "ABC"},
{input: "ıabc", want: "ıABC"},
{input: "ııabc", want: "ııABC"},
{input: "abcı", want: "ABCı"},
{input: "abcıı", want: "ABCıı"},
{input: "abcıdef", want: "ABCıDEF"},
{input: "abcııdef", want: "ABCııDEF"},
{input: "abcıdefıghi", want: "ABCıDEFıGHI"}
];
test.each(tests)("$input", ({input, want}: {input: string; want: string}) => {
expect(toUpperSpecial(input)).toEqual(want);
});
});
+220
View File
@@ -0,0 +1,220 @@
import * as data from "./data";
export function falsy(d: data.ExpressionData): boolean {
switch (d.kind) {
case data.Kind.Null:
return true;
case data.Kind.Boolean:
return !d.value;
case data.Kind.Number:
const dv = d.value;
return dv === 0 || isNaN(dv);
case data.Kind.String:
return d.value.length === 0;
}
return false;
}
export function truthy(d: data.ExpressionData): boolean {
return !falsy(d);
}
// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
// Except objects are not coerced to primitives.
export function coerceTypes(
li: data.ExpressionData,
ri: data.ExpressionData
): [data.ExpressionData, data.ExpressionData] {
let lv = li;
let rv = ri;
// Do nothing, same kind
if (li.kind === ri.kind) {
return [lv, rv];
}
switch (li.kind) {
// Number, String
case data.Kind.Number:
if (ri.kind === data.Kind.String) {
rv = new data.NumberData(ri.number());
return [lv, rv];
}
break;
// String, Number
case data.Kind.String:
if (ri.kind === data.Kind.Number) {
lv = new data.NumberData(li.number());
return [lv, rv];
}
break;
// Boolean|Null, Any
case data.Kind.Null:
case data.Kind.Boolean:
lv = new data.NumberData(li.number());
return coerceTypes(lv, rv);
}
// Any, Boolean|Null
switch (ri.kind) {
case data.Kind.Null:
case data.Kind.Boolean:
rv = new data.NumberData(ri.number());
return coerceTypes(lv, rv);
}
return [lv, rv];
}
// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
export function equals(lhs: data.ExpressionData, rhs: data.ExpressionData): boolean {
let [lv, rv] = coerceTypes(lhs, rhs);
if (lv.kind != rv.kind) {
return false;
}
switch (lv.kind) {
// Null, Null
case data.Kind.Null:
return true;
// Number, Number
case data.Kind.Number:
const ld = lv.value;
const rd = (rv as data.NumberData).value;
if (isNaN(ld) || isNaN(rd)) {
return false;
}
return ld == rd;
// String, String
case data.Kind.String:
const ls = lv.value;
const rs = (rv as data.StringData).value;
return toUpperSpecial(ls) === toUpperSpecial(rs);
// Boolean, Boolean
case data.Kind.Boolean:
const lb = lv.value;
const rb = (rv as data.BooleanData).value;
return lb == rb;
// Object, Object
case data.Kind.Dictionary:
case data.Kind.Array:
// Check reference equality
return lv === rv;
}
return false;
}
// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
export function greaterThan(lhs: data.ExpressionData, rhs: data.ExpressionData): boolean {
const [lv, rv] = coerceTypes(lhs, rhs);
if (lv.kind != rv.kind) {
return false;
}
switch (lv.kind) {
// Number, Number
case data.Kind.Number:
const lf = lv.value;
const rf = (rv as data.NumberData).value;
if (isNaN(lf) || isNaN(rf)) {
return false;
}
return lf > rf;
// String, String
case data.Kind.String:
let ls = lv.value;
let rs = (rv as data.StringData).value;
ls = toUpperSpecial(ls);
rs = toUpperSpecial(rs);
return ls > rs;
// Boolean, Boolean
case data.Kind.Boolean:
const lb = (lv as data.BooleanData).value;
const rb = (rv as data.BooleanData).value;
return lb && !rb;
}
return false;
}
// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3.
// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives.
export function lessThan(lhs: data.ExpressionData, rhs: data.ExpressionData): boolean {
const [lv, rv] = coerceTypes(lhs, rhs);
if (lv.kind != rv.kind) {
return false;
}
switch (lv.kind) {
// Number, Number
case data.Kind.Number:
const lf = lv.value;
const rf = (rv as data.NumberData).value;
if (isNaN(lf) || isNaN(rf)) {
return false;
}
return lf < rf;
// String, String
case data.Kind.String:
let ls = lv.value;
let rs = (rv as data.StringData).value;
ls = toUpperSpecial(ls);
rs = toUpperSpecial(rs);
return ls < rs;
// Boolean, Boolean
case data.Kind.Boolean:
const lb = lv.value;
const rb = (rv as data.BooleanData).value;
return !lb && rb;
}
return false;
}
// Do not toUpper the small-dotless-ı
export function toUpperSpecial(s: string): string {
const sb: string[] = [];
let i = 0;
let len = s.length;
let found = s.indexOf("ı");
while (i < len) {
if (i < found) {
sb.push(s.substring(i, found).toUpperCase()); // Append upper segment
i = found;
} else if (i == found) {
sb.push(s.substring(i, i + 1));
i += 1;
found = s.indexOf("ı", i);
} else {
sb.push(s.substring(i).toUpperCase()); // Append upper remaining
break;
}
}
return sb.join("");
}
+178
View File
@@ -0,0 +1,178 @@
import * as fs from "fs";
import * as path from "path";
import {Expr} from "./ast";
import * as data from "./data";
import {kindStr} from "./data/expressiondata";
import {replacer} from "./data/replacer";
import {reviver} from "./data/reviver";
import {ExpressionError} from "./errors";
import {Evaluator} from "./evaluator";
import {Lexer, Result} from "./lexer";
import {Parser} from "./parser";
interface TestResult {
value: data.ExpressionData;
kind: string;
}
enum TestErrorKind {
Lexing = "lexing",
Parsing = "parsing",
Evaluation = "evaluation"
}
enum SkipOptionKind {
Typescript = "typescript"
}
interface TestOptions {
skip: SkipOptionKind[];
}
interface TestError {
value: string;
kind: TestErrorKind;
}
interface TestCase {
expr: string;
result: TestResult;
err: TestError | undefined;
contexts: data.Dictionary;
options: TestOptions;
}
function testCaseReviver(key: string, val: any): any {
if (key === "contexts") {
const tmp = JSON.stringify(val);
return JSON.parse(tmp, reviver);
}
if (key === "result") {
const t = val as TestResult;
t.value = reviver("value", t.value);
}
return val;
}
const testFiles = "./testdata";
describe("x-lang tests", () => {
const files = fs.readdirSync(testFiles);
for (const file of files) {
const fileName = path.join(testFiles, file);
const fileStat = fs.statSync(fileName);
if (fileStat.isDirectory()) {
throw new Error("sub-directories are not supported");
}
if (path.extname(fileName) !== ".json") {
throw new Error("only json files are supported " + file);
}
const testFile = fs.readFileSync(fileName, "utf8");
const testCases = JSON.parse(testFile, testCaseReviver) as {
[name: string]: TestCase[];
};
for (const testCaseName of Object.keys(testCases)) {
let tests = testCases[testCaseName];
// Filter out tests that are not supported by typescript
tests = tests.filter(t => !t.options?.skip?.includes(SkipOptionKind.Typescript));
const testName = path.basename(file, ".json");
if (tests.length === 0) {
continue;
}
describe(testName, () => {
test.each(tests)(`${testName}: ${testCaseName} ($expr)`, testCase => {
if (!testCase.contexts) {
testCase.contexts = new data.Dictionary();
}
const lexer = new Lexer(testCase.expr);
let result: Result;
try {
result = lexer.lex();
if (testCase.err && testCase.err.kind === TestErrorKind.Lexing) {
throw new Error("expected error lexing expression, but got none");
}
} catch (e: any) {
// Did test expect lexing error? If so, compare error message.
if (testCase.err && testCase.err.kind === TestErrorKind.Lexing) {
expect(e.message).toContain(testCase.err.value);
return;
}
throw new Error(`unexpected error lexing expression: ${e.message} ${e.stack}`);
}
// Parse
const contextNames = testCase.contexts.pairs().map(x => x.key);
const parser = new Parser(result.tokens, contextNames, []);
let expr: Expr;
try {
expr = parser.parse();
if (testCase.err?.kind === TestErrorKind.Parsing) {
throw new Error("expected error parsing expression, but got none");
}
} catch (e: any) {
// Did test expect parsing error?
if (testCase.err?.kind === TestErrorKind.Parsing) {
// Test expects parsing error
const pe = e as ExpressionError;
expect(errorWithExpression(pe, testCase.expr)).toContain(testCase.err.value);
return;
}
throw new Error(`unexpected error parsing expression: ${e.message} ${e.stack}`);
}
// Evaluate expression
try {
let result: data.ExpressionData;
if (expr !== undefined) {
const evaluator = new Evaluator(expr, testCase.contexts);
result = evaluator.evaluate();
} else {
result = new data.Null();
}
if (testCase.err?.kind === TestErrorKind.Evaluation) {
throw new Error("expected error evaluating expression, but got none");
}
expect(kindStr(result.kind)).toBe(testCase.result.kind);
expect(JSON.stringify(result, replacer)).toEqual(JSON.stringify(testCase.result.value, replacer));
} catch (e: any) {
if (testCase.err?.kind === TestErrorKind.Evaluation) {
const pe = e as ExpressionError;
expect(errorWithExpression(pe, testCase.expr)).toContain(testCase.err.value);
return;
}
throw new Error(`unexpected error evaluating expression: ${e.message} ${e.stack}`);
}
});
});
}
}
});
function errorWithExpression(e: ExpressionError, expr: string): string {
if (e.pos !== undefined) {
return `${e.message}. Located at position ${e.pos.column + 1} within expression: ${expr}`;
}
return `${e.message}. Located within expression: ${expr}`;
}