Skip to content
108 changes: 107 additions & 1 deletion packages/jinja/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,91 @@ export class ObjectValue extends RuntimeValue<Map<string, AnyRuntimeValue>> {
["items", new FunctionValue(() => this.items())],
["keys", new FunctionValue(() => this.keys())],
["values", new FunctionValue(() => this.values())],
[
"dictsort",
new FunctionValue((args) => {
// https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.dictsort
// Sort a dictionary and yield (key, value) pairs.
// Optional parameters:
// - case_sensitive: Sort in a case-sensitive manner (default: false)
// - by: Sort by 'key' or 'value' (default: 'key')
// - reverse: Reverse the sort order (default: false)

// Extract keyword arguments if present
let kwargs = new Map<string, AnyRuntimeValue>();
const positionalArgs = args.filter((arg) => {
if (arg instanceof KeywordArgumentsValue) {
kwargs = arg.value;
return false;
}
return true;
});

const caseSensitive = positionalArgs.at(0) ?? kwargs.get("case_sensitive") ?? new BooleanValue(false);
if (!(caseSensitive instanceof BooleanValue)) {
throw new Error("case_sensitive must be a boolean");
}

const by = positionalArgs.at(1) ?? kwargs.get("by") ?? new StringValue("key");
if (!(by instanceof StringValue)) {
throw new Error("by must be a string");
}
if (!["key", "value"].includes(by.value)) {
throw new Error("by must be either 'key' or 'value'");
}

const reverse = positionalArgs.at(2) ?? kwargs.get("reverse") ?? new BooleanValue(false);
if (!(reverse instanceof BooleanValue)) {
throw new Error("reverse must be a boolean");
}

// Convert to array of [key, value] pairs and sort
const items = Array.from(this.value.entries())
.map(([key, value]) => new ArrayValue([new StringValue(key), value]))
.sort((a, b) => {
const index = by.value === "key" ? 0 : 1;

let aValue: unknown = a.value[index].value;
let bValue: unknown = b.value[index].value;

// Handle null/undefined values - put them at the end
if (aValue == null && bValue == null) return 0;
if (aValue == null) return reverse.value ? -1 : 1;
if (bValue == null) return reverse.value ? 1 : -1;

// For case-insensitive string comparison
if (!caseSensitive.value && typeof aValue === "string" && typeof bValue === "string") {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}

// Ensure comparable types:
// This is only an potential issue when `by='value'` and the dictionary has mixed value types
const isPrimitive = (val: unknown) =>
typeof val === "string" || typeof val === "number" || typeof val === "boolean";
const firstNonPrimitive = isPrimitive(aValue) ? (isPrimitive(bValue) ? null : bValue) : aValue;
if (firstNonPrimitive !== null) {
throw new Error(
`Cannot sort dictionary with non-primitive value types (found ${typeof firstNonPrimitive})`
);
} else if (typeof aValue !== typeof bValue) {
throw new Error("Cannot sort dictionary with mixed value types");
}

const a1 = aValue as string | number | boolean;
const b1 = bValue as string | number | boolean;

if (a1 < b1) {
return reverse.value ? 1 : -1;
} else if (a1 > b1) {
return reverse.value ? -1 : 1;
}
return 0;
});

return new ArrayValue(items);
}),
],
]);

