Skip to content

Commit 1541761

Browse files
authored
Implement jinja formatting (#1368)
This PR introduces a simple `.format` function to the jinja `Template` class, for formatting jinja templates without modifying the output of the template. This mainly concerns indentation, but we also need to ensure that the introduction of this indentation does not modify the template itself.
1 parent 2475d6d commit 1541761

File tree

5 files changed

+468
-6
lines changed

5 files changed

+468
-6
lines changed

packages/jinja/src/format.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import type {
2+
Program,
3+
Statement,
4+
If,
5+
For,
6+
SetStatement,
7+
Macro,
8+
Expression,
9+
MemberExpression,
10+
CallExpression,
11+
Identifier,
12+
NumericLiteral,
13+
StringLiteral,
14+
BooleanLiteral,
15+
ArrayLiteral,
16+
TupleLiteral,
17+
ObjectLiteral,
18+
BinaryExpression,
19+
FilterExpression,
20+
SelectExpression,
21+
TestExpression,
22+
UnaryExpression,
23+
LogicalNegationExpression,
24+
SliceExpression,
25+
KeywordArgumentExpression,
26+
} from "./ast";
27+
28+
const NEWLINE = "\n";
29+
const OPEN_STATEMENT = "{%- ";
30+
const CLOSE_STATEMENT = " -%}";
31+
32+
const OPERATOR_PRECEDENCE: Record<string, number> = {
33+
MultiplicativeBinaryOperator: 2,
34+
AdditiveBinaryOperator: 1,
35+
ComparisonBinaryOperator: 0,
36+
};
37+
38+
export function format(program: Program, indent: string | number = "\t"): string {
39+
const indentStr = typeof indent === "number" ? " ".repeat(indent) : indent;
40+
const body = formatStatements(program.body, 0, indentStr);
41+
return body.replace(/\n$/, "");
42+
}
43+
44+
function createStatement(...text: string[]): string {
45+
return OPEN_STATEMENT + text.join(" ") + CLOSE_STATEMENT;
46+
}
47+
48+
function formatStatements(stmts: Statement[], depth: number, indentStr: string): string {
49+
return stmts.map((stmt) => formatStatement(stmt, depth, indentStr)).join(NEWLINE);
50+
}
51+
52+
function formatStatement(node: Statement, depth: number, indentStr: string): string {
53+
const pad = indentStr.repeat(depth);
54+
switch (node.type) {
55+
case "Program":
56+
return formatStatements((node as Program).body, depth, indentStr);
57+
case "If":
58+
return formatIf(node as If, depth, indentStr);
59+
case "For":
60+
return formatFor(node as For, depth, indentStr);
61+
case "Set":
62+
return formatSet(node as SetStatement, depth, indentStr);
63+
case "Macro":
64+
return formatMacro(node as Macro, depth, indentStr);
65+
default:
66+
return pad + "{{- " + formatExpression(node as Expression) + " -}}";
67+
}
68+
}
69+
70+
function formatIf(node: If, depth: number, indentStr: string): string {
71+
const pad = indentStr.repeat(depth);
72+
73+
const clauses: { test: Expression; body: Statement[] }[] = [];
74+
let current: If | undefined = node;
75+
while (current) {
76+
clauses.push({ test: current.test, body: current.body });
77+
if (current.alternate.length === 1 && current.alternate[0].type === "If") {
78+
current = current.alternate[0] as If;
79+
} else {
80+
break;
81+
}
82+
}
83+
84+
// IF
85+
let out =
86+
pad +
87+
createStatement("if", formatExpression(clauses[0].test)) +
88+
NEWLINE +
89+
formatStatements(clauses[0].body, depth + 1, indentStr);
90+
91+
// ELIF(s)
92+
for (let i = 1; i < clauses.length; i++) {
93+
out +=
94+
NEWLINE +
95+
pad +
96+
createStatement("elif", formatExpression(clauses[i].test)) +
97+
NEWLINE +
98+
formatStatements(clauses[i].body, depth + 1, indentStr);
99+
}
100+
101+
// ELSE
102+
if (current && current.alternate.length > 0) {
103+
out +=
104+
NEWLINE + pad + createStatement("else") + NEWLINE + formatStatements(current.alternate, depth + 1, indentStr);
105+
}
106+
107+
// ENDIF
108+
out += NEWLINE + pad + createStatement("endif");
109+
return out;
110+
}
111+
112+
function formatFor(node: For, depth: number, indentStr: string): string {
113+
const pad = indentStr.repeat(depth);
114+
let formattedIterable = "";
115+
if (node.iterable.type === "SelectExpression") {
116+
// Handle special case: e.g., `for x in [1, 2, 3] if x > 2`
117+
const n = node.iterable as SelectExpression;
118+
formattedIterable = `${formatExpression(n.iterable)} if ${formatExpression(n.test)}`;
119+
} else {
120+
formattedIterable = formatExpression(node.iterable);
121+
}
122+
let out =
123+
pad +
124+
createStatement("for", formatExpression(node.loopvar), "in", formattedIterable) +
125+
NEWLINE +
126+
formatStatements(node.body, depth + 1, indentStr);
127+
128+
if (node.defaultBlock.length > 0) {
129+
out +=
130+
NEWLINE + pad + createStatement("else") + NEWLINE + formatStatements(node.defaultBlock, depth + 1, indentStr);
131+
}
132+
133+
out += NEWLINE + pad + createStatement("endfor");
134+
return out;
135+
}
136+
137+
function formatSet(node: SetStatement, depth: number, indentStr: string): string {
138+
const pad = indentStr.repeat(depth);
139+
const left = formatExpression(node.assignee);
140+
const right = node.value ? formatExpression(node.value) : "";
141+
142+
const value = pad + createStatement("set", `${left}${node.value ? " = " + right : ""}`);
143+
if (node.body.length === 0) {
144+
return value;
145+
}
146+
return (
147+
value + NEWLINE + formatStatements(node.body, depth + 1, indentStr) + NEWLINE + pad + createStatement("endset")
148+
);
149+
}
150+
151+
function formatMacro(node: Macro, depth: number, indentStr: string): string {
152+
const pad = indentStr.repeat(depth);
153+
const args = node.args.map(formatExpression).join(", ");
154+
return (
155+
pad +
156+
createStatement("macro", `${node.name.value}(${args})`) +
157+
NEWLINE +
158+
formatStatements(node.body, depth + 1, indentStr) +
159+
NEWLINE +
160+
pad +
161+
createStatement("endmacro")
162+
);
163+
}
164+
165+
function formatExpression(node: Expression, parentPrec: number = -1): string {
166+
switch (node.type) {
167+
case "Identifier":
168+
return (node as Identifier).value;
169+
case "NullLiteral":
170+
return "none";
171+
case "NumericLiteral":
172+
case "BooleanLiteral":
173+
return `${(node as NumericLiteral | BooleanLiteral).value}`;
174+
case "StringLiteral":
175+
return JSON.stringify((node as StringLiteral).value);
176+
case "BinaryExpression": {
177+
const n = node as BinaryExpression;
178+
const thisPrecedence = OPERATOR_PRECEDENCE[n.operator.type] ?? 0;
179+
const left = formatExpression(n.left, thisPrecedence);
180+
const right = formatExpression(n.right, thisPrecedence + 1);
181+
const expr = `${left} ${n.operator.value} ${right}`;
182+
return thisPrecedence < parentPrec ? `(${expr})` : expr;
183+
}
184+
case "UnaryExpression": {
185+
const n = node as UnaryExpression;
186+
const val = n.operator.value + (n.operator.value === "not" ? " " : "") + formatExpression(n.argument, Infinity);
187+
return val;
188+
}
189+
case "LogicalNegationExpression":
190+
return `not ${formatExpression((node as LogicalNegationExpression).argument, Infinity)}`;
191+
case "CallExpression": {
192+
const n = node as CallExpression;
193+
const args = n.args.map((a) => formatExpression(a, -1)).join(", ");
194+
return `${formatExpression(n.callee, -1)}(${args})`;
195+
}
196+
case "MemberExpression": {
197+
const n = node as MemberExpression;
198+
let obj = formatExpression(n.object, -1);
199+
if (n.object.type !== "Identifier") {
200+
obj = `(${obj})`;
201+
}
202+
let prop = formatExpression(n.property, -1);
203+
if (!n.computed && n.property.type !== "Identifier") {
204+
prop = `(${prop})`;
205+
}
206+
return n.computed ? `${obj}[${prop}]` : `${obj}.${prop}`;
207+
}
208+
case "FilterExpression": {
209+
const n = node as FilterExpression;
210+
const operand = formatExpression(n.operand, Infinity);
211+
if (n.filter.type === "CallExpression") {
212+
return `${operand} | ${formatExpression(n.filter, -1)}`;
213+
}
214+
return `${operand} | ${(n.filter as Identifier).value}`;
215+
}
216+
case "SelectExpression": {
217+
const n = node as SelectExpression;
218+
return `${formatExpression(n.iterable, -1)} | select(${formatExpression(n.test, -1)})`;
219+
}
220+
case "TestExpression": {
221+
const n = node as TestExpression;
222+
return `${formatExpression(n.operand, -1)} is${n.negate ? " not" : ""} ${n.test.value}`;
223+
}
224+
case "ArrayLiteral":
225+
case "TupleLiteral": {
226+
const elems = ((node as ArrayLiteral | TupleLiteral).value as Expression[]).map((e) => formatExpression(e, -1));
227+
const brackets = node.type === "ArrayLiteral" ? "[]" : "()";
228+
return `${brackets[0]}${elems.join(", ")}${brackets[1]}`;
229+
}
230+
case "ObjectLiteral": {
231+
const entries = Array.from((node as ObjectLiteral).value.entries()).map(
232+
([k, v]) => `${formatExpression(k, -1)}: ${formatExpression(v, -1)}`
233+
);
234+
return `{ ${entries.join(", ")} }`;
235+
}
236+
case "SliceExpression": {
237+
const n = node as SliceExpression;
238+
const s = n.start ? formatExpression(n.start, -1) : "";
239+
const t = n.stop ? formatExpression(n.stop, -1) : "";
240+
const st = n.step ? `:${formatExpression(n.step, -1)}` : "";
241+
return `${s}:${t}${st}`;
242+
}
243+
case "KeywordArgumentExpression": {
244+
const n = node as KeywordArgumentExpression;
245+
return `${n.key.value}=${formatExpression(n.value, -1)}`;
246+
}
247+
case "If": {
248+
// Special case for ternary operator (If as an expression, not a statement)
249+
const n = node as If;
250+
const test = formatExpression(n.test, -1);
251+
const body = formatExpression(n.body[0], 0); // Ternary operators have a single body and alternate
252+
const alternate = formatExpression(n.alternate[0], -1);
253+
return `${body} if ${test} else ${alternate}`;
254+
}
255+
default:
256+
throw new Error(`Unknown expression type: ${node.type}`);
257+
}
258+
}

packages/jinja/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Environment, Interpreter } from "./runtime";
1616
import type { Program } from "./ast";
1717
import type { StringValue } from "./runtime";
1818
import { range } from "./utils";
19+
import { format } from "./format";
1920

