private applyFilter()

in packages/jinja/src/runtime.ts [690:1004]


	private applyFilter(operand: AnyRuntimeValue, filterNode: Identifier | CallExpression, environment: 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 (filterNode.type === "Identifier") {
			const filter = filterNode as Identifier;

			if (filter.value === "tojson") {
				return new StringValue(toJSON(operand));
			}

			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 IntegerValue(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 "IntegerValue":
									case "FloatValue":
										return (a as IntegerValue | FloatValue).value - (b as IntegerValue | FloatValue).value;
									case "StringValue":
										return (a as StringValue).value.localeCompare((b as StringValue).value);
									default:
										throw new Error(`Cannot compare type: ${a.type}`);
								}
							})
						);
					case "join":
						return new StringValue(operand.value.map((x) => x.value).join(""));
					case "string":
						return new StringValue(toJSON(operand));
					case "unique": {
						const seen = new Set();
						const output: AnyRuntimeValue[] = [];
						for (const item of operand.value) {
							if (!seen.has(item.value)) {
								seen.add(item.value);
								output.push(item);
							}
						}
						return new ArrayValue(output);
					}
					default:
						throw new Error(`Unknown ArrayValue filter: ${filter.value}`);
				}
			} else if (operand instanceof StringValue) {
				switch (filter.value) {
					// Filters that are also built-in functions
					case "length":
					case "upper":
					case "lower":
					case "title":
					case "capitalize": {
						const builtin = operand.builtins.get(filter.value);
						if (builtin instanceof FunctionValue) {
							return builtin.value(/* no arguments */ [], environment);
						} else if (builtin instanceof IntegerValue) {
							return builtin;
						} else {
							throw new Error(`Unknown StringValue filter: ${filter.value}`);
						}
					}
					case "trim":
						return new StringValue(operand.value.trim());
					case "indent":
						return new StringValue(
							operand.value
								.split("\n")
								.map((x, i) =>
									// By default, don't indent the first line or empty lines
									i === 0 || x.length === 0 ? x : "    " + x
								)
								.join("\n")
						);
					case "join":
					case "string":
						return operand; // no-op
					case "int": {
						const val = parseInt(operand.value, 10);
						return new IntegerValue(isNaN(val) ? 0 : val);
					}
					case "float": {
						const val = parseFloat(operand.value);
						return new FloatValue(isNaN(val) ? 0.0 : val);
					}
					default:
						throw new Error(`Unknown StringValue filter: ${filter.value}`);
				}
			} else if (operand instanceof IntegerValue || operand instanceof FloatValue) {
				switch (filter.value) {
					case "abs":
						return operand instanceof IntegerValue
							? new IntegerValue(Math.abs(operand.value))
							: new FloatValue(Math.abs(operand.value));
					case "int":
						return new IntegerValue(Math.floor(operand.value));
					case "float":
						return new FloatValue(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 IntegerValue(operand.value.size);
					default:
						throw new Error(`Unknown ObjectValue filter: ${filter.value}`);
				}
			} else if (operand instanceof BooleanValue) {
				switch (filter.value) {
					case "bool":
						return new BooleanValue(operand.value);
					case "int":
						return new IntegerValue(operand.value ? 1 : 0);
					case "float":
						return new FloatValue(operand.value ? 1.0 : 0.0);
					case "string":
						return new StringValue(operand.value ? "true" : "false");
					default:
						throw new Error(`Unknown BooleanValue filter: ${filter.value}`);
				}
			}
			throw new Error(`Cannot apply filter "${filter.value}" to type: ${operand.type}`);
		} else if (filterNode.type === "CallExpression") {
			const filter = filterNode as CallExpression;

			if (filter.callee.type !== "Identifier") {
				throw new Error(`Unknown filter: ${filter.callee.type}`);
			}
			const filterName = (filter.callee as Identifier).value;

			if (filterName === "tojson") {
				const [, kwargs] = this.evaluateArguments(filter.args, environment);
				const indent = kwargs.get("indent") ?? new NullValue();
				if (!(indent instanceof IntegerValue || indent instanceof NullValue)) {
					throw new Error("If set, indent must be a number");
				}
				return new StringValue(toJSON(operand, indent.value));
			} else if (filterName === "join") {
				let value;
				if (operand instanceof StringValue) {
					// NOTE: string.split('') breaks for unicode characters
					value = Array.from(operand.value);
				} else if (operand instanceof ArrayValue) {
					value = operand.value.map((x) => x.value);
				} else {
					throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`);
				}
				const [args, kwargs] = this.evaluateArguments(filter.args, environment);

				const separator = args.at(0) ?? kwargs.get("separator") ?? new StringValue("");
				if (!(separator instanceof StringValue)) {
					throw new Error("separator must be a string");
				}

				return new StringValue(value.join(separator.value));
			} else if (filterName === "int" || filterName === "float") {
				const [args, kwargs] = this.evaluateArguments(filter.args, environment);
				const defaultValue =
					args.at(0) ?? kwargs.get("default") ?? (filterName === "int" ? new IntegerValue(0) : new FloatValue(0.0));

				if (operand instanceof StringValue) {
					const val = filterName === "int" ? parseInt(operand.value, 10) : parseFloat(operand.value);
					return isNaN(val) ? defaultValue : filterName === "int" ? new IntegerValue(val) : new FloatValue(val);
				} else if (operand instanceof IntegerValue || operand instanceof FloatValue) {
					return operand;
				} else if (operand instanceof BooleanValue) {
					return filterName === "int"
						? new IntegerValue(operand.value ? 1 : 0)
						: new FloatValue(operand.value ? 1.0 : 0.0);
				} else {
					throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`);
				}
			} else if (filterName === "default") {
				const [args, kwargs] = this.evaluateArguments(filter.args, environment);
				const defaultValue = args[0] ?? new StringValue("");
				const booleanValue = args[1] ?? kwargs.get("boolean") ?? new BooleanValue(false);
				if (!(booleanValue instanceof BooleanValue)) {
					throw new Error("`default` filter flag must be a boolean");
				}
				if (operand instanceof UndefinedValue || (booleanValue.value && !operand.__bool__().value)) {
					return defaultValue;
				}
				return operand;
			}

			if (operand instanceof ArrayValue) {
				switch (filterName) {
					case "selectattr":
					case "rejectattr": {
						const select = filterName === "selectattr";

						if (operand.value.some((x) => !(x instanceof ObjectValue))) {
							throw new Error(`\`${filterName}\` can only be applied to array of objects`);
						}
						if (filter.args.some((x) => x.type !== "StringLiteral")) {
							throw new Error(`arguments of \`${filterName}\` 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);
							const result = a ? testFunction(a, value) : false;
							return select ? result : !result;
						});

						return new ArrayValue(filtered);
					}
					case "map": {
						// Accumulate kwargs
						const [, kwargs] = this.evaluateArguments(filter.args, environment);

						if (kwargs.has("attribute")) {
							// Mapping on attributes
							const attr = kwargs.get("attribute");
							if (!(attr instanceof StringValue)) {
								throw new Error("attribute must be a string");
							}
							const defaultValue = kwargs.get("default");
							const mapped = operand.value.map((item) => {
								if (!(item instanceof ObjectValue)) {
									throw new Error("items in map must be an object");
								}
								return item.value.get(attr.value) ?? defaultValue ?? new UndefinedValue();
							});
							return new ArrayValue(mapped);
						} else {
							throw new Error("`map` expressions without `attribute` set are not currently supported.");
						}
					}
				}
				throw new Error(`Unknown ArrayValue filter: ${filterName}`);
			} else if (operand instanceof StringValue) {
				switch (filterName) {
					case "indent": {
						// https://jinja.palletsprojects.com/en/3.1.x/templates/#jinja-filters.indent
						// Return a copy of the string with each line indented by 4 spaces. The first line and blank lines are not indented by default.
						// Parameters:
						//  - width: Number of spaces, or a string, to indent by.
						//  - first: Don't skip indenting the first line.
						//  - blank: Don't skip indenting empty lines.

						const [args, kwargs] = this.evaluateArguments(filter.args, environment);

						const width = args.at(0) ?? kwargs.get("width") ?? new IntegerValue(4);
						if (!(width instanceof IntegerValue)) {
							throw new Error("width must be a number");
						}
						const first = args.at(1) ?? kwargs.get("first") ?? new BooleanValue(false);
						const blank = args.at(2) ?? kwargs.get("blank") ?? new BooleanValue(false);

						const lines = operand.value.split("\n");
						const indent = " ".repeat(width.value);
						const indented = lines.map((x, i) =>
							(!first.value && i === 0) || (!blank.value && x.length === 0) ? x : indent + x
						);
						return new StringValue(indented.join("\n"));
					}
					case "replace": {
						const replaceFn = operand.builtins.get("replace");
						if (!(replaceFn instanceof FunctionValue)) {
							throw new Error("replace filter not available");
						}
						const [args, kwargs] = this.evaluateArguments(filter.args, environment);
						return replaceFn.value([...args, new KeywordArgumentsValue(kwargs)], environment);
					}
				}
				throw new Error(`Unknown StringValue filter: ${filterName}`);
			} else {
				throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`);
			}
		}
		throw new Error(`Unknown filter: ${filterNode.type}`);
	}