items(): ArrayValue {
Expand Down Expand Up @@ -818,8 +903,17 @@ export class Interpreter {
);
case "length":
return new IntegerValue(operand.value.size);
default:
default: {
// Check if the filter exists in builtins
const builtin = operand.builtins.get(filter.value);
if (builtin) {
if (builtin instanceof FunctionValue) {
return builtin.value([], environment);
}
return builtin;
}
throw new Error(`Unknown ObjectValue filter: ${filter.value}`);
}
}
} else if (operand instanceof BooleanValue) {
switch (filter.value) {
Expand Down Expand Up @@ -996,6 +1090,18 @@ export class Interpreter {
}
}
throw new Error(`Unknown StringValue filter: ${filterName}`);
} else if (operand instanceof ObjectValue) {
// Check if the filter exists in builtins for ObjectValue
const builtin = operand.builtins.get(filterName);
if (builtin && builtin instanceof FunctionValue) {
const [args, kwargs] = this.evaluateArguments(filter.args, environment);
// Pass keyword arguments as the last argument if present
if (kwargs.size > 0) {
args.push(new KeywordArgumentsValue(kwargs));
}
return builtin.value(args, environment);
}
throw new Error(`Unknown ObjectValue filter: ${filterName}`);
} else {
throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`);
}
Expand Down
192 changes: 192 additions & 0 deletions packages/jinja/test/templates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ const TEST_STRINGS = {
FILTER_OPERATOR_15: `|{{ "abcabcabc" | replace("a", "b") }}|{{ "abcabcabc" | replace("a", "b", 1) }}|{{ "abcabcabc" | replace("a", "b", count=1) }}|`,
FILTER_OPERATOR_16: `|{{ undefined | default("hello") }}|{{ false | default("hello") }}|{{ false | default("hello", true) }}|{{ 0 | default("hello", boolean=true) }}|`,
FILTER_OPERATOR_17: `{{ [1, 2, 1, -1, 2] | unique | list | length }}`,
FILTER_OPERATOR_DICTSORT_1: `{% for key, value in mydict | dictsort %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_2: `{% for key, value in mydict | dictsort(by='value') %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_3: `{% for key, value in mydict | dictsort(reverse=true) %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_4: `{% for key, value in casedict | dictsort %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_5: `{% for key, value in casedict | dictsort(case_sensitive=true) %}{{ key }}:{{ value }},{% endfor %}`,
FILTER_OPERATOR_DICTSORT_6: `{% for key, value in numdict | dictsort(by='value', reverse=true) %}{{ key }}:{{ value }},{% endfor %}`,

// Filter statements
FILTER_STATEMENTS: `{% filter upper %}text{% endfilter %}`,
Expand Down Expand Up @@ -2389,6 +2395,168 @@ const TEST_PARSED = {
{ value: "length", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
],
FILTER_OPERATOR_DICTSORT_1: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "mydict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_2: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "mydict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "by", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "value", type: "StringLiteral" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_3: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "mydict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "reverse", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "true", type: "Identifier" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_4: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "casedict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_5: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "casedict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "case_sensitive", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "true", type: "Identifier" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],
FILTER_OPERATOR_DICTSORT_6: [
{ value: "{%", type: "OpenStatement" },
{ value: "for", type: "Identifier" },
{ value: "key", type: "Identifier" },
{ value: ",", type: "Comma" },
{ value: "value", type: "Identifier" },
{ value: "in", type: "Identifier" },
{ value: "numdict", type: "Identifier" },
{ value: "|", type: "Pipe" },
{ value: "dictsort", type: "Identifier" },
{ value: "(", type: "OpenParen" },
{ value: "by", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "value", type: "StringLiteral" },
{ value: ",", type: "Comma" },
{ value: "reverse", type: "Identifier" },
{ value: "=", type: "Equals" },
{ value: "true", type: "Identifier" },
{ value: ")", type: "CloseParen" },
{ value: "%}", type: "CloseStatement" },
{ value: "{{", type: "OpenExpression" },
{ value: "key", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ":", type: "Text" },
{ value: "{{", type: "OpenExpression" },
{ value: "value", type: "Identifier" },
{ value: "}}", type: "CloseExpression" },
{ value: ",", type: "Text" },
{ value: "{%", type: "OpenStatement" },
{ value: "endfor", type: "Identifier" },
{ value: "%}", type: "CloseStatement" },
],

// Filter statements
FILTER_STATEMENTS: [
Expand Down Expand Up @@ -4020,6 +4188,24 @@ const TEST_CONTEXT = {
FILTER_OPERATOR_15: {},
FILTER_OPERATOR_16: {},
FILTER_OPERATOR_17: {},
FILTER_OPERATOR_DICTSORT_1: {
mydict: { c: 3, a: 1, b: 2 },
},
FILTER_OPERATOR_DICTSORT_2: {
mydict: { c: 3, a: 1, b: 2 },
},
FILTER_OPERATOR_DICTSORT_3: {
mydict: { c: 3, a: 1, b: 2 },
},
FILTER_OPERATOR_DICTSORT_4: {
casedict: { B: 2, a: 1, C: 3 },
},
FILTER_OPERATOR_DICTSORT_5: {
casedict: { B: 2, a: 1, C: 3 },
},
FILTER_OPERATOR_DICTSORT_6: {
numdict: { apple: 5, banana: 2, cherry: 8 },
},

// Filter statements
FILTER_STATEMENTS: {},
Expand Down Expand Up @@ -4236,6 +4422,12 @@ const EXPECTED_OUTPUTS = {
FILTER_OPERATOR_15: `|bbcbbcbbc|bbcabcabc|bbcabcabc|`,
FILTER_OPERATOR_16: `|hello|false|hello|hello|`,
FILTER_OPERATOR_17: `3`,
FILTER_OPERATOR_DICTSORT_1: `a:1,b:2,c:3,`,
FILTER_OPERATOR_DICTSORT_2: `a:1,b:2,c:3,`,
FILTER_OPERATOR_DICTSORT_3: `c:3,b:2,a:1,`,
FILTER_OPERATOR_DICTSORT_4: `a:1,B:2,C:3,`,
FILTER_OPERATOR_DICTSORT_5: `B:2,C:3,a:1,`,
FILTER_OPERATOR_DICTSORT_6: `cherry:8,apple:5,banana:2,`,

// Filter statements
FILTER_STATEMENTS: `TEXT`,
Expand Down