Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions examples/pycompat-rendering/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "pycompat-rendering"
version = "0.1.0"
edition = "2021"

[dependencies]
minijinja = { path = "../../minijinja" }
37 changes: 37 additions & 0 deletions examples/pycompat-rendering/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Python-Compatible Rendering Example

This example demonstrates MiniJinja's Python-compatible rendering mode.

## Background

By default, MiniJinja renders values in its own style:
- Booleans: `true`/`false`
- None: `none`
- Strings: Rust-style escaping and quoting

When Python compatibility is needed (e.g., for SQL templating where precise output matters), you can enable PyCompat mode to match Python Jinja2's output:
- Booleans: `True`/`False`
- None: `None`
- Strings: Python-style escaping and quoting

## Usage

```rust
use minijinja::Environment;

let mut env = Environment::new();

// Enable Python-compatible rendering
env.set_pycompat_rendering(true);

// Now templates will render values like Python Jinja2
let tmpl = env.template_from_str("{{ [true, false, none, 'hello'] }}")?;
let result = tmpl.render(minijinja::context!{})?;
// Output: [True, False, None, 'hello']
```

## Running this Example

```bash
cargo run --example pycompat-rendering
```
72 changes: 72 additions & 0 deletions examples/pycompat-rendering/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
use minijinja::{context, Environment};
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We really don't need this example here.


fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== MiniJinja Rendering Comparison ===");

// Create environments for both modes to avoid borrowing issues
let mut env_default = Environment::new();
let mut env_pycompat = Environment::new();

// Add the same template to both environments
let template_str = r#"{{ [true, false, none, 'foo', "bar'baz", '\x13'] }}"#;
env_default.add_template("demo", template_str)?;
env_pycompat.add_template("demo", template_str)?;

// Configure rendering modes
env_default.set_pycompat_rendering(false);
env_pycompat.set_pycompat_rendering(true);

// Test rendering
let result_default = env_default.get_template("demo")?.render(context! {})?;
let result_pycompat = env_pycompat.get_template("demo")?.render(context! {})?;

println!("Default rendering: {}", result_default);
println!("PyCompat rendering: {}", result_pycompat);

println!("\n=== Individual Value Comparison ===");

// Test individual values
env_default.add_template("bool_true", "{{ true }}")?;
env_default.add_template("bool_false", "{{ false }}")?;
env_default.add_template("none_val", "{{ none }}")?;

env_pycompat.add_template("bool_true", "{{ true }}")?;
env_pycompat.add_template("bool_false", "{{ false }}")?;
env_pycompat.add_template("none_val", "{{ none }}")?;

println!("Default mode:");
println!(
" true -> {}",
env_default.get_template("bool_true")?.render(context! {})?
);
println!(
" false -> {}",
env_default
.get_template("bool_false")?
.render(context! {})?
);
println!(
" none -> {}",
env_default.get_template("none_val")?.render(context! {})?
);

println!("PyCompat mode:");
println!(
" true -> {}",
env_pycompat
.get_template("bool_true")?
.render(context! {})?
);
println!(
" false -> {}",
env_pycompat
.get_template("bool_false")?
.render(context! {})?
);
println!(
" none -> {}",
env_pycompat.get_template("none_val")?.render(context! {})?
);

Ok(())
}
21 changes: 21 additions & 0 deletions minijinja/src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub struct Environment<'source> {
#[cfg(feature = "fuel")]
fuel: Option<u64>,
recursion_limit: usize,
pycompat_rendering: bool,
}

impl Default for Environment<'_> {
Expand Down Expand Up @@ -111,6 +112,7 @@ impl<'source> Environment<'source> {
#[cfg(feature = "fuel")]
fuel: None,
recursion_limit: MAX_RECURSION,
pycompat_rendering: false,
}
}

Expand All @@ -133,6 +135,7 @@ impl<'source> Environment<'source> {
#[cfg(feature = "fuel")]
fuel: None,
recursion_limit: MAX_RECURSION,
pycompat_rendering: false,
}
}

Expand Down Expand Up @@ -576,6 +579,24 @@ impl<'source> Environment<'source> {
self.debug
}

