Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
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<T> { | |
type = "RuntimeValue"; | |
value: T; | |
/** | |
* A collection of built-in functions for this type. | |
*/ | |
builtins = new Map<string, AnyRuntimeValue>(); | |
/** | |
* 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<number> { | |
override type = "NumericValue"; | |
} | |
/** | |
* Represents a string value at runtime. | |
*/ | |
export class StringValue extends RuntimeValue<string> { | |
override type = "StringValue"; | |
override builtins = new Map<string, AnyRuntimeValue>([ | |
[ | |
"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<boolean> { | |
override type = "BooleanValue"; | |
} | |
/** | |
* Represents an Object value at runtime. | |
*/ | |
export class ObjectValue extends RuntimeValue<Map<string, AnyRuntimeValue>> { | |
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<string, AnyRuntimeValue> = new Map<string, AnyRuntimeValue>([ | |
[ | |
"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<AnyRuntimeValue[]> { | |
override type = "ArrayValue"; | |
override builtins = new Map<string, AnyRuntimeValue>([["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<null> { | |
override type = "NullValue"; | |
} | |
/** | |
* Represents an Undefined value at runtime. | |
*/ | |
export class UndefinedValue extends RuntimeValue<undefined> { | |
override type = "UndefinedValue"; | |
} | |
/** | |
* Represents the current environment (scope) at runtime. | |
*/ | |
export class Environment { | |
/** | |
* The variables declared in this environment. | |
*/ | |
variables: Map<string, AnyRuntimeValue> = 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<string, (...value: AnyRuntimeValue[]) => 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}`); | |
} | |
} | |