From 312f7abde871e5a1278e084c7384896a0a11b16e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 28 Aug 2025 13:30:34 +0200 Subject: [PATCH] Added tuple support --- minijinja/src/compiler/ast.rs | 32 ++++++++ minijinja/src/compiler/codegen.rs | 7 ++ minijinja/src/compiler/instructions.rs | 3 + minijinja/src/compiler/meta.rs | 2 + minijinja/src/compiler/parser.rs | 11 ++- minijinja/src/value/argtypes.rs | 75 +++++++++++++++++++ minijinja/src/value/mod.rs | 4 +- minijinja/src/vm/mod.rs | 10 +++ .../test_parser__parser@set.txt.snap | 2 +- .../test_parser__parser@tuples.txt.snap | 6 +- 10 files changed, 141 insertions(+), 11 deletions(-) diff --git a/minijinja/src/compiler/ast.rs b/minijinja/src/compiler/ast.rs index b55e4ba5f..b63b86ad4 100644 --- a/minijinja/src/compiler/ast.rs +++ b/minijinja/src/compiler/ast.rs @@ -142,6 +142,7 @@ pub enum Expr<'a> { GetItem(Spanned>), Call(Spanned>), List(Spanned>), + Tuple(Spanned>), Map(Spanned>), } @@ -161,6 +162,7 @@ impl fmt::Debug for Expr<'_> { Expr::GetItem(s) => fmt::Debug::fmt(s, f), Expr::Call(s) => fmt::Debug::fmt(s, f), Expr::List(s) => fmt::Debug::fmt(s, f), + Expr::Tuple(s) => fmt::Debug::fmt(s, f), Expr::Map(s) => fmt::Debug::fmt(s, f), } } @@ -179,6 +181,7 @@ impl Expr<'_> { | Expr::GetItem(_) => "expression", Expr::Call(_) => "call", Expr::List(_) => "list literal", + Expr::Tuple(_) => "tuple literal", Expr::Map(_) => "map literal", Expr::Test(_) => "test expression", Expr::Filter(_) => "filter expression", @@ -199,6 +202,7 @@ impl Expr<'_> { Expr::GetItem(s) => s.span(), Expr::Call(s) => s.span(), Expr::List(s) => s.span(), + Expr::Tuple(s) => s.span(), Expr::Map(s) => s.span(), } } @@ -207,6 +211,7 @@ impl Expr<'_> { match self { Expr::Const(c) => Some(c.value.clone()), Expr::List(l) => l.as_const(), + Expr::Tuple(t) => t.as_const(), Expr::Map(m) => m.as_const(), Expr::UnaryOp(c) => match c.op { UnaryOpKind::Not => c.expr.as_const().map(|value| Value::from(!value.is_true())), @@ -567,6 +572,33 @@ impl List<'_> { } } +/// Creates a tuple of values. +#[cfg_attr(feature = "internal_debug", derive(Debug))] +#[cfg_attr(feature = "unstable_machinery_serde", derive(serde::Serialize))] +pub struct Tuple<'a> { + pub items: Vec>, +} + +impl Tuple<'_> { + pub fn as_const(&self) -> Option { + use crate::value::Tuple as ValueTuple; + + if !self.items.iter().all(|x| matches!(x, Expr::Const(_))) { + return None; + } + + let items = self.items.iter(); + let sequence = items.filter_map(|expr| match expr { + Expr::Const(v) => Some(v.value.clone()), + _ => None, + }); + + Some(Value::from_object(ValueTuple::from( + sequence.collect::>(), + ))) + } +} + /// Creates a map of values. #[cfg_attr(feature = "internal_debug", derive(Debug))] #[cfg_attr(feature = "unstable_machinery_serde", derive(serde::Serialize))] diff --git a/minijinja/src/compiler/codegen.rs b/minijinja/src/compiler/codegen.rs index c55c4fd63..46189584a 100644 --- a/minijinja/src/compiler/codegen.rs +++ b/minijinja/src/compiler/codegen.rs @@ -714,6 +714,13 @@ impl<'source> CodeGenerator<'source> { } self.add(Instruction::BuildList(Some(l.items.len()))); } + ast::Expr::Tuple(t) => { + self.set_line_from_span(t.span()); + for item in &t.items { + self.compile_expr(item); + } + self.add(Instruction::BuildTuple(Some(t.items.len()))); + } ast::Expr::Map(m) => { self.set_line_from_span(m.span()); assert_eq!(m.keys.len(), m.values.len()); diff --git a/minijinja/src/compiler/instructions.rs b/minijinja/src/compiler/instructions.rs index 37ac8a817..142c1b17a 100644 --- a/minijinja/src/compiler/instructions.rs +++ b/minijinja/src/compiler/instructions.rs @@ -66,6 +66,9 @@ pub enum Instruction<'source> { /// Builds a list of the last n pairs on the stack. BuildList(Option), + /// Builds a tuple of the last n pairs on the stack. + BuildTuple(Option), + /// Unpacks a list into N stack items. UnpackList(usize), diff --git a/minijinja/src/compiler/meta.rs b/minijinja/src/compiler/meta.rs index 261dec031..a1d204dd6 100644 --- a/minijinja/src/compiler/meta.rs +++ b/minijinja/src/compiler/meta.rs @@ -186,6 +186,7 @@ fn tracker_visit_expr<'a>(expr: &ast::Expr<'a>, state: &mut AssignmentTracker<'a .for_each(|x| tracker_visit_callarg(x, state)); } ast::Expr::List(expr) => expr.items.iter().for_each(|x| tracker_visit_expr(x, state)), + ast::Expr::Tuple(expr) => expr.items.iter().for_each(|x| tracker_visit_expr(x, state)), ast::Expr::Map(expr) => expr.keys.iter().zip(expr.values.iter()).for_each(|(k, v)| { tracker_visit_expr(k, state); tracker_visit_expr(v, state); @@ -197,6 +198,7 @@ fn track_assign<'a>(expr: &ast::Expr<'a>, state: &mut AssignmentTracker<'a>) { match expr { ast::Expr::Var(var) => state.assign(var.id), ast::Expr::List(list) => list.items.iter().for_each(|x| track_assign(x, state)), + ast::Expr::Tuple(tuple) => tuple.items.iter().for_each(|x| track_assign(x, state)), _ => {} } } diff --git a/minijinja/src/compiler/parser.rs b/minijinja/src/compiler/parser.rs index d7a04b48b..5bc403af4 100644 --- a/minijinja/src/compiler/parser.rs +++ b/minijinja/src/compiler/parser.rs @@ -716,11 +716,10 @@ impl<'a> Parser<'a> { } fn parse_tuple_or_expression(&mut self, span: Span) -> Result, Error> { - // MiniJinja does not really have tuples, but it treats the tuple - // syntax the same as lists. + // Parse tuple expressions as actual tuples now if skip_token!(self, Token::ParenClose) { - return Ok(ast::Expr::List(Spanned::new( - ast::List { items: vec![] }, + return Ok(ast::Expr::Tuple(Spanned::new( + ast::Tuple { items: vec![] }, self.stream.expand_span(span), ))); } @@ -737,8 +736,8 @@ impl<'a> Parser<'a> { } items.push(ok!(self.parse_expr())); } - expr = ast::Expr::List(Spanned::new( - ast::List { items }, + expr = ast::Expr::Tuple(Spanned::new( + ast::Tuple { items }, self.stream.expand_span(span), )); } else { diff --git a/minijinja/src/value/argtypes.rs b/minijinja/src/value/argtypes.rs index cd82883c8..855fca9a5 100644 --- a/minijinja/src/value/argtypes.rs +++ b/minijinja/src/value/argtypes.rs @@ -690,6 +690,81 @@ impl<'a, T: ArgType<'a, Output = T>> ArgType<'a> for Rest { } } +/// A tuple wrapper that renders as Python-style tuples. +/// +/// This type works exactly like `Vec` functionally but when +/// rendered it displays as `(item1, item2, ...)` instead of +/// `[item1, item2, ...]`. It's used internally to represent +/// tuple expressions from templates. +/// +/// ``` +/// # use minijinja::value::{Value, Tuple}; +/// let tuple = Tuple::from(vec![Value::from(1), Value::from(2)]); +/// let value = Value::from_object(tuple); +/// assert_eq!(value.to_string(), "(1, 2)"); +/// ``` +#[derive(Debug, Clone)] +pub struct Tuple(pub Vec); + +impl From> for Tuple { + fn from(vec: Vec) -> Self { + Tuple(vec) + } +} + +impl From<&[Value]> for Tuple { + fn from(slice: &[Value]) -> Self { + Tuple(slice.to_vec()) + } +} + +impl Deref for Tuple { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Tuple { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Object for Tuple { + fn repr(self: &Arc) -> ObjectRepr { + ObjectRepr::Seq + } + + fn get_value(self: &Arc, key: &Value) -> Option { + key.as_usize().and_then(|i| self.0.get(i).cloned()) + } + + fn enumerate(self: &Arc) -> Enumerator { + Enumerator::Seq(self.0.len()) + } + + fn enumerator_len(self: &Arc) -> Option { + Some(self.0.len()) + } + + fn render(self: &Arc, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("(")?; + for (idx, value) in self.0.iter().enumerate() { + if idx > 0 { + f.write_str(", ")?; + } + std::fmt::Display::fmt(value, f)?; + } + // Special case: single item tuple needs trailing comma + if self.0.len() == 1 { + f.write_str(",")?; + } + f.write_str(")") + } +} + /// Utility to accept keyword arguments. /// /// Keyword arguments are represented as regular values as the last argument diff --git a/minijinja/src/value/mod.rs b/minijinja/src/value/mod.rs index 0232dfbab..bca072c3e 100644 --- a/minijinja/src/value/mod.rs +++ b/minijinja/src/value/mod.rs @@ -216,7 +216,9 @@ use crate::value::ops::as_f64; use crate::value::serialize::transform; use crate::vm::State; -pub use crate::value::argtypes::{from_args, ArgType, FunctionArgs, FunctionResult, Kwargs, Rest}; +pub use crate::value::argtypes::{ + from_args, ArgType, FunctionArgs, FunctionResult, Kwargs, Rest, Tuple, +}; pub use crate::value::merge_object::merge_maps; pub use crate::value::object::{DynObject, Enumerator, Object, ObjectExt, ObjectRepr}; diff --git a/minijinja/src/vm/mod.rs b/minijinja/src/vm/mod.rs index b03fd8e84..c6fc48a01 100644 --- a/minijinja/src/vm/mod.rs +++ b/minijinja/src/vm/mod.rs @@ -403,6 +403,16 @@ impl<'env> Vm<'env> { v.reverse(); stack.push(Value::from_object(v)) } + Instruction::BuildTuple(n) => { + use crate::value::Tuple; + let count = n.unwrap_or_else(|| stack.pop().try_into().unwrap()); + let mut v = Vec::with_capacity(untrusted_size_hint(count)); + for _ in 0..count { + v.push(stack.pop()); + } + v.reverse(); + stack.push(Value::from_object(Tuple::from(v))) + } Instruction::UnpackList(count) => { ctx_ok!(self.unpack_list(&mut stack, *count)); } diff --git a/minijinja/tests/snapshots/test_parser__parser@set.txt.snap b/minijinja/tests/snapshots/test_parser__parser@set.txt.snap index a02ae650b..1450545d7 100644 --- a/minijinja/tests/snapshots/test_parser__parser@set.txt.snap +++ b/minijinja/tests/snapshots/test_parser__parser@set.txt.snap @@ -28,7 +28,7 @@ Ok( } @ 2:11-2:12, ], } @ 2:8-2:12, - expr: List { + expr: Tuple { items: [ Const { value: 1, diff --git a/minijinja/tests/snapshots/test_parser__parser@tuples.txt.snap b/minijinja/tests/snapshots/test_parser__parser@tuples.txt.snap index ab24940d2..28317a1b1 100644 --- a/minijinja/tests/snapshots/test_parser__parser@tuples.txt.snap +++ b/minijinja/tests/snapshots/test_parser__parser@tuples.txt.snap @@ -25,7 +25,7 @@ Ok( raw: "\n", } @ 1:15-2:0, EmitExpr { - expr: List { + expr: Tuple { items: [ Const { value: 1, @@ -43,7 +43,7 @@ Ok( raw: "\n", } @ 2:15-3:0, EmitExpr { - expr: List { + expr: Tuple { items: [ Const { value: 1, @@ -55,7 +55,7 @@ Ok( raw: "\n", } @ 3:10-4:0, EmitExpr { - expr: List { + expr: Tuple { items: [], } @ 4:3-4:5, } @ 4:0-4:5,