/// Enables or disables Python-compatible rendering mode.
///
/// When enabled, certain value types will be rendered to match Python Jinja2's output:
/// - `true`/`false` will be rendered as `True`/`False`
/// - `none` will be rendered as `None`
/// - String escaping and quoting will match Python's style
///
/// This is useful when you need template output that's compatible with Python Jinja2.
/// By default this is disabled.
pub fn set_pycompat_rendering(&mut self, enabled: bool) {
self.pycompat_rendering = enabled;
}

/// Returns whether Python-compatible rendering mode is enabled.
pub fn pycompat_rendering(&self) -> bool {
self.pycompat_rendering
}

/// Sets the optional fuel of the engine.
///
/// When MiniJinja is compiled with the `fuel` feature then every
Expand Down
55 changes: 55 additions & 0 deletions minijinja/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ impl Unescaper {
let val = ok!(self.parse_u16(&mut char_iter));
ok!(self.push_u16(val));
}
'x' => {
let val = ok!(self.parse_hex_byte(&mut char_iter));
ok!(self.push_char(val as char));
}
'0'..='7' => {
let val = ok!(self.parse_octal_byte(d, &mut char_iter));
ok!(self.push_char(val as char));
}
_ => return Err(ErrorKind::BadEscape.into()),
},
}
Expand All @@ -297,6 +305,33 @@ impl Unescaper {
u16::from_str_radix(&hexnum, 16).map_err(|_| ErrorKind::BadEscape.into())
}

fn parse_hex_byte(&self, chars: &mut Chars) -> Result<u8, Error> {
let hexnum = chars.chain(repeat('\0')).take(2).collect::<String>();
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably error if the sequence is just \x1

u8::from_str_radix(&hexnum, 16).map_err(|_| ErrorKind::BadEscape.into())
}

fn parse_octal_byte(&self, first_digit: char, chars: &mut Chars) -> Result<u8, Error> {
let mut octal_str = String::new();
octal_str.push(first_digit);

// Collect up to 2 more octal digits (0-7)
for _ in 0..2 {
let next_char = chars.as_str().chars().next();
if let Some(c) = next_char {
if ('0'..='7').contains(&c) {
octal_str.push(c);
chars.next(); // consume the character
} else {
break;
}
} else {
break;
}
}

u8::from_str_radix(&octal_str, 8).map_err(|_| ErrorKind::BadEscape.into())
}

fn push_u16(&mut self, c: u16) -> Result<(), Error> {
match (self.pending_surrogate, (0xD800..=0xDFFF).contains(&c)) {
(0, false) => match decode_utf16(once(c)).next() {
Expand Down Expand Up @@ -450,6 +485,26 @@ mod tests {
assert_eq!(unescape(r"\t\b\f\r\n\\\/").unwrap(), "\t\x08\x0c\r\n\\/");
assert_eq!(unescape("foobarbaz").unwrap(), "foobarbaz");
assert_eq!(unescape(r"\ud83d\udca9").unwrap(), "💩");

// Test new escape sequences
assert_eq!(unescape(r"\0").unwrap(), "\0");
assert_eq!(unescape(r"foo\0bar").unwrap(), "foo\0bar");
assert_eq!(unescape(r"\x00").unwrap(), "\0");
assert_eq!(unescape(r"\x42").unwrap(), "B");
assert_eq!(unescape(r"\xab").unwrap(), "\u{ab}");
assert_eq!(unescape(r"foo\x42bar").unwrap(), "fooBbar");
assert_eq!(unescape(r"\x0a").unwrap(), "\n");
assert_eq!(unescape(r"\x0d").unwrap(), "\r");

// Test octal escape sequences
assert_eq!(unescape(r"\0").unwrap(), "\0"); // octal 0 = null
assert_eq!(unescape(r"\1").unwrap(), "\x01"); // octal 1 = SOH
assert_eq!(unescape(r"\12").unwrap(), "\n"); // octal 12 = 10 decimal = LF
assert_eq!(unescape(r"\123").unwrap(), "S"); // octal 123 = 83 decimal = 'S'
assert_eq!(unescape(r"\141").unwrap(), "a"); // octal 141 = 97 decimal = 'a'
assert_eq!(unescape(r"\177").unwrap(), "\x7f"); // octal 177 = 127 decimal = DEL
assert_eq!(unescape(r"foo\123bar").unwrap(), "fooSbar"); // 'S' in the middle
assert_eq!(unescape(r"\101\102\103").unwrap(), "ABC"); // octal for A, B, C
}

#[test]
Expand Down
Loading
Loading