Skip to content

Commit 23d1508

Browse files
Implement {% for %} tag
1 parent 4432927 commit 23d1508

21 files changed

Lines changed: 1085 additions & 277 deletions

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ crate-type = ["cdylib"]
1111
pyo3 = { version = "0.28.0", features = ["num-bigint"] }
1212
regex = "1.11.2"
1313
oxc = { version = "0.98.0", features = ["full"] }
14-
dtl-lexer = { git = "https://github.com/LilyFirefly/django-rusty-templates", rev = "94bb0a45da6f388ea516ca2e3a7d71825e9b1068" }
14+
dtl-lexer = { git = "https://github.com/LilyFirefly/django-rusty-templates", rev = "c0a5c0fc66ce7bf3b0ae21cf31b57a20bfee5f5e" }
1515
num-bigint = "0.4.6"

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "django-template-transpiler"
7-
requires-python = ">=3.10"
7+
requires-python = ">=3.12"
88
classifiers = [
99
"Programming Language :: Rust",
1010
"Programming Language :: Python :: Implementation :: CPython",
@@ -18,10 +18,14 @@ python-source = "python"
1818

1919
[tool.pytest.ini_options]
2020
DJANGO_SETTINGS_MODULE = "tests.settings"
21+
django_debug_mode = "true"
22+
addopts = ["--import-mode=importlib"]
23+
pythonpath = ["tests"]
24+
2125

2226
[dependency-groups]
2327
dev = [
24-
"django",
28+
"django>6",
2529
"maturin",
2630
"pytest",
2731
"pytest-django",

python/django_template_transpiler/data/core.js

Lines changed: 147 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/**
2+
* @callback RenderFunction
3+
* @param {Engine} engine
4+
* @param {Context} context
5+
*/
6+
17
function todo() {
28
throw new Error("Not implemented")
39
}
@@ -6,33 +12,37 @@ const None = Symbol.for("None")
612
const True = true
713
const False = false
814

15+
const VarNotFound = ""
916

1017
class Context {
18+
#entries = new Map()
19+
/** @type {Map<string,any>|Context}*/
20+
#parentContext = new Map(Object.entries({None, True, False}))
21+
1122
constructor(dict, parentContext = undefined) {
12-
this._entries = new Map(Object.entries(dict))
13-
14-
this.parentContext = (
15-
parentContext instanceof Context
16-
? parentContext
17-
: new Proxy(new Map(Object.entries({None, True, False})), {
18-
get(target, prop) {
19-
const value = target.get(prop)
20-
return value !== undefined ? value : ""
21-
}
22-
})
23-
)
24-
25-
return new Proxy(this, {
26-
get(target, prop) {
27-
const value = target._entries.get(prop)
28-
return value !== undefined ? value : target.parentContext[prop]
29-
}
30-
})
23+
this.#entries = new Map(Object.entries(dict))
24+
if (parentContext instanceof Context) {
25+
this.#parentContext = parentContext
26+
}
3127
}
3228

3329
extend(dict) {
3430
return new Context(dict, this)
3531
}
32+
33+
has(key) {
34+
return this.#entries.has(key) || this.#parentContext.has(key)
35+
}
36+
37+
get(key) {
38+
if (this.#entries.has(key)) {
39+
return this.#entries.get(key)
40+
}
41+
if (this.#parentContext.has(key)) {
42+
return this.#parentContext.get(key)
43+
}
44+
return VarNotFound
45+
}
3646
}
3747

3848

@@ -64,10 +74,6 @@ function escape(value) {
6474
return SafeString.isSafe(value) ? value : new Option(String(value)).innerHTML
6575
}
6676

67-
function toNumber(value) {
68-
return Number.isFinite(value) ? value : parseFloat(String(value))
69-
}
70-
7177
function toInteger(value) {
7278
const result = parseInt(value, 10)
7379
if (!Number.isFinite(result)) {
@@ -77,7 +83,8 @@ function toInteger(value) {
7783
}
7884

7985
class Filters {
80-
constructor() {
86+
constructor(engine) {
87+
this.engine = engine
8188
return new Proxy(Object.freeze(this), {
8289
get(target, prop) {
8390
const result = target[prop]
@@ -347,18 +354,93 @@ class Filters {
347354
}
348355
}
349356

357+
358+
class Tags {
359+
#engine
360+
361+
constructor(engine) {
362+
this.#engine = engine
363+
364+
return new Proxy(Object.freeze(this), {
365+
get(target, prop, receiver) {
366+
if (Reflect.has(target, prop)) {
367+
return Reflect.get(target, prop, receiver)
368+
}
369+
if (Reflect.has(target, `do_${prop}`)) {
370+
return Reflect.get(target, `do_${prop}`, receiver).bind(target)
371+
}
372+
throw new Error(`Unknown tag: ${prop}`)
373+
}
374+
})
375+
}
376+
377+
/**
378+
*
379+
* @param params
380+
* @param {Context} params.context
381+
* @param {Iterable} params.iter
382+
* @param {function(any): Object<string, any>} params.forloopVariables
383+
* @param {boolean} params.reversed
384+
* @param {RenderFunction} params.render
385+
* @param {RenderFunction | null} params.emptyRender
386+
* @returns {string}
387+
*/
388+
do_for({context, iter, forloopVariables, render, emptyRender = null, reversed = false}) {
389+
const parentloop = context.get("forloop") || None
390+
const container = Array.from(iter)
391+
392+
function getContext(counter, value) {
393+
return context.extend({
394+
...forloopVariables(value),
395+
forloop: {
396+
get counter() {
397+
return counter + 1
398+
},
399+
get counter0() {
400+
return counter
401+
},
402+
get revcounter() {
403+
return container.length - counter
404+
},
405+
get revcounter0() {
406+
return container.length - counter - 1
407+
},
408+
get first() {
409+
return counter === 0
410+
},
411+
get last() {
412+
return counter === container.length - 1
413+
},
414+
get length() {
415+
return container.length
416+
},
417+
parentloop,
418+
}
419+
})
420+
}
421+
422+
if (container.length === 0) {
423+
return emptyRender?.call(null, this.#engine, context) ?? VarNotFound
424+
}
425+
const reducer = reversed ? container.reduceRight.bind(container) : container.reduce.bind(container)
426+
return reducer((acc, value, idx) => acc + render(this.#engine, getContext(idx, value)), "")
427+
}
428+
}
429+
350430
const utils = Object.freeze({
351431
contains(first, second) {
352432
return Array.from(first).contains(second)
353433
},
354434
do_is(first, second) {
355435
return first === second
356-
}
436+
},
357437
})
358438

359439
class Engine {
440+
#tags = new Tags(this)
441+
#filters = new Filters(this)
442+
360443
constructor() {
361-
this._filters = new Filters()
362444
this._ = utils
363445
}
364446

@@ -371,25 +453,56 @@ class Engine {
371453
}
372454

373455
/**
374-
* @param {string} varName
375-
* @param {any} literal
456+
* @param {string[]|undefined} varNames
457+
* @param {any|undefined} literal
376458
* @param {Context} context
377-
* @param {Filters} filters
378-
* @returns {string}
459+
* @param {string[]} filters
460+
* @returns {any}
379461
*/
380-
variable({varName, literal, context, filters}) {
381-
let value = literal || context[varName]
462+
variable({varNames = [], literal = undefined, context, filters}) {
463+
if (literal !== undefined) {
464+
return `${literal}`
465+
}
466+
467+
let target = context
468+
for (let varName of varNames) {
469+
const orig = target
470+
if (target instanceof Context || target instanceof Map) {
471+
target = target.get(varName)
472+
} else if ((target instanceof Set && target.has(varName)) || Object.hasOwn(target, varName)) {
473+
target = target[varName]
474+
} else {
475+
target = VarNotFound
476+
}
477+
478+
if (typeof target === "function") {
479+
target = target.call(orig)
480+
} else if (target === null || target === undefined) {
481+
target = VarNotFound
482+
}
483+
}
484+
382485
for (const {filterName, argument} of filters) {
383-
value = escape(this._filters[filterName](value, argument))
486+
target = this.#filters[filterName](target, argument)
384487
}
385-
return value
488+
489+
return target
490+
}
491+
492+
escape(value) {
493+
return escape(`${value}`)
494+
}
495+
496+
tag({tagName, args, context}) {
497+
return this.#tags[tagName]({...args, context})
386498
}
387499

388500
translate(value) {
389501
console.warn("Translation not implemented")
390502
return value
391503
}
392504

505+
393506
}
394507

395508
export default Engine

src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod parser;
2+
mod tag_for;
23
mod tag_if;
34
mod utils;
45
mod variable;
@@ -23,3 +24,24 @@ pub mod django_template_transpiler {
2324
Ok(crate::transpile(raw_template))
2425
}
2526
}
27+
28+
#[cfg(test)]
29+
pub(crate) mod tests {
30+
use crate::parser::Parser;
31+
use dtl_lexer::types::TemplateString;
32+
use oxc::allocator::Allocator;
33+
use regex::Regex;
34+
35+
pub(crate) fn render_template(template: &str) -> String {
36+
let allocator = Allocator::default();
37+
Parser::new(&allocator, TemplateString(template)).render()
38+
}
39+
40+
pub(crate) fn assert_template(expected: &str, template: &str) {
41+
let process_regex = Regex::new(r"[\s\n]+").unwrap();
42+
assert_eq!(
43+
process_regex.replace_all(expected.trim(), " "),
44+
process_regex.replace_all(render_template(template).trim(), " ")
45+
);
46+
}
47+
}

src/parser.rs

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::tag_for::ParseFor;
12
use crate::tag_if::ParseIf;
23
use crate::utils::{SimplerAstMethods, SomeWrap};
34
use crate::variable::parse_variable;
@@ -72,10 +73,13 @@ impl<'t> Parser<'t> {
7273
None,
7374
),
7475
TokenType::Comment => continue,
75-
TokenType::Variable => parse_variable(
76-
&self.ast_builder,
77-
self.template,
78-
(token.at.0 + START_TAG_LEN, token.at.1 - 2 * START_TAG_LEN),
76+
TokenType::Variable => self.ast_builder.expression_call_simple(
77+
["engine", "escape"],
78+
vec![parse_variable(
79+
&self.ast_builder,
80+
self.template,
81+
(token.at.0 + START_TAG_LEN, token.at.1 - 2 * START_TAG_LEN),
82+
)],
7983
),
8084
TokenType::Tag => {
8185
let tag =
@@ -121,8 +125,9 @@ impl<'t> Parser<'t> {
121125

122126
match tag_name {
123127
"if" => self.parse_if(tag),
128+
"for" => self.parse_for(tag),
124129
"autoescape" | "block" | "comment" | "csrf_token" | "cycle" | "debug" | "filter"
125-
| "firstof" | "for" | "ifchanged" | "" | "load" | "lorem" | "now" | "partial"
130+
| "firstof" | "ifchanged" | "" | "load" | "lorem" | "now" | "partial"
126131
| "partialdef" | "querystring" | "regroup" | "resetcycle" | "spaceless"
127132
| "templatetag" | "url" | "verbatim" | "widthratio" | "with" => {
128133
todo!("{tag_name} not implemented yet")
@@ -177,23 +182,7 @@ impl<'t> Parser<'t> {
177182

178183
#[cfg(test)]
179184
mod tests {
180-
use crate::parser::Parser;
181-
use dtl_lexer::types::TemplateString;
182-
use oxc::allocator::Allocator;
183-
use regex::Regex;
184-
185-
fn render_template(template: &str) -> String {
186-
let allocator = Allocator::default();
187-
Parser::new(&allocator, TemplateString(template)).render()
188-
}
189-
190-
fn assert_template(expected: &str, template: &str) {
191-
let process_regex = Regex::new(r"[\s\n]+").unwrap();
192-
assert_eq!(
193-
process_regex.replace_all(expected.trim(), " "),
194-
process_regex.replace_all(render_template(template).trim(), " ")
195-
);
196-
}
185+
use crate::tests::{assert_template, render_template};
197186

198187
#[test]
199188
fn test_empty() {

0 commit comments

Comments
 (0)