Merge parser and expressions into repository

This commit is contained in:
Christopher Schleiden
2023-01-06 15:54:31 -08:00
parent 4f961a6247
commit 70e33f999d
328 changed files with 35337 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) {
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.");
}
}
+156
View File
@@ -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);
});
});
+147
View File
@@ -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);
}
+40
View File
@@ -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;
}
}
+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;
}
}
@@ -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;
};
+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";
+21
View File
@@ -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;
}
}
+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;
}
}
@@ -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}');
});
});
+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;
}
@@ -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);
}
);
});
+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);
}
}
+52
View File
@@ -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";
}
}
+27
View File
@@ -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);
});
+245
View File
@@ -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 {}
+63
View File
@@ -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);
}
}
+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));
},
};
@@ -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")
);
});
});
+122
View File
@@ -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;
}
+22
View File
@@ -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;
},
};
+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("");
},
};
@@ -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;
}
}
}
}
+6
View File
@@ -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";
+144
View File
@@ -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);
}
);
});
+425
View File
@@ -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;
}
+397
View File
@@ -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);
}
}
+101
View File
@@ -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);
}
);
});
+229
View File
@@ -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("");
}
+198
View File
@@ -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}`;
}