import type { NumericLiteral, StringLiteral, BooleanLiteral, Statement, Program, If, For, SetStatement, MemberExpression, CallExpression, Identifier, BinaryExpression, FilterExpression, TestExpression, UnaryExpression, SliceExpression, KeywordArgumentExpression, } 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); } } /** * 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 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]; }), ], ]); constructor(public parent?: Environment) {} /** * Set the value of a variable in the current environment. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types set(name: string, value: any): 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; // } /** * Declare if doesn't exist, assign otherwise. */ setVariable(name: string, value: AnyRuntimeValue): AnyRuntimeValue { let env: Environment | undefined; try { env = this.resolve(name); } catch { /* empty */ } (env ?? 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 { return this.resolve(name).variables.get(name) ?? new NullValue(); } } 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 (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)); } } 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 (operand instanceof ArrayValue) { switch (node.filter.value) { 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: ${node.filter.value}`); } } else if (operand instanceof StringValue) { switch (node.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: ${node.filter.value}`); } } else if (operand instanceof NumericValue) { switch (node.filter.value) { case "abs": return new NumericValue(Math.abs(operand.value)); default: throw new Error(`Unknown NumericValue filter: ${node.filter.value}`); } } throw new Error(`Cannot apply filter "${node.filter.value}" to type: ${operand.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 result: boolean = (() => { try { const operand = this.evaluate(node.operand, environment); switch (node.test.value) { case "boolean": return operand.type === "BooleanValue"; case "callable": return operand instanceof FunctionValue; case "odd": if (operand.type !== "NumericValue") { throw new Error(`Cannot apply test "odd" to type: ${operand.type}`); } return (operand as NumericValue).value % 2 !== 0; case "even": if (operand.type !== "NumericValue") { throw new Error(`Cannot apply test "even" to type: ${operand.type}`); } return (operand as NumericValue).value % 2 === 0; case "false": return operand.type === "BooleanValue" && !(operand as BooleanValue).value; case "true": return operand.type === "BooleanValue" && (operand as BooleanValue).value; case "number": return operand.type === "NumericValue"; case "integer": return operand.type === "NumericValue" && Number.isInteger((operand as NumericValue).value); case "iterable": return operand instanceof ArrayValue || operand instanceof StringValue; case "lower": { const str = (operand as StringValue).value; return operand.type === "StringValue" && str === str.toLowerCase(); } case "upper": { const str = (operand as StringValue).value; return operand.type === "StringValue" && str === str.toUpperCase(); } case "none": return operand.type === "NullValue"; case "defined": return true; case "undefined": return false; } throw new Error(`Unknown test: ${node.test.value}`); } catch (e) { if (node.operand.type === "Identifier") { // Special cases where we want to check if a variable is defined if (node.test.value === "defined") { return false; } else if (node.test.value === "undefined") { return true; } } throw e; } })(); 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") { 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); } if (!(value instanceof RuntimeValue)) { throw new Error(`${object.type} has no property '${property.value}'`); } return value; } 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)); // For this iteration, set the loop variable to the current element scope.setVariable(node.loopvar.value, iterable.value[i]); // 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 "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}`); } }