import type { NumericLiteral, StringLiteral, BooleanLiteral, ArrayLiteral, Statement, Program, If, For, SetStatement, MemberExpression, CallExpression, Identifier, BinaryExpression, FilterExpression, TestExpression, UnaryExpression, SliceExpression, KeywordArgumentExpression, ObjectLiteral, TupleLiteral, } from "./ast"; import { slice, titleCase } from "./utils"; export type AnyRuntimeValue = | NumericValue | StringValue | BooleanValue | ObjectValue | ArrayValue | FunctionValue | NullValue | UndefinedValue; /** * Abstract base class for all Runtime values. * Should not be instantiated directly. */ abstract class RuntimeValue { type = "RuntimeValue"; value: T; /** * A collection of built-in functions for this type. */ builtins = new Map(); /** * Creates a new RuntimeValue. */ constructor(value: T = undefined as unknown as T) { this.value = value; } /** * Determines truthiness or falsiness of the runtime value. * This function should be overridden by subclasses if it has custom truthiness criteria. * @returns {BooleanValue} BooleanValue(true) if the value is truthy, BooleanValue(false) otherwise. */ __bool__(): BooleanValue { return new BooleanValue(!!this.value); } } /** * Represents a numeric value at runtime. */ export class NumericValue extends RuntimeValue { override type = "NumericValue"; } /** * Represents a string value at runtime. */ export class StringValue extends RuntimeValue { override type = "StringValue"; override builtins = new Map([ [ "upper", new FunctionValue(() => { return new StringValue(this.value.toUpperCase()); }), ], [ "lower", new FunctionValue(() => { return new StringValue(this.value.toLowerCase()); }), ], [ "strip", new FunctionValue(() => { return new StringValue(this.value.trim()); }), ], [ "title", new FunctionValue(() => { return new StringValue(titleCase(this.value)); }), ], ["length", new NumericValue(this.value.length)], ]); } /** * Represents a boolean value at runtime. */ export class BooleanValue extends RuntimeValue { override type = "BooleanValue"; } /** * Represents an Object value at runtime. */ export class ObjectValue extends RuntimeValue> { override type = "ObjectValue"; /** * NOTE: necessary to override since all JavaScript arrays are considered truthy, * while only non-empty Python arrays are consider truthy. * * e.g., * - JavaScript: {} && 5 -> 5 * - Python: {} and 5 -> {} */ override __bool__(): BooleanValue { return new BooleanValue(this.value.size > 0); } override builtins: Map = new Map([ [ "get", new FunctionValue(([key, defaultValue]) => { if (!(key instanceof StringValue)) { throw new Error(`Object key must be a string: got ${key.type}`); } return this.value.get(key.value) ?? defaultValue ?? new NullValue(); }), ], [ "items", new FunctionValue(() => { return new ArrayValue( Array.from(this.value.entries()).map(([key, value]) => new ArrayValue([new StringValue(key), value])) ); }), ], ]); } /** * Represents an Array value at runtime. */ export class ArrayValue extends RuntimeValue { override type = "ArrayValue"; override builtins = new Map([["length", new NumericValue(this.value.length)]]); /** * NOTE: necessary to override since all JavaScript arrays are considered truthy, * while only non-empty Python arrays are consider truthy. * * e.g., * - JavaScript: [] && 5 -> 5 * - Python: [] and 5 -> [] */ override __bool__(): BooleanValue { return new BooleanValue(this.value.length > 0); } } /** * Represents a Tuple value at runtime. * NOTE: We extend ArrayValue since JavaScript does not have a built-in Tuple type. */ export class TupleValue extends ArrayValue { override type = "TupleValue"; } /** * Represents a Function value at runtime. */ export class FunctionValue extends RuntimeValue<(args: AnyRuntimeValue[], scope: Environment) => AnyRuntimeValue> { override type = "FunctionValue"; } /** * Represents a Null value at runtime. */ export class NullValue extends RuntimeValue { override type = "NullValue"; } /** * Represents an Undefined value at runtime. */ export class UndefinedValue extends RuntimeValue { override type = "UndefinedValue"; } /** * Represents the current environment (scope) at runtime. */ export class Environment { /** * The variables declared in this environment. */ variables: Map = new Map([ [ "namespace", new FunctionValue((args) => { if (args.length === 0) { return new ObjectValue(new Map()); } if (args.length !== 1 || !(args[0] instanceof ObjectValue)) { throw new Error("`namespace` expects either zero arguments or a single object argument"); } return args[0]; }), ], ]); /** * The tests available in this environment. */ tests: Map boolean> = new Map([ ["boolean", (operand) => operand.type === "BooleanValue"], ["callable", (operand) => operand instanceof FunctionValue], [ "odd", (operand) => { if (operand.type !== "NumericValue") { throw new Error(`Cannot apply test "odd" to type: ${operand.type}`); } return (operand as NumericValue).value % 2 !== 0; }, ], [ "even", (operand) => { if (operand.type !== "NumericValue") { throw new Error(`Cannot apply test "even" to type: ${operand.type}`); } return (operand as NumericValue).value % 2 === 0; }, ], ["false", (operand) => operand.type === "BooleanValue" && !(operand as BooleanValue).value], ["true", (operand) => operand.type === "BooleanValue" && (operand as BooleanValue).value], ["number", (operand) => operand.type === "NumericValue"], ["integer", (operand) => operand.type === "NumericValue" && Number.isInteger((operand as NumericValue).value)], ["iterable", (operand) => operand instanceof ArrayValue || operand instanceof StringValue], [ "lower", (operand) => { const str = (operand as StringValue).value; return operand.type === "StringValue" && str === str.toLowerCase(); }, ], [ "upper", (operand) => { const str = (operand as StringValue).value; return operand.type === "StringValue" && str === str.toUpperCase(); }, ], ["none", (operand) => operand.type === "NullValue"], ["defined", (operand) => operand.type !== "UndefinedValue"], ["undefined", (operand) => operand.type === "UndefinedValue"], ["equalto", (a, b) => a.value === b.value], ]); constructor(public parent?: Environment) {} /** * Set the value of a variable in the current environment. */ set(name: string, value: unknown): AnyRuntimeValue { return this.declareVariable(name, convertToRuntimeValues(value)); } private declareVariable(name: string, value: AnyRuntimeValue): AnyRuntimeValue { if (this.variables.has(name)) { throw new SyntaxError(`Variable already declared: ${name}`); } this.variables.set(name, value); return value; } // private assignVariable(name: string, value: AnyRuntimeValue): AnyRuntimeValue { // const env = this.resolve(name); // env.variables.set(name, value); // return value; // } /** * Set variable in the current scope. * See https://jinja.palletsprojects.com/en/3.0.x/templates/#assignments for more information. */ setVariable(name: string, value: AnyRuntimeValue): AnyRuntimeValue { this.variables.set(name, value); return value; } /** * Resolve the environment in which the variable is declared. * @param {string} name The name of the variable. * @returns {Environment} The environment in which the variable is declared. */ private resolve(name: string): Environment { if (this.variables.has(name)) { return this; } // Traverse scope chain if (this.parent) { return this.parent.resolve(name); } throw new Error(`Unknown variable: ${name}`); } lookupVariable(name: string): AnyRuntimeValue { try { return this.resolve(name).variables.get(name) ?? new UndefinedValue(); } catch { return new UndefinedValue(); } } } export class Interpreter { global: Environment; constructor(env?: Environment) { this.global = env ?? new Environment(); } /** * Run the program. */ run(program: Program): AnyRuntimeValue { return this.evaluate(program, this.global); } /** * Evaluates expressions following the binary operation type. */ private evaluateBinaryExpression(node: BinaryExpression, environment: Environment): AnyRuntimeValue { const left = this.evaluate(node.left, environment); // Logical operators // NOTE: Short-circuiting is handled by the `evaluate` function switch (node.operator.value) { case "and": return left.__bool__().value ? this.evaluate(node.right, environment) : left; case "or": return left.__bool__().value ? left : this.evaluate(node.right, environment); } // Equality operators const right = this.evaluate(node.right, environment); switch (node.operator.value) { case "==": return new BooleanValue(left.value == right.value); case "!=": return new BooleanValue(left.value != right.value); } if (left instanceof UndefinedValue || right instanceof UndefinedValue) { throw new Error("Cannot perform operation on undefined values"); } else if (left instanceof NullValue || right instanceof NullValue) { throw new Error("Cannot perform operation on null values"); } else if (left instanceof NumericValue && right instanceof NumericValue) { // Evaulate pure numeric operations with binary operators. switch (node.operator.value) { // Arithmetic operators case "+": return new NumericValue(left.value + right.value); case "-": return new NumericValue(left.value - right.value); case "*": return new NumericValue(left.value * right.value); case "/": return new NumericValue(left.value / right.value); case "%": return new NumericValue(left.value % right.value); // Comparison operators case "<": return new BooleanValue(left.value < right.value); case ">": return new BooleanValue(left.value > right.value); case ">=": return new BooleanValue(left.value >= right.value); case "<=": return new BooleanValue(left.value <= right.value); } } else if (left instanceof ArrayValue && right instanceof ArrayValue) { // Evaluate array operands with binary operator. switch (node.operator.value) { case "+": return new ArrayValue(left.value.concat(right.value)); } } else if (right instanceof ArrayValue) { const member = right.value.find((x) => x.value === left.value) !== undefined; switch (node.operator.value) { case "in": return new BooleanValue(member); case "not in": return new BooleanValue(!member); } } if (left instanceof StringValue || right instanceof StringValue) { // Support string concatenation as long as at least one operand is a string switch (node.operator.value) { case "+": return new StringValue(left.value.toString() + right.value.toString()); } } if (left instanceof StringValue && right instanceof StringValue) { switch (node.operator.value) { case "in": return new BooleanValue(right.value.includes(left.value)); case "not in": return new BooleanValue(!right.value.includes(left.value)); } } if (left instanceof StringValue && right instanceof ObjectValue) { switch (node.operator.value) { case "in": return new BooleanValue(right.value.has(left.value)); case "not in": return new BooleanValue(!right.value.has(left.value)); } } throw new SyntaxError(`Unknown operator "${node.operator.value}" between ${left.type} and ${right.type}`); } /** * Evaluates expressions following the filter operation type. */ private evaluateFilterExpression(node: FilterExpression, environment: Environment): AnyRuntimeValue { const operand = this.evaluate(node.operand, environment); // For now, we only support the built-in filters // TODO: Add support for non-identifier filters // e.g., functions which return filters: {{ numbers | select("odd") }} // TODO: Add support for user-defined filters // const filter = environment.lookupVariable(node.filter.value); // if (!(filter instanceof FunctionValue)) { // throw new Error(`Filter must be a function: got ${filter.type}`); // } // return filter.value([operand], environment); // https://jinja.palletsprojects.com/en/3.0.x/templates/#list-of-builtin-filters if (node.filter.type === "Identifier") { const filter = node.filter as Identifier; if (operand instanceof ArrayValue) { switch (filter.value) { case "list": return operand; case "first": return operand.value[0]; case "last": return operand.value[operand.value.length - 1]; case "length": return new NumericValue(operand.value.length); case "reverse": return new ArrayValue(operand.value.reverse()); case "sort": return new ArrayValue( operand.value.sort((a, b) => { if (a.type !== b.type) { throw new Error(`Cannot compare different types: ${a.type} and ${b.type}`); } switch (a.type) { case "NumericValue": return (a as NumericValue).value - (b as NumericValue).value; case "StringValue": return (a as StringValue).value.localeCompare((b as StringValue).value); default: throw new Error(`Cannot compare type: ${a.type}`); } }) ); default: throw new Error(`Unknown ArrayValue filter: ${filter.value}`); } } else if (operand instanceof StringValue) { switch (filter.value) { case "length": return new NumericValue(operand.value.length); case "upper": return new StringValue(operand.value.toUpperCase()); case "lower": return new StringValue(operand.value.toLowerCase()); case "title": return new StringValue(titleCase(operand.value)); case "capitalize": return new StringValue(operand.value.charAt(0).toUpperCase() + operand.value.slice(1)); case "trim": return new StringValue(operand.value.trim()); default: throw new Error(`Unknown StringValue filter: ${filter.value}`); } } else if (operand instanceof NumericValue) { switch (filter.value) { case "abs": return new NumericValue(Math.abs(operand.value)); default: throw new Error(`Unknown NumericValue filter: ${filter.value}`); } } else if (operand instanceof ObjectValue) { switch (filter.value) { case "items": return new ArrayValue( Array.from(operand.value.entries()).map(([key, value]) => new ArrayValue([new StringValue(key), value])) ); case "length": return new NumericValue(operand.value.size); default: throw new Error(`Unknown ObjectValue filter: ${filter.value}`); } } throw new Error(`Cannot apply filter "${filter.value}" to type: ${operand.type}`); } else if (node.filter.type === "CallExpression") { const filter = node.filter as CallExpression; if (filter.callee.type !== "Identifier") { throw new Error(`Unknown filter: ${filter.callee.type}`); } const filterName = (filter.callee as Identifier).value; if (operand instanceof ArrayValue) { switch (filterName) { case "selectattr": { if (operand.value.some((x) => !(x instanceof ObjectValue))) { throw new Error("`selectattr` can only be applied to array of objects"); } if (filter.args.some((x) => x.type !== "StringLiteral")) { throw new Error("arguments of `selectattr` must be strings"); } const [attr, testName, value] = filter.args.map((x) => this.evaluate(x, environment)) as StringValue[]; let testFunction: (...x: AnyRuntimeValue[]) => boolean; if (testName) { // Get the test function from the environment const test = environment.tests.get(testName.value); if (!test) { throw new Error(`Unknown test: ${testName.value}`); } testFunction = test; } else { // Default to truthiness of first argument testFunction = (...x: AnyRuntimeValue[]) => x[0].__bool__().value; } // Filter the array using the test function const filtered = (operand.value as ObjectValue[]).filter((item) => { const a = item.value.get(attr.value); if (a) { return testFunction(a, value); } return false; }); return new ArrayValue(filtered); } } throw new Error(`Unknown ArrayValue filter: ${filterName}`); } else { throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`); } } throw new Error(`Unknown filter: ${node.filter.type}`); } /** * Evaluates expressions following the test operation type. */ private evaluateTestExpression(node: TestExpression, environment: Environment): BooleanValue { // For now, we only support the built-in tests // https://jinja.palletsprojects.com/en/3.0.x/templates/#list-of-builtin-tests // // TODO: Add support for non-identifier tests. e.g., divisibleby(number) const operand = this.evaluate(node.operand, environment); const test = environment.tests.get(node.test.value); if (!test) { throw new Error(`Unknown test: ${node.test.value}`); } const result = test(operand); return new BooleanValue(node.negate ? !result : result); } /** * Evaluates expressions following the unary operation type. */ private evaluateUnaryExpression(node: UnaryExpression, environment: Environment): AnyRuntimeValue { const argument = this.evaluate(node.argument, environment); switch (node.operator.value) { case "not": return new BooleanValue(!argument.value); default: throw new SyntaxError(`Unknown operator: ${node.operator.value}`); } } private evalProgram(program: Program, environment: Environment): StringValue { return this.evaluateBlock(program.body, environment); } private evaluateBlock(statements: Statement[], environment: Environment): StringValue { // Jinja templates always evaluate to a String, // so we accumulate the result of each statement into a final string let result = ""; for (const statement of statements) { const lastEvaluated = this.evaluate(statement, environment); if (lastEvaluated.type !== "NullValue" && lastEvaluated.type !== "UndefinedValue") { result += lastEvaluated.value; } } return new StringValue(result); } private evaluateIdentifier(node: Identifier, environment: Environment): AnyRuntimeValue { return environment.lookupVariable(node.value); } private evaluateCallExpression(expr: CallExpression, environment: Environment): AnyRuntimeValue { // Accumulate all keyword arguments into a single object, which will be // used as the final argument in the call function. const args: AnyRuntimeValue[] = []; const kwargs = new Map(); for (const argument of expr.args) { if (argument.type === "KeywordArgumentExpression") { const kwarg = argument as KeywordArgumentExpression; kwargs.set(kwarg.key.value, this.evaluate(kwarg.value, environment)); } else { args.push(this.evaluate(argument, environment)); } } if (kwargs.size > 0) { args.push(new ObjectValue(kwargs)); } const fn = this.evaluate(expr.callee, environment); if (fn.type !== "FunctionValue") { throw new Error(`Cannot call something that is not a function: got ${fn.type}`); } return (fn as FunctionValue).value(args, environment); } private evaluateSliceExpression( object: AnyRuntimeValue, expr: SliceExpression, environment: Environment ): ArrayValue | StringValue { if (!(object instanceof ArrayValue || object instanceof StringValue)) { throw new Error("Slice object must be an array or string"); } const start = this.evaluate(expr.start, environment); const stop = this.evaluate(expr.stop, environment); const step = this.evaluate(expr.step, environment); // Validate arguments if (!(start instanceof NumericValue || start instanceof UndefinedValue)) { throw new Error("Slice start must be numeric or undefined"); } if (!(stop instanceof NumericValue || stop instanceof UndefinedValue)) { throw new Error("Slice stop must be numeric or undefined"); } if (!(step instanceof NumericValue || step instanceof UndefinedValue)) { throw new Error("Slice step must be numeric or undefined"); } if (object instanceof ArrayValue) { return new ArrayValue(slice(object.value, start.value, stop.value, step.value)); } else { return new StringValue(slice(Array.from(object.value), start.value, stop.value, step.value).join("")); } } private evaluateMemberExpression(expr: MemberExpression, environment: Environment): AnyRuntimeValue { const object = this.evaluate(expr.object, environment); let property; if (expr.computed) { if (expr.property.type === "SliceExpression") { return this.evaluateSliceExpression(object, expr.property as SliceExpression, environment); } else { property = this.evaluate(expr.property, environment); } } else { property = new StringValue((expr.property as Identifier).value); } let value; if (object instanceof ObjectValue) { if (!(property instanceof StringValue)) { throw new Error(`Cannot access property with non-string: got ${property.type}`); } value = object.value.get(property.value) ?? object.builtins.get(property.value); } else if (object instanceof ArrayValue || object instanceof StringValue) { if (property instanceof NumericValue) { value = object.value.at(property.value); if (object instanceof StringValue) { value = new StringValue(object.value.at(property.value)); } } else if (property instanceof StringValue) { value = object.builtins.get(property.value); } else { throw new Error(`Cannot access property with non-string/non-number: got ${property.type}`); } } else { if (!(property instanceof StringValue)) { throw new Error(`Cannot access property with non-string: got ${property.type}`); } value = object.builtins.get(property.value); } return value instanceof RuntimeValue ? value : new UndefinedValue(); } private evaluateSet(node: SetStatement, environment: Environment): NullValue { const rhs = this.evaluate(node.value, environment); if (node.assignee.type === "Identifier") { const variableName = (node.assignee as Identifier).value; environment.setVariable(variableName, rhs); } else if (node.assignee.type === "MemberExpression") { const member = node.assignee as MemberExpression; const object = this.evaluate(member.object, environment); if (!(object instanceof ObjectValue)) { throw new Error("Cannot assign to member of non-object"); } if (member.property.type !== "Identifier") { throw new Error("Cannot assign to member with non-identifier property"); } object.value.set((member.property as Identifier).value, rhs); } else { throw new Error(`Invalid LHS inside assignment expression: ${JSON.stringify(node.assignee)}`); } return new NullValue(); } private evaluateIf(node: If, environment: Environment): StringValue { const test = this.evaluate(node.test, environment); return this.evaluateBlock(test.__bool__().value ? node.body : node.alternate, environment); } private evaluateFor(node: For, environment: Environment): StringValue { // Scope for the for loop const scope = new Environment(environment); const iterable = this.evaluate(node.iterable, scope); if (!(iterable instanceof ArrayValue)) { throw new Error(`Expected iterable type in for loop: got ${iterable.type}`); } let result = ""; for (let i = 0; i < iterable.value.length; ++i) { // Update the loop variable // TODO: Only create object once, then update value? const loop = new Map([ ["index", new NumericValue(i + 1)], ["index0", new NumericValue(i)], ["revindex", new NumericValue(iterable.value.length - i)], ["revindex0", new NumericValue(iterable.value.length - i - 1)], ["first", new BooleanValue(i === 0)], ["last", new BooleanValue(i === iterable.value.length - 1)], ["length", new NumericValue(iterable.value.length)], ["previtem", i > 0 ? iterable.value[i - 1] : new UndefinedValue()], ["nextitem", i < iterable.value.length - 1 ? iterable.value[i + 1] : new UndefinedValue()], ] as [string, AnyRuntimeValue][]); scope.setVariable("loop", new ObjectValue(loop)); const current = iterable.value[i]; // For this iteration, set the loop variable to the current element if (node.loopvar.type === "Identifier") { scope.setVariable((node.loopvar as Identifier).value, current); } else if (node.loopvar.type === "TupleLiteral") { const loopvar = node.loopvar as TupleLiteral; if (current.type !== "ArrayValue") { throw new Error(`Cannot unpack non-iterable type: ${current.type}`); } const c = current as ArrayValue; // check if too few or many items to unpack if (loopvar.value.length !== c.value.length) { throw new Error(`Too ${loopvar.value.length > c.value.length ? "few" : "many"} items to unpack`); } for (let j = 0; j < loopvar.value.length; ++j) { if (loopvar.value[j].type !== "Identifier") { throw new Error(`Cannot unpack non-identifier type: ${loopvar.value[j].type}`); } scope.setVariable((loopvar.value[j] as Identifier).value, c.value[j]); } } // Evaluate the body of the for loop const evaluated = this.evaluateBlock(node.body, scope); result += evaluated.value; } return new StringValue(result); } evaluate(statement: Statement | undefined, environment: Environment): AnyRuntimeValue { if (statement === undefined) return new UndefinedValue(); switch (statement.type) { // Program case "Program": return this.evalProgram(statement as Program, environment); // Statements case "Set": return this.evaluateSet(statement as SetStatement, environment); case "If": return this.evaluateIf(statement as If, environment); case "For": return this.evaluateFor(statement as For, environment); // Expressions case "NumericLiteral": return new NumericValue(Number((statement as NumericLiteral).value)); case "StringLiteral": return new StringValue((statement as StringLiteral).value); case "BooleanLiteral": return new BooleanValue((statement as BooleanLiteral).value); case "ArrayLiteral": return new ArrayValue((statement as ArrayLiteral).value.map((x) => this.evaluate(x, environment))); case "TupleLiteral": return new TupleValue((statement as TupleLiteral).value.map((x) => this.evaluate(x, environment))); case "ObjectLiteral": { const mapping = new Map(); for (const [key, value] of (statement as ObjectLiteral).value) { const evaluatedKey = this.evaluate(key, environment); if (!(evaluatedKey instanceof StringValue)) { throw new Error(`Object keys must be strings: got ${evaluatedKey.type}`); } mapping.set(evaluatedKey.value, this.evaluate(value, environment)); } return new ObjectValue(mapping); } case "Identifier": return this.evaluateIdentifier(statement as Identifier, environment); case "CallExpression": return this.evaluateCallExpression(statement as CallExpression, environment); case "MemberExpression": return this.evaluateMemberExpression(statement as MemberExpression, environment); case "UnaryExpression": return this.evaluateUnaryExpression(statement as UnaryExpression, environment); case "BinaryExpression": return this.evaluateBinaryExpression(statement as BinaryExpression, environment); case "FilterExpression": return this.evaluateFilterExpression(statement as FilterExpression, environment); case "TestExpression": return this.evaluateTestExpression(statement as TestExpression, environment); default: throw new SyntaxError(`Unknown node type: ${statement.type}`); } } } /** * Helper function to convert JavaScript values to runtime values. */ function convertToRuntimeValues(input: unknown): AnyRuntimeValue { switch (typeof input) { case "number": return new NumericValue(input); case "string": return new StringValue(input); case "boolean": return new BooleanValue(input); case "object": if (input === null) { return new NullValue(); } else if (Array.isArray(input)) { return new ArrayValue(input.map(convertToRuntimeValues)); } else { return new ObjectValue( new Map(Object.entries(input).map(([key, value]) => [key, convertToRuntimeValues(value)])) ); } case "function": // Wrap the user's function in a runtime function // eslint-disable-next-line @typescript-eslint/no-unused-vars return new FunctionValue((args, _scope) => { // NOTE: `_scope` is not used since it's in the global scope const result = input(...args.map((x) => x.value)) ?? null; // map undefined -> null return convertToRuntimeValues(result); }); default: throw new Error(`Cannot convert to runtime value: ${input}`); } }