From b18ad69b9895a6a61dc7438d91071e84b8dcbb58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:36:28 +0000 Subject: [PATCH 1/6] Initial plan From bd153da0e75ab5b9d307040c5c8e5488f57cc68a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:47:48 +0000 Subject: [PATCH 2/6] Add dictsort filter implementation with comprehensive tests Co-authored-by: xenova <26504141+xenova@users.noreply.github.com> --- packages/jinja/src/runtime.ts | 77 +++++++++++ packages/jinja/test/templates.test.js | 192 ++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) diff --git a/packages/jinja/src/runtime.ts b/packages/jinja/src/runtime.ts index cd59167e39..a0bd29a940 100644 --- a/packages/jinja/src/runtime.ts +++ b/packages/jinja/src/runtime.ts @@ -818,6 +818,21 @@ export class Interpreter { ); case "length": return new IntegerValue(operand.value.size); + case "dictsort": { + // Default dictsort behavior (no parameters) + // Sort by key, case-insensitive, not reversed + const items = Array.from(operand.value.entries()).map( + ([key, value]) => new ArrayValue([new StringValue(key), value]) + ); + + items.sort((a, b) => { + const aKey = (a.value[0] as StringValue).value.toLowerCase(); + const bKey = (b.value[0] as StringValue).value.toLowerCase(); + return aKey < bKey ? -1 : aKey > bKey ? 1 : 0; + }); + + return new ArrayValue(items); + } default: throw new Error(`Unknown ObjectValue filter: ${filter.value}`); } @@ -996,6 +1011,68 @@ export class Interpreter { } } throw new Error(`Unknown StringValue filter: ${filterName}`); + } else if (operand instanceof ObjectValue) { + switch (filterName) { + case "dictsort": { + // https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.dictsort + // Sort a dictionary and yield (key, value) pairs. + // 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) + + const [args, kwargs] = this.evaluateArguments(filter.args, environment); + + const caseSensitive = args.at(0) ?? kwargs.get("case_sensitive") ?? new BooleanValue(false); + if (!(caseSensitive instanceof BooleanValue)) { + throw new Error("case_sensitive must be a boolean"); + } + + const by = args.at(1) ?? kwargs.get("by") ?? new StringValue("key"); + if (!(by instanceof StringValue)) { + throw new Error("by must be a string"); + } + if (by.value !== "key" && by.value !== "value") { + throw new Error("by must be either 'key' or 'value'"); + } + + const reverse = args.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 + const items = Array.from(operand.value.entries()).map( + ([key, value]) => new ArrayValue([new StringValue(key), value]) + ); + + // Sort the items + items.sort((a, b) => { + const aItem = a.value[by.value === "key" ? 0 : 1]; + const bItem = b.value[by.value === "key" ? 0 : 1]; + + let aValue: unknown = aItem.value; + let bValue: unknown = bItem.value; + + // For case-insensitive string comparison + if (!caseSensitive.value && typeof aValue === "string" && typeof bValue === "string") { + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + } + + // Compare values + if (aValue != null && bValue != null && aValue < bValue) { + return reverse.value ? 1 : -1; + } else if (aValue != null && bValue != null && aValue > bValue) { + return reverse.value ? -1 : 1; + } + return 0; + }); + + return new ArrayValue(items); + } + } + throw new Error(`Unknown ObjectValue filter: ${filterName}`); } else { throw new Error(`Cannot apply filter "${filterName}" to type: ${operand.type}`); } diff --git a/packages/jinja/test/templates.test.js b/packages/jinja/test/templates.test.js index b24c3b13a6..8af1c9dc1a 100644 --- a/packages/jinja/test/templates.test.js +++ b/packages/jinja/test/templates.test.js @@ -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 %}`, @@ -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: [ @@ -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: {}, @@ -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`, From be497ed82ed7fefd5a7336089df0bcc9cb153525 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:51:10 +0000 Subject: [PATCH 3/6] Refactor dictsort implementation to eliminate code duplication and improve null handling Co-authored-by: xenova <26504141+xenova@users.noreply.github.com> --- packages/jinja/src/runtime.ts | 98 ++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/packages/jinja/src/runtime.ts b/packages/jinja/src/runtime.ts index a0bd29a940..4ddc317e1f 100644 --- a/packages/jinja/src/runtime.ts +++ b/packages/jinja/src/runtime.ts @@ -687,6 +687,55 @@ export class Interpreter { return [positionalArguments, keywordArguments]; } + /** + * Helper method to apply dictsort filter on an ObjectValue + */ + private applyDictSort( + operand: ObjectValue, + caseSensitive: BooleanValue, + by: StringValue, + reverse: BooleanValue + ): ArrayValue { + // Convert to array of [key, value] pairs + const items = Array.from(operand.value.entries()).map( + ([key, value]) => new ArrayValue([new StringValue(key), value]) + ); + + // Sort the items + items.sort((a, b) => { + const aItem = a.value[by.value === "key" ? 0 : 1]; + const bItem = b.value[by.value === "key" ? 0 : 1]; + + let aValue: unknown = aItem.value; + let bValue: unknown = bItem.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(); + } + + // Compare values - TypeScript needs help knowing these are comparable + // After the null checks above, we know they're not null + 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); + } + 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 @@ -818,21 +867,14 @@ export class Interpreter { ); case "length": return new IntegerValue(operand.value.size); - case "dictsort": { + case "dictsort": // Default dictsort behavior (no parameters) - // Sort by key, case-insensitive, not reversed - const items = Array.from(operand.value.entries()).map( - ([key, value]) => new ArrayValue([new StringValue(key), value]) + return this.applyDictSort( + operand, + new BooleanValue(false), // case_sensitive + new StringValue("key"), // by + new BooleanValue(false) // reverse ); - - items.sort((a, b) => { - const aKey = (a.value[0] as StringValue).value.toLowerCase(); - const bKey = (b.value[0] as StringValue).value.toLowerCase(); - return aKey < bKey ? -1 : aKey > bKey ? 1 : 0; - }); - - return new ArrayValue(items); - } default: throw new Error(`Unknown ObjectValue filter: ${filter.value}`); } @@ -1041,35 +1083,7 @@ export class Interpreter { throw new Error("reverse must be a boolean"); } - // Convert to array of [key, value] pairs - const items = Array.from(operand.value.entries()).map( - ([key, value]) => new ArrayValue([new StringValue(key), value]) - ); - - // Sort the items - items.sort((a, b) => { - const aItem = a.value[by.value === "key" ? 0 : 1]; - const bItem = b.value[by.value === "key" ? 0 : 1]; - - let aValue: unknown = aItem.value; - let bValue: unknown = bItem.value; - - // For case-insensitive string comparison - if (!caseSensitive.value && typeof aValue === "string" && typeof bValue === "string") { - aValue = aValue.toLowerCase(); - bValue = bValue.toLowerCase(); - } - - // Compare values - if (aValue != null && bValue != null && aValue < bValue) { - return reverse.value ? 1 : -1; - } else if (aValue != null && bValue != null && aValue > bValue) { - return reverse.value ? -1 : 1; - } - return 0; - }); - - return new ArrayValue(items); + return this.applyDictSort(operand, caseSensitive, by, reverse); } } throw new Error(`Unknown ObjectValue filter: ${filterName}`); From 84c9b0cce4e57f277f83f66daeea57c0290da368 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:52:22 +0000 Subject: [PATCH 4/6] Add documentation for type handling in dictsort comparisons Co-authored-by: xenova <26504141+xenova@users.noreply.github.com> --- packages/jinja/src/runtime.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/jinja/src/runtime.ts b/packages/jinja/src/runtime.ts index 4ddc317e1f..4997da6892 100644 --- a/packages/jinja/src/runtime.ts +++ b/packages/jinja/src/runtime.ts @@ -720,8 +720,11 @@ export class Interpreter { bValue = bValue.toLowerCase(); } - // Compare values - TypeScript needs help knowing these are comparable - // After the null checks above, we know they're not null + // Compare values + // Note: This assumes comparable types (string, number, boolean). + // Mixed types (e.g., string vs number) will use JavaScript's default comparison, + // which matches Jinja's behavior. Complex types (objects, arrays) are not typically + // used as dictionary values in Jinja templates and may produce undefined results. const a1 = aValue as string | number | boolean; const b1 = bValue as string | number | boolean; From 85481b01caa63c155ca9b61d0f2b5664a8af9a27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:11:38 +0000 Subject: [PATCH 5/6] Move dictsort to ObjectValue builtins as FunctionValue Co-authored-by: xenova <26504141+xenova@users.noreply.github.com> --- packages/jinja/src/runtime.ts | 190 ++++++++++++++++++---------------- 1 file changed, 99 insertions(+), 91 deletions(-) diff --git a/packages/jinja/src/runtime.ts b/packages/jinja/src/runtime.ts index 4997da6892..8503ef1f5b 100644 --- a/packages/jinja/src/runtime.ts +++ b/packages/jinja/src/runtime.ts @@ -303,6 +303,87 @@ export class ObjectValue extends RuntimeValue> { ["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. + // 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(); + 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 (by.value !== "key" && by.value !== "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 + const items = Array.from(this.value.entries()).map( + ([key, value]) => new ArrayValue([new StringValue(key), value]) + ); + + // Sort the items + items.sort((a, b) => { + const aItem = a.value[by.value === "key" ? 0 : 1]; + const bItem = b.value[by.value === "key" ? 0 : 1]; + + let aValue: unknown = aItem.value; + let bValue: unknown = bItem.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(); + } + + // Compare values + // Note: This assumes comparable types (string, number, boolean). + // Mixed types (e.g., string vs number) will use JavaScript's default comparison, + // which matches Jinja's behavior. Complex types (objects, arrays) are not typically + // used as dictionary values in Jinja templates and may produce undefined results. + 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 { @@ -687,58 +768,6 @@ export class Interpreter { return [positionalArguments, keywordArguments]; } - /** - * Helper method to apply dictsort filter on an ObjectValue - */ - private applyDictSort( - operand: ObjectValue, - caseSensitive: BooleanValue, - by: StringValue, - reverse: BooleanValue - ): ArrayValue { - // Convert to array of [key, value] pairs - const items = Array.from(operand.value.entries()).map( - ([key, value]) => new ArrayValue([new StringValue(key), value]) - ); - - // Sort the items - items.sort((a, b) => { - const aItem = a.value[by.value === "key" ? 0 : 1]; - const bItem = b.value[by.value === "key" ? 0 : 1]; - - let aValue: unknown = aItem.value; - let bValue: unknown = bItem.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(); - } - - // Compare values - // Note: This assumes comparable types (string, number, boolean). - // Mixed types (e.g., string vs number) will use JavaScript's default comparison, - // which matches Jinja's behavior. Complex types (objects, arrays) are not typically - // used as dictionary values in Jinja templates and may produce undefined results. - 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); - } - 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 @@ -870,16 +899,17 @@ export class Interpreter { ); case "length": return new IntegerValue(operand.value.size); - case "dictsort": - // Default dictsort behavior (no parameters) - return this.applyDictSort( - operand, - new BooleanValue(false), // case_sensitive - new StringValue("key"), // by - new BooleanValue(false) // reverse - ); - 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) { @@ -1057,37 +1087,15 @@ export class Interpreter { } throw new Error(`Unknown StringValue filter: ${filterName}`); } else if (operand instanceof ObjectValue) { - switch (filterName) { - case "dictsort": { - // https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.dictsort - // Sort a dictionary and yield (key, value) pairs. - // 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) - - const [args, kwargs] = this.evaluateArguments(filter.args, environment); - - const caseSensitive = args.at(0) ?? kwargs.get("case_sensitive") ?? new BooleanValue(false); - if (!(caseSensitive instanceof BooleanValue)) { - throw new Error("case_sensitive must be a boolean"); - } - - const by = args.at(1) ?? kwargs.get("by") ?? new StringValue("key"); - if (!(by instanceof StringValue)) { - throw new Error("by must be a string"); - } - if (by.value !== "key" && by.value !== "value") { - throw new Error("by must be either 'key' or 'value'"); - } - - const reverse = args.at(2) ?? kwargs.get("reverse") ?? new BooleanValue(false); - if (!(reverse instanceof BooleanValue)) { - throw new Error("reverse must be a boolean"); - } - - return this.applyDictSort(operand, caseSensitive, by, reverse); + // 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 { From 9f169f1aadf381b6818a9b35251232d9a62f00f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:23:38 +0000 Subject: [PATCH 6/6] Reuse index calculation in dictsort comparison Co-authored-by: xenova <26504141+xenova@users.noreply.github.com> --- packages/jinja/src/runtime.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/jinja/src/runtime.ts b/packages/jinja/src/runtime.ts index 8503ef1f5b..7965882780 100644 --- a/packages/jinja/src/runtime.ts +++ b/packages/jinja/src/runtime.ts @@ -348,8 +348,9 @@ export class ObjectValue extends RuntimeValue> { // Sort the items items.sort((a, b) => { - const aItem = a.value[by.value === "key" ? 0 : 1]; - const bItem = b.value[by.value === "key" ? 0 : 1]; + const index = by.value === "key" ? 0 : 1; + const aItem = a.value[index]; + const bItem = b.value[index]; let aValue: unknown = aItem.value; let bValue: unknown = bItem.value;