2021
export class Template {
2122
parsed: Program;
@@ -55,6 +56,10 @@ export class Template {
5556
const result = interpreter.run(this.parsed) as StringValue;
5657
return result.value;
5758
}
59+
60+
format(options?: { indent: string | number }): string {
61+
return format(this.parsed, options?.indent || "\t");
62+
}
5863
}
5964

6065
export { Environment, Interpreter, tokenize, parse };

packages/jinja/src/runtime.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,32 @@ export class StringValue extends RuntimeValue<string> {
117117
return new StringValue(this.value.trimStart());
118118
}),
119119
],
120+
[
121+
"startswith",
122+
new FunctionValue((args) => {
123+
if (args.length === 0) {
124+
throw new Error("startswith() requires at least one argument");
125+
}
126+
const prefix = args[0];
127+
if (!(prefix instanceof StringValue)) {
128+
throw new Error("startswith() argument must be a string");
129+
}
130+
return new BooleanValue(this.value.startsWith(prefix.value));
131+
}),
132+
],
133+
[
134+
"endswith",
135+
new FunctionValue((args) => {
136+
if (args.length === 0) {
137+
throw new Error("endswith() requires at least one argument");
138+
}
139+
const suffix = args[0];
140+
if (!(suffix instanceof StringValue)) {
141+
throw new Error("endswith() argument must be a string");
142+
}
143+
return new BooleanValue(this.value.endsWith(suffix.value));
144+
}),
145+
],
120146
[
121147
"split",
122148
// follows Python's `str.split(sep=None, maxsplit=-1)` function behavior

0 commit comments

Comments
 (0)