Merge parser and expressions into repository
This commit is contained in:
@@ -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) {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { complete, CompletionItem, trimTokenVector } from "./completion";
|
||||
import { BooleanData } from "./data/boolean";
|
||||
import { Dictionary } from "./data/dictionary";
|
||||
import { StringData } from "./data/string";
|
||||
import { 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: "secrets",
|
||||
value: new Dictionary({
|
||||
key: "AWS_TOKEN",
|
||||
value: new BooleanData(true),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "hashFiles(1,255)",
|
||||
value: new Dictionary(),
|
||||
}
|
||||
);
|
||||
|
||||
const testFunctions: FunctionInfo[] = [];
|
||||
|
||||
const testComplete = (input: string): CompletionItem[] => {
|
||||
const pos = input.indexOf("|");
|
||||
input = input.replace("|", "");
|
||||
|
||||
const results = complete(
|
||||
input.slice(0, pos >= 0 ? pos : input.length),
|
||||
testContext,
|
||||
testFunctions
|
||||
);
|
||||
|
||||
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("toJS| == 1")).toContainEqual(expected);
|
||||
});
|
||||
|
||||
it("removes parentheses from passed in function context", () => {
|
||||
expect(testComplete("|")).toContainEqual({
|
||||
label: "hashFiles",
|
||||
function: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("for contexts", () => {
|
||||
it("provides suggestions for env", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 contexts in function call", () => {
|
||||
expect(testComplete("toJSON(env.|)")).toEqual(
|
||||
completionItems("BAR_TEST", "FOO")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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['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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Dictionary, isDictionary } from "./data/dictionary";
|
||||
import { ExpressionData } from "./data/expressiondata";
|
||||
import { Evaluator } from "./evaluator";
|
||||
import { wellKnownFunctions } from "./funcs";
|
||||
import { 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
|
||||
export function complete(
|
||||
input: string,
|
||||
context: Dictionary,
|
||||
extensionFunctions: FunctionInfo[]
|
||||
): 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);
|
||||
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.key))
|
||||
// Sort contexts
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function completionItemFromContext(context: string): CompletionItem {
|
||||
const parenIndex = context.indexOf("(");
|
||||
const isFunc = parenIndex >= 0 && context.indexOf(")") >= 0;
|
||||
|
||||
return {
|
||||
label: isFunc ? context.substring(0, parenIndex) : context,
|
||||
function: isFunc,
|
||||
};
|
||||
}
|
||||
|
||||
export function trimTokenVector(tokenVector: Token[]): Token[] {
|
||||
let tokenIdx = tokenVector.length;
|
||||
|
||||
while (tokenIdx > 0) {
|
||||
const token = tokenVector[tokenIdx - 1];
|
||||
switch (token.type) {
|
||||
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,40 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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") }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
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 (this.indexMap[key.toLowerCase()]) {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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}');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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.pos;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as data from "./data";
|
||||
import { Evaluator } from "./evaluator";
|
||||
import { Lexer } from "./lexer";
|
||||
import { Parser } from "./parser";
|
||||
|
||||
test("evaluator", () => {
|
||||
const input = "foo['']";
|
||||
|
||||
const lexer = new Lexer(input);
|
||||
const result = lexer.lex();
|
||||
|
||||
// Parse
|
||||
const parser = new Parser(result.tokens, ["foo"], []);
|
||||
const expr = parser.parse();
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
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 { idxHelper } from "./idxHelper";
|
||||
import { TokenType } from "./lexer";
|
||||
import { equals, falsy, greaterThan, lessThan, truthy } from "./result";
|
||||
|
||||
export class EvaluationError extends Error {}
|
||||
|
||||
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
||||
constructor(private n: Expr, private context: data.Dictionary) {}
|
||||
|
||||
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}`);
|
||||
}
|
||||
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));
|
||||
|
||||
return fcall(functionCall, args);
|
||||
}
|
||||
}
|
||||
|
||||
function fcall(
|
||||
fc: FunctionCall,
|
||||
args: data.ExpressionData[]
|
||||
): data.ExpressionData {
|
||||
const f = wellKnownFunctions[fc.functionName.lexeme.toLowerCase()];
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as data from "./data";
|
||||
|
||||
export class FilteredArray extends data.Array {}
|
||||
@@ -0,0 +1,63 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
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")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ExpressionData } from "../data";
|
||||
import { reviver } from "../data/reviver";
|
||||
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");
|
||||
}
|
||||
|
||||
const d = JSON.parse(is, reviver);
|
||||
return d;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("");
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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, " "));
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { complete } from "./completion";
|
||||
export * as data from "./data";
|
||||
export { ExpressionError } from "./errors";
|
||||
export { Evaluator } from "./evaluator";
|
||||
export { Lexer } from "./lexer";
|
||||
export { Parser } from "./parser";
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Lexer, Token, TokenType } from "./lexer";
|
||||
|
||||
describe("lexer", () => {
|
||||
const tests: {
|
||||
input: string;
|
||||
tokenType: TokenType[];
|
||||
token?: Token;
|
||||
}[] = [
|
||||
{ input: "<", tokenType: [TokenType.LESS] },
|
||||
{ input: ">", tokenType: [TokenType.GREATER] },
|
||||
|
||||
{ input: "!=", tokenType: [TokenType.BANG_EQUAL] },
|
||||
{ input: "==", tokenType: [TokenType.EQUAL_EQUAL] },
|
||||
{ input: "<=", tokenType: [TokenType.LESS_EQUAL] },
|
||||
{ input: ">=", tokenType: [TokenType.GREATER_EQUAL] },
|
||||
|
||||
{ input: "&&", tokenType: [TokenType.AND] },
|
||||
{ input: "||", tokenType: [TokenType.OR] },
|
||||
|
||||
// Numbers
|
||||
{ input: "12", tokenType: [TokenType.NUMBER] },
|
||||
{ input: "12.0", tokenType: [TokenType.NUMBER] },
|
||||
{ input: "0", tokenType: [TokenType.NUMBER] },
|
||||
{ input: "-0", tokenType: [TokenType.NUMBER] },
|
||||
{ input: "-12.0", tokenType: [TokenType.NUMBER] },
|
||||
|
||||
// Strings
|
||||
{ input: "'It''s okay'", tokenType: [TokenType.STRING] },
|
||||
{
|
||||
input: "format('{0} == ''queued''', needs)",
|
||||
tokenType: [
|
||||
TokenType.IDENTIFIER,
|
||||
TokenType.LEFT_PAREN,
|
||||
TokenType.STRING,
|
||||
TokenType.COMMA,
|
||||
TokenType.IDENTIFIER,
|
||||
TokenType.RIGHT_PAREN,
|
||||
],
|
||||
},
|
||||
|
||||
// Arrays
|
||||
{
|
||||
input: "[1,2]",
|
||||
tokenType: [
|
||||
TokenType.LEFT_BRACKET,
|
||||
TokenType.NUMBER,
|
||||
TokenType.COMMA,
|
||||
TokenType.NUMBER,
|
||||
TokenType.RIGHT_BRACKET,
|
||||
],
|
||||
},
|
||||
|
||||
// Simple expressions
|
||||
{
|
||||
input: "1 == 2",
|
||||
tokenType: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER],
|
||||
},
|
||||
{
|
||||
input: "1== 1",
|
||||
tokenType: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER],
|
||||
},
|
||||
{
|
||||
input: "1< 1",
|
||||
tokenType: [TokenType.NUMBER, TokenType.LESS, TokenType.NUMBER],
|
||||
},
|
||||
|
||||
// Identifiers
|
||||
{
|
||||
input: "github",
|
||||
tokenType: [TokenType.IDENTIFIER],
|
||||
token: {
|
||||
type: TokenType.IDENTIFIER,
|
||||
lexeme: "github",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Keywords
|
||||
{
|
||||
input: "true",
|
||||
tokenType: [TokenType.TRUE],
|
||||
token: {
|
||||
type: TokenType.TRUE,
|
||||
lexeme: "true",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
input: "false",
|
||||
tokenType: [TokenType.FALSE],
|
||||
token: {
|
||||
type: TokenType.FALSE,
|
||||
lexeme: "false",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
input: "null",
|
||||
tokenType: [TokenType.NULL],
|
||||
token: {
|
||||
type: TokenType.NULL,
|
||||
lexeme: "null",
|
||||
pos: {
|
||||
line: 0,
|
||||
column: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test.each(tests)(
|
||||
"$input",
|
||||
({
|
||||
input,
|
||||
tokenType,
|
||||
token,
|
||||
}: {
|
||||
input: string;
|
||||
tokenType: TokenType[];
|
||||
token?: Token;
|
||||
}) => {
|
||||
const l = new Lexer(input);
|
||||
|
||||
const r = l.lex();
|
||||
|
||||
const want = r.tokens.map((t) => t.type);
|
||||
|
||||
tokenType.push(TokenType.EOF);
|
||||
|
||||
expect(want).toEqual(tokenType);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
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 Token = {
|
||||
type: TokenType;
|
||||
|
||||
lexeme: string;
|
||||
value?: string | number | boolean;
|
||||
|
||||
pos: Pos;
|
||||
};
|
||||
|
||||
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: "",
|
||||
pos: this.pos(),
|
||||
});
|
||||
|
||||
return {
|
||||
tokens: this.tokens,
|
||||
};
|
||||
}
|
||||
|
||||
private pos(): Pos {
|
||||
return {
|
||||
line: this.line,
|
||||
column: this.start - this.lastLineOffset,
|
||||
};
|
||||
}
|
||||
|
||||
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 reverse(): 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),
|
||||
pos: this.pos(),
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
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))
|
||||
);
|
||||
} 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));
|
||||
|
||||
case this.match(TokenType.TRUE):
|
||||
return new Literal(new data.BooleanData(true));
|
||||
|
||||
case this.match(TokenType.NULL):
|
||||
return new Literal(new data.Null());
|
||||
|
||||
case this.match(TokenType.NUMBER):
|
||||
return new Literal(new data.NumberData(this.previous().value as number));
|
||||
|
||||
case this.match(TokenType.STRING):
|
||||
return new Literal(new data.StringData(this.previous().value as string));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
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("");
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user