Repository: mitsuhiko/minijinja (language: rust)

Issue:
Support using custom delimiters
Hi ! First of all thanks for this awesome lib !

Do you have any plans on implementing a way to provide custom delimiters ? Either at compile time or at runtime.

This feature does not exist in Tera, and Askama’s approach makes it impossi ble to have runtime provided templates. I am writing a CLI to parse and render jinja2 templates using `[[ … ]]`, `[% … %]` and `[# … #]` to avoid conflicting with other templating languages used in the files I am processing.

Thanks in advance !


Discussion / hints:
Hi @mitsuhiko . Thanks for taking interest in my request. Any news on this feature ?
@NikolaLohinski i looked into it recently again and it's quite a pain to implement and I wasn't sure if this feature is really that useful since editors also won't be able to handle custom Jinja delimiters. I just added raw tags, could they be helpful instead? (#69).
Hi @mitsuhiko thanks for answering.

Regarding the raw tag : it would not help me because it would require me to escape every piece of conflicting delimiter every time I encounter them and there is lot.

Regarding editors : I don't understand how this is relevant to the feature itself. For sure most editors won't be able to parse jinja files with other delimiters but usually (at least for me), you want the file to be highlighted with the editor config for the underlying language, not for jinja itself. Should you have to template Python files (for whatever weird reason), you'd want the editor to show Python, not plain text with Jinja. My personal use case is to template HCL files.

Finally, if you consider it hurtful to implement, I can definitely look into it and take a shot at it if you would be so kind to accept a PR, and ideally give some thoughts on how you'd envision it.
I would be open to a PR if it does not pull in a new dependency. There are however definitely some API limitations right now that would have to be worked with. For one a new `Settings` object or similar could be added that's passed between the environment, template and the `meta` API and the lexer and other internals.

The tricky bit will be that using `memstr` internally will no longer work. For efficiently parsing arbitrary delimiters one would probably want to use something like aho-corasick except i don't want to add a dependency. If aho-corasick turns out useful, it could be added as an optional feature with the extra dependency.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e549714..14dd5ca2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ All notable changes to MiniJinja are documented here.
 
 - Added `Value::is_number`. (#240)
 - `TryFrom` for `Value` now converts integers to `f32` and `f64`.
+- Added the new `custom_syntax` feature which allows custom delimiters
+  to be configured. (#245)
 
 ## 0.31.1
 
diff --git a/Makefile b/Makefile
index 6074a5da..6640e1db 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
-DOC_FEATURES=source,json,urlencode,testutils
-TEST_FEATURES=unstable_machinery,builtins,source,json,urlencode,debug,internal_debug,macros,multi_template,adjacent_loop_items
+DOC_FEATURES=source,json,urlencode,testutils,custom_syntax
+TEST_FEATURES=unstable_machinery,builtins,source,json,urlencode,debug,internal_debug,macros,multi_template,adjacent_loop_items,custom_syntax
 
 .PHONY: all
 all: test
@@ -50,6 +50,12 @@ check:
 	@cd minijinja; cargo check --no-default-features
 	@echo "check all features:"
 	@cd minijinja; cargo check --all-features
+	@echo "check custom-delimiters:"
+	@cd minijinja; cargo check --features=custom_syntax
+	@echo "check custom-delimiters+source:"
+	@cd minijinja; cargo check --features=custom_syntax,source
+	@echo "check source:"
+	@cd minijinja; cargo check --features=source
 	@echo "check macro only:"
 	@cd minijinja; cargo check --no-default-features --features macros
 	@echo "check multi_template only:"
diff --git a/minijinja/Cargo.toml b/minijinja/Cargo.toml
index 8b5d69cc..b436fb1f 100644
--- a/minijinja/Cargo.toml
+++ b/minijinja/Cargo.toml
@@ -13,7 +13,7 @@ rust-version = "1.61"
 exclude = ["tests"]
 
 [package.metadata.docs.rs]
-features = ["source", "json", "urlencode", "testutils"]
+features = ["source", "json", "urlencode", "testutils", "custom_syntax"]
 rustdoc-args = ["--cfg", "docsrs", "--html-in-header", "doc-header.html"]
 
 [features]
@@ -26,6 +26,7 @@ debug = []
 source = ["self_cell", "memo-map"]
 unicode = ["unicode-ident"]
 testutils = []
+custom_syntax = ["dep:aho-corasick"]
 
 # Speedups
 key_interning = []
@@ -52,6 +53,7 @@ multi-template = ["multi_template"]
 adjacent-loop-items = ["adjacent_loop_items"]
 
 [dependencies]
+aho-corasick = { version = "1.0", default-features = false, optional = true }
 serde = "1.0.130"
 v_htmlescape = { version = "0.15.8", optional = true }
 self_cell = { version = "0.10.1", optional = true }
diff --git a/minijinja/src/compiler/lexer.rs b/minijinja/src/compiler/lexer.rs
index 6cf5a48a..cbeb9eed 100644
--- a/minijinja/src/compiler/lexer.rs
+++ b/minijinja/src/compiler/lexer.rs
@@ -5,12 +5,28 @@ use crate::utils::{memchr, memstr, unescape};
 #[cfg(test)]
 use similar_asserts::assert_eq;
 
+#[cfg(feature = "custom_syntax")]
+pub use crate::custom_syntax::SyntaxConfig;
+
+/// Non configurable syntax config
+#[cfg(not(feature = "custom_syntax"))]
+#[derive(Debug, Copy, Clone, Default)]
+pub struct SyntaxConfig;
+
 enum LexerState {
     Template,
     InVariable,
     InBlock,
 }
 
+/// Utility enum that defines a marker.
+#[derive(Debug, Copy, Clone)]
+pub enum StartMarker {
+    Variable,
+    Block,
+    Comment,
+}
+
 struct TokenizerState<'s> {
     stack: Vec<LexerState>,
     rest: &'s str,
@@ -19,7 +35,7 @@ struct TokenizerState<'s> {
     current_col: u32,
 }
 
-fn find_marker(a: &str) -> Option<(usize, bool)> {
+fn find_start_marker_memchr(a: &str) -> Option<(usize, bool)> {
     let bytes = a.as_bytes();
     let mut offset = 0;
     loop {
@@ -37,6 +53,66 @@ fn find_marker(a: &str) -> Option<(usize, bool)> {
     }
 }
 
+#[cfg(feature = "custom_syntax")]
+fn find_start_marker(a: &str, syntax_config: &SyntaxConfig) -> Option<(usize, bool)> {
+    // If we have a custom delimiter we need to use the aho-corasick
+    // otherwise we can use internal memchr.
+    match syntax_config.aho_corasick {
+        Some(ref ac) => {
+            let bytes = a.as_bytes();
+            ac.find(bytes).map(|m| {
+                (
+                    m.start(),
+                    bytes.get(m.start() + m.len()).copied() == Some(b'-'),
+                )
+            })
+        }
+        None => find_start_marker_memchr(a),
+    }
+}
+
+#[cfg(not(feature = "custom_syntax"))]
+fn find_start_marker(a: &str, _syntax_config: &SyntaxConfig) -> Option<(usize, bool)> {
+    find_start_marker_memchr(a)
+}
+
+fn match_start_marker(rest: &str, syntax_config: &SyntaxConfig) -> Option<(StartMarker, usize)> {
+    #[cfg(not(feature = "custom_syntax"))]
+    {
+        let _ = syntax_config;
+        return match_start_marker_default(rest);
+    }
+
+    #[cfg(feature = "custom_syntax")]
+    {
+        if syntax_config.aho_corasick.is_none() {
+            return match_start_marker_default(rest);
+        }
+
+        for delimiter in syntax_config.start_delimiters_order {
+            let marker = match delimiter {
+                StartMarker::Variable => &syntax_config.syntax.variable_start as &str,
+                StartMarker::Block => &syntax_config.syntax.block_start as &str,
+                StartMarker::Comment => &syntax_config.syntax.comment_start as &str,
+            };
+            if rest.get(..marker.len()) == Some(marker) {
+                return Some((delimiter, marker.len()));
+            }
+        }
+
+        None
+    }
+}
+
+fn match_start_marker_default(rest: &str) -> Option<(StartMarker, usize)> {
+    match rest.get(..2) {
+        Some("{{") => Some((StartMarker::Variable, 2)),
+        Some("{%") => Some((StartMarker::Block, 2)),
+        Some("{#") => Some((StartMarker::Comment, 2)),
+        _ => None,
+    }
+}
+
 #[cfg(feature = "unicode")]
 fn lex_identifier(s: &str) -> usize {
     s.chars()
@@ -71,7 +147,7 @@ fn lex_identifier(s: &str) -> usize {
         .count()
 }
 
-fn skip_basic_tag(block_str: &str, name: &str) -> Option<(usize, bool)> {
+fn skip_basic_tag(block_str: &str, name: &str, block_end: &str) -> Option<(usize, bool)> {
     let mut ptr = block_str;
     let mut trim = false;
 
@@ -94,7 +170,7 @@ fn skip_basic_tag(block_str: &str, name: &str) -> Option<(usize, bool)> {
         ptr = rest;
         trim = true;
     }
-    ptr = match ptr.strip_prefix("%}") {
+    ptr = match ptr.strip_prefix(block_end) {
         Some(ptr) => ptr,
         None => return None,
     };
@@ -249,6 +325,7 @@ impl<'s> TokenizerState<'s> {
 pub fn tokenize(
     input: &str,
     in_expr: bool,
+    syntax_config: SyntaxConfig,
 ) -> impl Iterator<Item = Result<(Token<'_>, Span), Error>> {
     let mut state = TokenizerState {
         rest: input,
@@ -263,231 +340,292 @@ pub fn tokenize(
     };
     let mut trim_leading_whitespace = false;
 
-    std::iter::from_fn(move || loop {
-        if state.rest.is_empty() || state.failed {
-            return None;
-        }
+    std::iter::from_fn(move || {
+        let (variable_end, block_start, block_end, comment_end) = {
+            #[cfg(feature = "custom_syntax")]
+            {
+                (
+                    &syntax_config.syntax.variable_end as &str,
+                    &syntax_config.syntax.block_start as &str,
+                    &syntax_config.syntax.block_end as &str,
+                    &syntax_config.syntax.comment_end as &str,
+                )
+            }
+            #[cfg(not(feature = "custom_syntax"))]
+            {
+                // hardcode them here again, so the compiler can optimize
+                ("}}", "{%", "%}", "#}")
+            }
+        };
 
-        let mut old_loc = state.loc();
-        match state.stack.last() {
-            Some(LexerState::Template) => {
-                match state.rest.get(..2) {
-                    Some("{{") => {
-                        if state.rest.as_bytes().get(2) == Some(&b'-') {
-                            state.advance(3);
-                        } else {
-                            state.advance(2);
-                        }
-                        state.stack.push(LexerState::InVariable);
-                        return Some(Ok((Token::VariableStart, state.span(old_loc))));
-                    }
-                    Some("{%") => {
-                        // raw blocks require some special handling.  If we are at the beginning of a raw
-                        // block we want to skip everything until {% endraw %} completely ignoring iterior
-                        // syntax and emit the entire raw block as TemplateData.
-                        if let Some((mut ptr, _)) = skip_basic_tag(&state.rest[2..], "raw") {
-                            ptr += 2;
-                            while let Some(block) = memstr(&state.rest.as_bytes()[ptr..], b"{%") {
-                                ptr += block + 2;
-                                if let Some((endraw, trim)) =
-                                    skip_basic_tag(&state.rest[ptr..], "endraw")
+        loop {
+            if state.rest.is_empty() || state.failed {
+                return None;
+            }
+
+            let mut old_loc = state.loc();
+            match state.stack.last() {
+                Some(LexerState::Template) => {
+                    match match_start_marker(state.rest, &syntax_config) {
+                        Some((StartMarker::Comment, skip)) => {
+                            if let Some(end) =
+                                memstr(&state.rest.as_bytes()[skip..], comment_end.as_bytes())
+                            {
+                                if state
+                                    .rest
+                                    .as_bytes()
+                                    .get(end.saturating_sub(1) + skip)
+                                    .copied()
+                                    == Some(b'-')
                                 {
-                                    let result = &state.rest[..ptr + endraw];
-                                    state.advance(ptr + endraw);
-                                    trim_leading_whitespace = trim;
-                                    return Some(Ok((
-                                        Token::TemplateData(result),
-                                        state.span(old_loc),
-                                    )));
+                                    trim_leading_whitespace = true;
                                 }
+                                state.advance(end + skip + comment_end.len());
+                                continue;
+                            } else {
+                                return Some(Err(state.syntax_error("unexpected end of comment")));
                             }
-                            return Some(Err(state.syntax_error("unexpected end of raw block")));
                         }
-
-                        if state.rest.as_bytes().get(2) == Some(&b'-') {
-                            state.advance(3);
-                        } else {
-                            state.advance(2);
+                        Some((StartMarker::Variable, skip)) => {
+                            if state.rest.as_bytes().get(skip) == Some(&b'-') {
+                                state.advance(skip + 1);
+                            } else {
+                                state.advance(skip);
+                            }
+                            state.stack.push(LexerState::InVariable);
+                            return Some(Ok((Token::VariableStart, state.span(old_loc))));
                         }
-
-                        state.stack.push(LexerState::InBlock);
-                        return Some(Ok((Token::BlockStart, state.span(old_loc))));
-                    }
-                    Some("{#") => {
-                        if let Some(comment_end) = memstr(state.rest.as_bytes(), b"#}") {
-                            if state
-                                .rest
-                                .as_bytes()
-                                .get(comment_end.saturating_sub(1))
-                                .copied()
-                                == Some(b'-')
+                        Some((StartMarker::Block, skip)) => {
+                            // raw blocks require some special handling.  If we are at the beginning of a raw
+                            // block we want to skip everything until {% endraw %} completely ignoring iterior
+                            // syntax and emit the entire raw block as TemplateData.
+                            if let Some((mut ptr, _)) =
+                                skip_basic_tag(&state.rest[skip..], "raw", block_end)
                             {
-                                trim_leading_whitespace = true;
+                                ptr += skip;
+                                while let Some(block) =
+                                    memstr(&state.rest.as_bytes()[ptr..], block_start.as_bytes())
+                                {
+                                    ptr += block + block_start.len();
+                                    if let Some((endraw, trim)) =
+                                        skip_basic_tag(&state.rest[ptr..], "endraw", block_end)
+                                    {
+                                        let result = &state.rest[..ptr + endraw];
+                                        state.advance(ptr + endraw);
+                                        trim_leading_whitespace = trim;
+                                        return Some(Ok((
+                                            Token::TemplateData(result),
+                                            state.span(old_loc),
+                                        )));
+                                    }
+                                }
+                                return Some(
+                                    Err(state.syntax_error("unexpected end of raw block")),
+                                );
                             }
-                            state.advance(comment_end + 2);
-                            continue;
-                        } else {
-                            return Some(Err(state.syntax_error("unexpected end of comment")));
+
+                            if state.rest.as_bytes().get(skip) == Some(&b'-') {
+                                state.advance(skip + 1);
+                            } else {
+                                state.advance(skip);
+                            }
+
+                            state.stack.push(LexerState::InBlock);
+                            return Some(Ok((Token::BlockStart, state.span(old_loc))));
                         }
+                        None => {}
                     }
-                    _ => {}
-                }
 
-                if trim_leading_whitespace {
-                    trim_leading_whitespace = false;
-                    state.skip_whitespace();
+                    if trim_leading_whitespace {
+                        trim_leading_whitespace = false;
+                        state.skip_whitespace();
+                    }
                     old_loc = state.loc();
-                }
 
-                let (lead, span) = match find_marker(state.rest) {
-                    Some((start, false)) => (state.advance(start), state.span(old_loc)),
-                    Some((start, _)) => {
-                        let peeked = &state.rest[..start];
-                        let trimmed = peeked.trim_end();
-                        let lead = state.advance(trimmed.len());
-                        let span = state.span(old_loc);
-                        state.advance(peeked.len() - trimmed.len());
-                        (lead, span)
-                    }
-                    None => (state.advance(state.rest.len()), state.span(old_loc)),
-                };
-                if lead.is_empty() {
-                    continue;
-                }
-                return Some(Ok((Token::TemplateData(lead), span)));
-            }
-            Some(LexerState::InBlock | LexerState::InVariable) => {
-                // in blocks whitespace is generally ignored, skip it.
-                match state
-                    .rest
-                    .as_bytes()
-                    .iter()
-                    .position(|&x| !x.is_ascii_whitespace())
-                {
-                    Some(0) => {}
-                    None => {
-                        state.advance(state.rest.len());
-                        continue;
-                    }
-                    Some(offset) => {
-                        state.advance(offset);
+                    let (lead, span) = match find_start_marker(state.rest, &syntax_config) {
+                        Some((start, false)) => (state.advance(start), state.span(old_loc)),
+                        Some((start, _)) => {
+                            let peeked = &state.rest[..start];
+                            let trimmed = peeked.trim_end();
+                            let lead = state.advance(trimmed.len());
+                            let span = state.span(old_loc);
+                            state.advance(peeked.len() - trimmed.len());
+                            (lead, span)
+                        }
+                        None => (state.advance(state.rest.len()), state.span(old_loc)),
+                    };
+                    if lead.is_empty() {
                         continue;
                     }
+                    return Some(Ok((Token::TemplateData(lead), span)));
                 }
-
-                // look out for the end of blocks
-                if let Some(&LexerState::InBlock) = state.stack.last() {
-                    if let Some("-%}") = state.rest.get(..3) {
-                        state.stack.pop();
-                        trim_leading_whitespace = true;
-                        state.advance(3);
-                        return Some(Ok((Token::BlockEnd, state.span(old_loc))));
-                    }
-                    if let Some("%}") = state.rest.get(..2) {
-                        state.stack.pop();
-                        state.advance(2);
-                        return Some(Ok((Token::BlockEnd, state.span(old_loc))));
+                Some(LexerState::InBlock | LexerState::InVariable) => {
+                    // in blocks whitespace is generally ignored, skip it.
+                    match state
+                        .rest
+                        .as_bytes()
+                        .iter()
+                        .position(|&x| !x.is_ascii_whitespace())
+                    {
+                        Some(0) => {}
+                        None => {
+                            state.advance(state.rest.len());
+                            continue;
+                        }
+                        Some(offset) => {
+                            state.advance(offset);
+                            continue;
+                        }
                     }
-                } else {
-                    if let Some("-}}") = state.rest.get(..3) {
-                        state.stack.pop();
-                        state.advance(3);
-                        trim_leading_whitespace = true;
-                        return Some(Ok((Token::VariableEnd, state.span(old_loc))));
+
+                    // look out for the end of blocks
+                    if let Some(&LexerState::InBlock) = state.stack.last() {
+                        if state.rest.get(..1) == Some("-")
+                            && state.rest.get(1..block_end.len() + 1) == Some(block_end)
+                        {
+                            state.stack.pop();
+                            trim_leading_whitespace = true;
+                            state.advance(block_end.len() + 1);
+                            return Some(Ok((Token::BlockEnd, state.span(old_loc))));
+                        }
+                        if state.rest.get(..block_end.len()) == Some(block_end) {
+                            state.stack.pop();
+                            state.advance(block_end.len());
+                            return Some(Ok((Token::BlockEnd, state.span(old_loc))));
+                        }
+                    } else {
+                        if state.rest.get(..1) == Some("-")
+                            && state.rest.get(1..variable_end.len() + 1) == Some(variable_end)
+                        {
+                            state.stack.pop();
+                            state.advance(variable_end.len() + 1);
+                            trim_leading_whitespace = true;
+                            return Some(Ok((Token::VariableEnd, state.span(old_loc))));
+                        }
+                        if state.rest.get(..variable_end.len()) == Some(variable_end) {
+                            state.stack.pop();
+                            state.advance(variable_end.len());
+                            return Some(Ok((Token::VariableEnd, state.span(old_loc))));
+                        }
                     }
-                    if let Some("}}") = state.rest.get(..2) {
-                        state.stack.pop();
+
+                    // two character operators
+                    let op = match state.rest.as_bytes().get(..2) {
+                        Some(b"//") => Some(Token::FloorDiv),
+                        Some(b"**") => Some(Token::Pow),
+                        Some(b"==") => Some(Token::Eq),
+                        Some(b"!=") => Some(Token::Ne),
+                        Some(b">=") => Some(Token::Gte),
+                        Some(b"<=") => Some(Token::Lte),
+                        _ => None,
+                    };
+                    if let Some(op) = op {
                         state.advance(2);
-                        return Some(Ok((Token::VariableEnd, state.span(old_loc))));
+                        return Some(Ok((op, state.span(old_loc))));
                     }
-                }
-
-                // two character operators
-                let op = match state.rest.as_bytes().get(..2) {
-                    Some(b"//") => Some(Token::FloorDiv),
-                    Some(b"**") => Some(Token::Pow),
-                    Some(b"==") => Some(Token::Eq),
-                    Some(b"!=") => Some(Token::Ne),
-                    Some(b">=") => Some(Token::Gte),
-                    Some(b"<=") => Some(Token::Lte),
-                    _ => None,
-                };
-                if let Some(op) = op {
-                    state.advance(2);
-                    return Some(Ok((op, state.span(old_loc))));
-                }
 
-                // single character operators (and strings)
-                let op = match state.rest.as_bytes().get(0) {
-                    Some(b'+') => Some(Token::Plus),
-                    Some(b'-') => Some(Token::Minus),
-                    Some(b'*') => Some(Token::Mul),
-                    Some(b'/') => Some(Token::Div),
-                    Some(b'%') => Some(Token::Mod),
-                    Some(b'!') => Some(Token::Bang),
-                    Some(b'.') => Some(Token::Dot),
-                    Some(b',') => Some(Token::Comma),
-                    Some(b':') => Some(Token::Colon),
-                    Some(b'~') => Some(Token::Tilde),
-                    Some(b'|') => Some(Token::Pipe),
-                    Some(b'=') => Some(Token::Assign),
-                    Some(b'>') => Some(Token::Gt),
-                    Some(b'<') => Some(Token::Lt),
-                    Some(b'(') => Some(Token::ParenOpen),
-                    Some(b')') => Some(Token::ParenClose),
-                    Some(b'[') => Some(Token::BracketOpen),
-                    Some(b']') => Some(Token::BracketClose),
-                    Some(b'{') => Some(Token::BraceOpen),
-                    Some(b'}') => Some(Token::BraceClose),
-                    Some(b'\'') => {
-                        return Some(state.eat_string(b'\''));
-                    }
-                    Some(b'"') => {
-                        return Some(state.eat_string(b'"'));
+                    // single character operators (and strings)
+                    let op = match state.rest.as_bytes().get(0) {
+                        Some(b'+') => Some(Token::Plus),
+                        Some(b'-') => Some(Token::Minus),
+                        Some(b'*') => Some(Token::Mul),
+                        Some(b'/') => Some(Token::Div),
+                        Some(b'%') => Some(Token::Mod),
+                        Some(b'!') => Some(Token::Bang),
+                        Some(b'.') => Some(Token::Dot),
+                        Some(b',') => Some(Token::Comma),
+                        Some(b':') => Some(Token::Colon),
+                        Some(b'~') => Some(Token::Tilde),
+                        Some(b'|') => Some(Token::Pipe),
+                        Some(b'=') => Some(Token::Assign),
+                        Some(b'>') => Some(Token::Gt),
+                        Some(b'<') => Some(Token::Lt),
+                        Some(b'(') => Some(Token::ParenOpen),
+                        Some(b')') => Some(Token::ParenClose),
+                        Some(b'[') => Some(Token::BracketOpen),
+                        Some(b']') => Some(Token::BracketClose),
+                        Some(b'{') => Some(Token::BraceOpen),
+                        Some(b'}') => Some(Token::BraceClose),
+                        Some(b'\'') => {
+                            return Some(state.eat_string(b'\''));
+                        }
+                        Some(b'"') => {
+                            return Some(state.eat_string(b'"'));
+                        }
+                        Some(c) if c.is_ascii_digit() => return Some(state.eat_number()),
+                        _ => None,
+                    };
+                    if let Some(op) = op {
+                        state.advance(1);
+                        return Some(Ok((op, state.span(old_loc))));
                     }
-                    Some(c) if c.is_ascii_digit() => return Some(state.eat_number()),
-                    _ => None,
-                };
-                if let Some(op) = op {
-                    state.advance(1);
-                    return Some(Ok((op, state.span(old_loc))));
-                }
 
-                return Some(state.eat_identifier());
+                    return Some(state.eat_identifier());
+                }
+                None => panic!("empty lexer state"),
             }
-            None => panic!("empty lexer state"),
         }
     })
 }
 
 #[test]
 fn test_find_marker() {
-    assert!(find_marker("{").is_none());
-    assert!(find_marker("foo").is_none());
-    assert!(find_marker("foo {").is_none());
-    assert_eq!(find_marker("foo {{"), Some((4, false)));
-    assert_eq!(find_marker("foo {{-"), Some((4, true)));
+    let syntax = SyntaxConfig::default();
+    assert!(find_start_marker("{", &syntax).is_none());
+    assert!(find_start_marker("foo", &syntax).is_none());
+    assert!(find_start_marker("foo {", &syntax).is_none());
+    assert_eq!(find_start_marker("foo {{", &syntax), Some((4, false)));
+    assert_eq!(find_start_marker("foo {{-", &syntax), Some((4, true)));
+}
+
+#[test]
+#[cfg(feature = "custom_syntax")]
+fn test_find_marker_custom_syntax() {
+    use crate::Syntax;
+
+    let syntax = Syntax {
+        block_start: "%{".into(),
+        block_end: "}%".into(),
+        variable_start: "[[".into(),
+        variable_end: "]]".into(),
+        comment_start: "/*".into(),
+        comment_end: "*/".into(),
+    };
+
+    let syntax_config = syntax.compile().expect("failed to create syntax config");
+
+    assert_eq!(find_start_marker("%{", &syntax_config), Some((0, false)));
+    assert!(find_start_marker("/", &syntax_config).is_none());
+    assert!(find_start_marker("foo [", &syntax_config).is_none());
+    assert_eq!(
+        find_start_marker("foo /*", &syntax_config),
+        Some((4, false))
+    );
+    assert_eq!(
+        find_start_marker("foo [[-", &syntax_config),
+        Some((4, true))
+    );
 }
 
 #[test]
 fn test_is_basic_tag() {
-    assert_eq!(skip_basic_tag(" raw %}", "raw"), Some((7, false)));
-    assert_eq!(skip_basic_tag(" raw %}", "endraw"), None);
-    assert_eq!(skip_basic_tag("  raw  %}", "raw"), Some((9, false)));
-    assert_eq!(skip_basic_tag("-  raw  -%}", "raw"), Some((11, true)));
+    assert_eq!(skip_basic_tag(" raw %}", "raw", "%}"), Some((7, false)));
+    assert_eq!(skip_basic_tag(" raw %}", "endraw", "%}"), None);
+    assert_eq!(skip_basic_tag("  raw  %}", "raw", "%}"), Some((9, false)));
+    assert_eq!(skip_basic_tag("-  raw  -%}", "raw", "%}"), Some((11, true)));
 }
 
 #[test]
 fn test_basic_identifiers() {
     fn assert_ident(s: &str) {
-        match tokenize(s, true).next() {
+        match tokenize(s, true, Default::default()).next() {
             Some(Ok((Token::Ident(ident), _))) if ident == s => {}
             _ => panic!("did not get a matching token result: {s:?}"),
         }
     }
 
     fn assert_not_ident(s: &str) {
-        let res = tokenize(s, true).collect::<Result<Vec<_>, _>>();
+        let res = tokenize(s, true, Default::default()).collect::<Result<Vec<_>, _>>();
         if let Ok(tokens) = res {
             if let &[(Token::Ident(_), _)] = &tokens[..] {
                 panic!("got a single ident for {s:?}")
diff --git a/minijinja/src/compiler/parser.rs b/minijinja/src/compiler/parser.rs
index 1657edbb..1e8f18e7 100644
--- a/minijinja/src/compiler/parser.rs
+++ b/minijinja/src/compiler/parser.rs
@@ -2,7 +2,7 @@ use std::collections::BTreeSet;
 use std::fmt;
 
 use crate::compiler::ast::{self, Spanned};
-use crate::compiler::lexer::tokenize;
+use crate::compiler::lexer::{tokenize, SyntaxConfig};
 use crate::compiler::tokens::{Span, Token};
 use crate::error::{Error, ErrorKind};
 use crate::value::Value;
@@ -95,8 +95,8 @@ struct TokenStream<'a> {
 
 impl<'a> TokenStream<'a> {
     /// Tokenize a template
-    pub fn new(source: &'a str, in_expr: bool) -> TokenStream<'a> {
-        let mut iter = Box::new(tokenize(source, in_expr)) as Box<dyn Iterator<Item = _>>;
+    pub fn new(source: &'a str, in_expr: bool, syntax: SyntaxConfig) -> TokenStream<'a> {
+        let mut iter = Box::new(tokenize(source, in_expr, syntax)) as Box<dyn Iterator<Item = _>>;
         let current = iter.next();
         TokenStream {
             iter,
@@ -216,9 +216,9 @@ macro_rules! with_recursion_guard {
 }
 
 impl<'a> Parser<'a> {
-    pub fn new(source: &'a str, in_expr: bool) -> Parser<'a> {
+    pub fn new(source: &'a str, in_expr: bool, syntax: SyntaxConfig) -> Parser<'a> {
         Parser {
-            stream: TokenStream::new(source, in_expr),
+            stream: TokenStream::new(source, in_expr, syntax),
             in_macro: false,
             blocks: BTreeSet::new(),
             depth: 0,
@@ -1091,7 +1091,17 @@ impl<'a> Parser<'a> {
 }
 
 /// Parses a template
+#[cfg(feature = "unstable_machinery")]
 pub fn parse<'source>(source: &'source str, filename: &str) -> Result<ast::Stmt<'source>, Error> {
+    parse_with_syntax(source, filename, Default::default())
+}
+
+/// Parses a template with a specific syntax
+pub fn parse_with_syntax<'source>(
+    source: &'source str,
+    filename: &str,
+    syntax: SyntaxConfig,
+) -> Result<ast::Stmt<'source>, Error> {
     // we want to chop off a single newline at the end.  This means that a template
     // by default does not end in a newline which is a useful property to allow
     // inline templates to work.  If someone wants a trailing newline the expectation
@@ -1104,7 +1114,7 @@ pub fn parse<'source>(source: &'source str, filename: &str) -> Result<ast::Stmt<
         source = &source[..source.len() - 1];
     }
 
-    let mut parser = Parser::new(source, false);
+    let mut parser = Parser::new(source, false, syntax);
     parser.parse().map_err(|mut err| {
         if err.line().is_none() {
             err.set_filename_and_span(filename, parser.stream.last_span())
@@ -1114,8 +1124,8 @@ pub fn parse<'source>(source: &'source str, filename: &str) -> Result<ast::Stmt<
 }
 
 /// Parses an expression
-pub fn parse_expr(source: &str) -> Result<ast::Expr<'_>, Error> {
-    let mut parser = Parser::new(source, true);
+pub fn parse_expr(source: &str, syntax: SyntaxConfig) -> Result<ast::Expr<'_>, Error> {
+    let mut parser = Parser::new(source, true, syntax);
     parser
         .parse_expr()
         .and_then(|result| {
diff --git a/minijinja/src/custom_syntax.rs b/minijinja/src/custom_syntax.rs
new file mode 100644
index 00000000..c1f4a765
--- /dev/null
+++ b/minijinja/src/custom_syntax.rs
@@ -0,0 +1,175 @@
+use aho_corasick::AhoCorasick;
+use std::borrow::Cow;
+use std::sync::Arc;
+
+use crate::compiler::lexer::StartMarker;
+use crate::error::{Error, ErrorKind};
+
+/// The delimiter configuration for the environment and the parser.
+///
+/// MiniJinja allows you to override the syntax configuration for
+/// templates by setting different delimiters.  The end markers can
+/// be shared, but the start markers need to be distinct.  It would
+/// thus not be valid to configure `{{` to be the marker for both
+/// variables and blocks.
+///
+/// ```
+/// # use minijinja::{Environment, Syntax};
+/// let mut environment = Environment::new();
+/// environment.set_syntax(Syntax {
+///     block_start: "\\BLOCK{".into(),
+///     block_end: "}".into(),
+///     variable_start: "\\VAR{".into(),
+///     variable_end: "}".into(),
+///     comment_start: "\\#{".into(),
+///     comment_end: "}".into(),
+/// }).unwrap();
+/// ```
+#[derive(Debug, Clone, Eq, PartialEq)]
+#[cfg_attr(docsrs, doc(cfg(feature = "custom_syntax")))]
+pub struct Syntax {
+    /// The start of a block. By default it is `{%`.
+    pub block_start: Cow<'static, str>,
+    /// The end of a block. By default it is `%}`.
+    pub block_end: Cow<'static, str>,
+    /// The start of a variable. By default it is `{{`.
+    pub variable_start: Cow<'static, str>,
+    /// The end of a variable. By default it is `}}`.
+    pub variable_end: Cow<'static, str>,
+    /// The start of a comment. By default it is `{#`.
+    pub comment_start: Cow<'static, str>,
+    /// The end of a comment. By default it is `#}`.
+    pub comment_end: Cow<'static, str>,
+}
+
+const DEFAULT_SYNTAX: Syntax = Syntax {
+    block_start: Cow::Borrowed("{%"),
+    block_end: Cow::Borrowed("%}"),
+    variable_start: Cow::Borrowed("{{"),
+    variable_end: Cow::Borrowed("}}"),
+    comment_start: Cow::Borrowed("{#"),
+    comment_end: Cow::Borrowed("#}"),
+};
+
+impl Default for Syntax {
+    fn default() -> Self {
+        DEFAULT_SYNTAX
+    }
+}
+
+impl Syntax {
+    /// Creates a new syntax configuration with custom delimiters.
+    pub fn compile(self) -> Result<SyntaxConfig, Error> {
+        if self == DEFAULT_SYNTAX {
+            return Ok(SyntaxConfig::default());
+        }
+
+        ok!(self.check_delimiters());
+
+        let mut delimiter_order = [
+            StartMarker::Variable,
+            StartMarker::Block,
+            StartMarker::Comment,
+        ];
+        delimiter_order.sort_by_key(|marker| {
+            std::cmp::Reverse(match marker {
+                StartMarker::Variable => self.variable_start.len(),
+                StartMarker::Block => self.block_start.len(),
+                StartMarker::Comment => self.comment_start.len(),
+            })
+        });
+
+        let aho_corasick = ok!(AhoCorasick::builder()
+            .match_kind(aho_corasick::MatchKind::LeftmostLongest)
+            .build([
+                &self.variable_start as &str,
+                &self.block_start as &str,
+                &self.comment_start as &str,
+            ])
+            .map_err(|_| ErrorKind::InvalidDelimiter.into()));
+        Ok(Arc::new(SyntaxConfigInternal {
+            syntax: self,
+            aho_corasick: Some(aho_corasick),
+            start_delimiters_order: delimiter_order,
+        }))
+    }
+
+    /// block, variable and comment start strings must be different
+    fn check_delimiters(&self) -> Result<(), Error> {
+        if self.block_start != self.variable_start
+            && self.block_start != self.comment_start
+            && self.variable_start != self.comment_start
+        {
+            Ok(())
+        } else {
+            Err(ErrorKind::InvalidDelimiter.into())
+        }
+    }
+}
+
+/// Internal configuration for the environment and the parser.
+#[derive(Debug)]
+pub struct SyntaxConfigInternal {
+    pub(crate) syntax: Syntax,
+    pub(crate) start_delimiters_order: [StartMarker; 3],
+    pub(crate) aho_corasick: Option<aho_corasick::AhoCorasick>,
+}
+
+/// Configurable syntax config
+pub type SyntaxConfig = Arc<SyntaxConfigInternal>;
+
+impl Default for SyntaxConfigInternal {
+    fn default() -> Self {
+        SyntaxConfigInternal {
+            syntax: Syntax::default(),
+            start_delimiters_order: [
+                StartMarker::Variable,
+                StartMarker::Block,
+                StartMarker::Comment,
+            ],
+            aho_corasick: None,
+        }
+    }
+}
+
+#[test]
+fn test_compile() {
+    let syntax = Syntax {
+        block_start: "{".into(),
+        block_end: "}".into(),
+        variable_start: "${".into(),
+        variable_end: "}".into(),
+        comment_start: "{*".into(),
+        comment_end: "*}".into(),
+    };
+
+    let settings = syntax.compile().unwrap();
+
+    let aho_corasick = settings.aho_corasick.as_ref().unwrap();
+
+    let input = "{for x in range(3)}${x}{endfor}{* nothing *}";
+
+    let mut matches = aho_corasick.find_iter(input);
+    // '{'
+    let statement_match = matches.next().unwrap();
+    assert_eq!(statement_match.start(), 0);
+    assert_eq!(statement_match.end(), 1);
+
+    // '${'
+    let var_match = matches.next().unwrap();
+    assert_eq!(var_match.start(), 19);
+    assert_eq!(var_match.end(), 21);
+
+    // '{'
+    let statemend_end_match = matches.next().unwrap();
+    assert_eq!(statemend_end_match.start(), 23);
+    assert_eq!(statemend_end_match.end(), 24);
+
+    // '{*'
+    let comment_match = matches.next().unwrap();
+    dbg!(&input.get(comment_match.start()..comment_match.end()));
+    assert_eq!(comment_match.start(), 31);
+    assert_eq!(comment_match.end(), 33);
+
+    assert!(matches.next().is_none());
+}
diff --git a/minijinja/src/environment.rs b/minijinja/src/environment.rs
index fec4b6eb..accaa342 100644
--- a/minijinja/src/environment.rs
+++ b/minijinja/src/environment.rs
@@ -6,6 +6,7 @@ use std::sync::Arc;
 use serde::Serialize;
 
 use crate::compiler::codegen::CodeGenerator;
+use crate::compiler::lexer::SyntaxConfig;
 use crate::compiler::parser::parse_expr;
 use crate::error::{attach_basic_debug_info, Error, ErrorKind};
 use crate::expression::Expression;
@@ -20,7 +21,7 @@ type TemplateMap<'source> = BTreeMap<&'source str, Arc<CompiledTemplate<'source>
 
 #[derive(Clone)]
 enum Source<'source> {
-    Borrowed(TemplateMap<'source>),
+    Borrowed(TemplateMap<'source>, SyntaxConfig),
     #[cfg(feature = "source")]
     Owned(crate::source::Source),
 }
@@ -28,7 +29,7 @@ enum Source<'source> {
 impl<'source> fmt::Debug for Source<'source> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
-            Self::Borrowed(tmpls) => fmt::Debug::fmt(&BTreeMapKeysDebug(tmpls), f),
+            Self::Borrowed(tmpls, _) => fmt::Debug::fmt(&BTreeMapKeysDebug(tmpls), f),
             #[cfg(feature = "source")]
             Self::Owned(arg0) => fmt::Debug::fmt(arg0, f),
         }
@@ -101,7 +102,7 @@ impl<'source> Environment<'source> {
     /// [`empty`](Environment::empty) method.
     pub fn new() -> Environment<'source> {
         Environment {
-            templates: Source::Borrowed(Default::default()),
+            templates: Source::Borrowed(Default::default(), Default::default()),
             filters: defaults::get_builtin_filters(),
             tests: defaults::get_builtin_tests(),
             globals: defaults::get_globals(),
@@ -121,7 +122,7 @@ impl<'source> Environment<'source> {
     /// logic for auto escaping configured.
     pub fn empty() -> Environment<'source> {
         Environment {
-            templates: Source::Borrowed(Default::default()),
+            templates: Source::Borrowed(Default::default(), Default::default()),
             filters: Default::default(),
             tests: Default::default(),
             globals: Default::default(),
@@ -150,8 +151,12 @@ impl<'source> Environment<'source> {
     )]
     pub fn add_template(&mut self, name: &'source str, source: &'source str) -> Result<(), Error> {
         match self.templates {
-            Source::Borrowed(ref mut map) => {
-                let compiled_template = ok!(CompiledTemplate::from_name_and_source(name, source));
+            Source::Borrowed(ref mut map, ref syntax) => {
+                let compiled_template = ok!(CompiledTemplate::from_name_and_source_with_syntax(
+                    name,
+                    source,
+                    syntax.clone()
+                ));
                 map.insert(name, Arc::new(compiled_template));
                 Ok(())
             }
@@ -163,7 +168,7 @@ impl<'source> Environment<'source> {
     /// Removes a template by name.
     pub fn remove_template(&mut self, name: &str) {
         match self.templates {
-            Source::Borrowed(ref mut map) => {
+            Source::Borrowed(ref mut map, _) => {
                 map.remove(name);
             }
             #[cfg(feature = "source")]
@@ -188,7 +193,7 @@ impl<'source> Environment<'source> {
     /// ```
     pub fn get_template(&self, name: &str) -> Result<Template<'_>, Error> {
         let compiled = match &self.templates {
-            Source::Borrowed(ref map) => {
+            Source::Borrowed(ref map, _) => {
                 ok!(map.get(name).ok_or_else(|| Error::new_not_found(name)))
             }
             #[cfg(feature = "source")]
@@ -251,7 +256,11 @@ impl<'source> Environment<'source> {
     }
 
     fn _render_str(&self, name: &str, source: &str, root: Value) -> Result<String, Error> {
-        let compiled = ok!(CompiledTemplate::from_name_and_source(name, source));
+        let compiled = ok!(CompiledTemplate::from_name_and_source_with_syntax(
+            name,
+            source,
+            self._syntax_config().clone()
+        ));
         let mut rv = String::with_capacity(compiled.buffer_size_hint);
         Vm::new(self)
             .eval(
@@ -396,6 +405,38 @@ impl<'source> Environment<'source> {
         self.fuel
     }
 
+    /// Sets the syntax for the environment.
+    ///
+    /// Note that when `source` is used, the syntax is held on the underlying source
+    /// which means that the actual source needs to have it's syntax changed.
+    ///
+    /// See [`Syntax`](crate::Syntax) for more information.
+    #[cfg(feature = "custom_syntax")]
+    #[cfg_attr(docsrs, doc(cfg(feature = "custom_syntax")))]
+    pub fn set_syntax(&mut self, syntax: crate::custom_syntax::Syntax) -> Result<(), Error> {
+        match self.templates {
+            Source::Borrowed(_, ref mut syn) => *syn = ok!(syntax.compile()),
+            #[cfg(feature = "source")]
+            Source::Owned(ref mut source) => ok!(source.set_syntax(syntax)),
+        };
+        Ok(())
+    }
+
+    /// Returns the current syntax.
+    #[cfg(feature = "custom_syntax")]
+    #[cfg_attr(docsrs, doc(cfg(feature = "custom_syntax")))]
+    pub fn syntax(&self) -> &crate::custom_syntax::Syntax {
+        &self._syntax_config().syntax
+    }
+
+    fn _syntax_config(&self) -> &SyntaxConfig {
+        match self.templates {
+            Source::Borrowed(_, ref syn) => syn,
+            #[cfg(feature = "source")]
+            Source::Owned(ref source) => source._syntax_config(),
+        }
+    }
+
     /// Sets the template source for the environment.
     ///
     /// This helps when working with dynamically loaded templates.  The
@@ -419,7 +460,7 @@ impl<'source> Environment<'source> {
     #[cfg_attr(docsrs, doc(cfg(feature = "source")))]
     pub fn source(&self) -> Option<&crate::source::Source> {
         match self.templates {
-            Source::Borrowed(_) => None,
+            Source::Borrowed(..) => None,
             Source::Owned(ref source) => Some(source),
         }
     }
@@ -429,7 +470,7 @@ impl<'source> Environment<'source> {
     #[cfg_attr(docsrs, doc(cfg(feature = "source")))]
     pub fn source_mut(&mut self) -> Option<&mut crate::source::Source> {
         match self.templates {
-            Source::Borrowed(_) => None,
+            Source::Borrowed(..) => None,
             Source::Owned(ref mut source) => Some(source),
         }
     }
@@ -445,7 +486,7 @@ impl<'source> Environment<'source> {
     }
 
     fn _compile_expression(&self, expr: &'source str) -> Result<Expression<'_, 'source>, Error> {
-        let ast = ok!(parse_expr(expr));
+        let ast = ok!(parse_expr(expr, self._syntax_config().clone()));
         let mut gen = CodeGenerator::new("<expression>", expr);
         gen.compile_expr(&ast);
         let (instructions, _) = gen.finish();
diff --git a/minijinja/src/error.rs b/minijinja/src/error.rs
index ad500b2b..5e9ad952 100644
--- a/minijinja/src/error.rs
+++ b/minijinja/src/error.rs
@@ -137,6 +137,9 @@ pub enum ErrorKind {
     /// Engine ran out of fuel
     #[cfg(feature = "fuel")]
     OutOfFuel,
+    #[cfg(feature = "custom_syntax")]
+    /// Error creating aho-corasick delimiters
+    InvalidDelimiter,
 }
 
 impl ErrorKind {
@@ -162,6 +165,8 @@ impl ErrorKind {
             ErrorKind::WriteFailure => "failed to write output",
             #[cfg(feature = "fuel")]
             ErrorKind::OutOfFuel => "engine ran out of fuel",
+            #[cfg(feature = "custom_syntax")]
+            ErrorKind::InvalidDelimiter => "invalid custom delimiters",
         }
     }
 }
diff --git a/minijinja/src/lib.rs b/minijinja/src/lib.rs
index f8894e3d..e6e09c24 100644
--- a/minijinja/src/lib.rs
+++ b/minijinja/src/lib.rs
@@ -173,6 +173,8 @@
 //!   the previous uses of key interning are no longer needed.  Enabling it however
 //!   cuts down on memory usage slightly in certain scenarios by interning all string
 //!   keys used in dynamic map values.
+//! - `custom_syntax`: when this feature is enabled, custom delimiters are supported by
+//!   the parser.
 //!
 //! </details>
 #![allow(clippy::cognitive_complexity)]
@@ -208,6 +210,9 @@ pub mod testutils;
 #[cfg(feature = "source")]
 mod source;
 
+#[cfg(feature = "custom_syntax")]
+mod custom_syntax;
+
 #[cfg(feature = "debug")]
 mod debug;
 
@@ -219,6 +224,9 @@ pub use self::output::Output;
 pub use self::template::Template;
 pub use self::utils::{AutoEscape, HtmlEscape, UndefinedBehavior};
 
+#[cfg(feature = "custom_syntax")]
+pub use self::custom_syntax::Syntax;
+
 #[cfg(feature = "source")]
 pub use self::source::Source;
 
@@ -237,8 +245,8 @@ pub mod machinery {
     pub use crate::compiler::ast;
     pub use crate::compiler::codegen::CodeGenerator;
     pub use crate::compiler::instructions::{Instruction, Instructions};
-    pub use crate::compiler::lexer::tokenize;
-    pub use crate::compiler::parser::parse;
+    pub use crate::compiler::lexer::{tokenize, SyntaxConfig};
+    pub use crate::compiler::parser::{parse, parse_with_syntax};
     pub use crate::compiler::tokens::{Span, Token};
     pub use crate::template::CompiledTemplate;
     pub use crate::vm::Vm;
diff --git a/minijinja/src/source.rs b/minijinja/src/source.rs
index b1034253..3bc03e75 100644
--- a/minijinja/src/source.rs
+++ b/minijinja/src/source.rs
@@ -9,6 +9,7 @@ use std::sync::Arc;
 use memo_map::MemoMap;
 use self_cell::self_cell;
 
+use crate::compiler::lexer::SyntaxConfig;
 use crate::error::{Error, ErrorKind};
 use crate::template::CompiledTemplate;
 
@@ -42,9 +43,11 @@ enum SourceBacking {
     Dynamic {
         templates: MemoMap<String, Arc<LoadedTemplate>>,
         loader: Arc<LoadFunc>,
+        syntax: SyntaxConfig,
     },
     Static {
         templates: HashMap<String, Arc<LoadedTemplate>>,
+        syntax: SyntaxConfig,
     },
 }
 
@@ -61,7 +64,7 @@ impl fmt::Debug for Source {
                 .debug_list()
                 .entries(templates.iter().map(|x| x.0))
                 .finish(),
-            SourceBacking::Static { templates } => f
+            SourceBacking::Static { templates, .. } => f
                 .debug_list()
                 .entries(templates.iter().map(|x| x.0))
                 .finish(),
@@ -100,10 +103,40 @@ impl Source {
         Source {
             backing: SourceBacking::Static {
                 templates: HashMap::new(),
+                syntax: Default::default(),
             },
         }
     }
 
+    /// Sets the syntax for the source.
+    ///
+    /// See [`Syntax`](crate::Syntax) for more information.
+    #[cfg(feature = "custom_syntax")]
+    #[cfg_attr(docsrs, doc(cfg(feature = "custom_syntax")))]
+    pub fn set_syntax(&mut self, new_syntax: crate::custom_syntax::Syntax) -> Result<(), Error> {
+        match self.backing {
+            SourceBacking::Dynamic { ref mut syntax, .. }
+            | SourceBacking::Static { ref mut syntax, .. } => {
+                *syntax = ok!(new_syntax.compile());
+            }
+        }
+        Ok(())
+    }
+
+    /// Returns the current syntax.
+    #[cfg(feature = "custom_syntax")]
+    #[cfg_attr(docsrs, doc(cfg(feature = "custom_syntax")))]
+    pub fn syntax(&self) -> &crate::custom_syntax::Syntax {
+        &self._syntax_config().syntax
+    }
+
+    pub(crate) fn _syntax_config(&self) -> &SyntaxConfig {
+        match &self.backing {
+            SourceBacking::Dynamic { ref syntax, .. }
+            | SourceBacking::Static { ref syntax, .. } => syntax,
+        }
+    }
+
     /// Creates a source with a dynamic loader.
     ///
     /// When a source was created with the loader, the source gains the ability
@@ -138,6 +171,7 @@ impl Source {
                     Some(rv) => Ok(rv),
                     None => Err(Error::new_not_found(name)),
                 }),
+                syntax: Default::default(),
             },
         }
     }
@@ -192,7 +226,11 @@ impl Source {
         let tmpl = ok!(LoadedTemplate::try_new(
             owner,
             |(name, source)| -> Result<_, Error> {
-                CompiledTemplate::from_name_and_source(name.as_str(), source)
+                CompiledTemplate::from_name_and_source_with_syntax(
+                    name.as_str(),
+                    source,
+                    self._syntax_config().clone(),
+                )
             }
         ));
 
@@ -202,7 +240,9 @@ impl Source {
             } => {
                 templates.replace(name, Arc::new(tmpl));
             }
-            SourceBacking::Static { ref mut templates } => {
+            SourceBacking::Static {
+                ref mut templates, ..
+            } => {
                 templates.insert(name, Arc::new(tmpl));
             }
         }
@@ -213,29 +253,37 @@ impl Source {
     pub fn remove_template(&mut self, name: &str) {
         match &mut self.backing {
             SourceBacking::Dynamic { templates, .. } => templates.remove(name),
-            SourceBacking::Static { templates } => templates.remove(name),
+            SourceBacking::Static { templates, .. } => templates.remove(name),
         };
     }
 
     /// Gets a compiled template from the source.
     pub(crate) fn get_compiled_template(&self, name: &str) -> Result<&CompiledTemplate<'_>, Error> {
         match &self.backing {
-            SourceBacking::Dynamic { templates, loader } => Ok(ok!(templates.get_or_try_insert(
-                name,
-                || -> Result<_, Error> {
+            SourceBacking::Dynamic {
+                templates,
+                loader,
+                syntax,
+            } => Ok(
+                ok!(templates.get_or_try_insert(name, || -> Result<_, Error> {
+                    let syntax = syntax.clone();
                     let source = ok!(loader(name));
                     let owner = (name.to_owned(), source);
                     let tmpl = ok!(LoadedTemplate::try_new(
                         owner,
                         |(name, source)| -> Result<_, Error> {
-                            CompiledTemplate::from_name_and_source(name.as_str(), source)
+                            CompiledTemplate::from_name_and_source_with_syntax(
+                                name.as_str(),
+                                source,
+                                syntax,
+                            )
                         }
                     ));
                     Ok(Arc::new(tmpl))
-                }
-            ))
-            .borrow_dependent()),
-            SourceBacking::Static { templates } => templates
+                }))
+                .borrow_dependent(),
+            ),
+            SourceBacking::Static { templates, .. } => templates
                 .get(name)
                 .map(|value| value.borrow_dependent())
                 .ok_or_else(|| Error::new_not_found(name)),
diff --git a/minijinja/src/syntax.rs b/minijinja/src/syntax.rs
index 52637160..21a77459 100644
--- a/minijinja/src/syntax.rs
+++ b/minijinja/src/syntax.rs
@@ -26,6 +26,7 @@
 //!   - [`{% do %}`](#-do-)
 //!   - [`{% autoescape %}`](#-autoescape-)
 //!   - [`{% raw %}`](#-raw-)
+//! - [Custom Delimiters](#custom-delimiters)
 //!
 //! </details>
 //!
@@ -682,5 +683,40 @@
 //! </ul>
 //! {% endraw %}
 //! ```
+//!
+#![cfg_attr(
+    feature = "custom_syntax",
+    doc = r#"
+# Custom Delimiters
+
+When MiniJinja has been compiled with the `custom_syntax` feature (see
+[`Syntax`](crate::Syntax)), it's possible to reconfigure the delimiters of the
+templates.  This is generally not recommended but it's useful for situations
+where Jinja templates are used to generate files with a syntax that would be
+conflicting for Jinja.  With custom delimiters it can for instance be more
+convenient to generate LaTeX files:
+
+```
+# use minijinja::{Environment, Syntax};
+let mut environment = Environment::new();
+environment.set_syntax(Syntax {
+    block_start: "\\BLOCK{".into(),
+    block_end: "}".into(),
+    variable_start: "\\VAR{".into(),
+    variable_end: "}".into(),
+    comment_start: "\\#{".into(),
+    comment_end: "}".into(),
+}).unwrap();
+```
+
+And then a template might look like this instead:
 
-// this is just for docs
+```latex
+\begin{itemize}
+\BLOCK{for item in sequence}
+  \item \VAR{item}
+\BLOCK{endfor}
+\end{itemize}
+```
+"#
+)]
diff --git a/minijinja/src/template.rs b/minijinja/src/template.rs
index 9966308e..f14831b5 100644
--- a/minijinja/src/template.rs
+++ b/minijinja/src/template.rs
@@ -5,7 +5,8 @@ use serde::Serialize;
 
 use crate::compiler::codegen::CodeGenerator;
 use crate::compiler::instructions::Instructions;
-use crate::compiler::parser::parse;
+use crate::compiler::lexer::SyntaxConfig;
+use crate::compiler::parser::parse_with_syntax;
 use crate::environment::Environment;
 use crate::error::{attach_basic_debug_info, Error, ErrorKind};
 use crate::output::{Output, WriteWrapper};
@@ -182,21 +183,35 @@ impl<'env> fmt::Debug for CompiledTemplate<'env> {
 
 impl<'source> CompiledTemplate<'source> {
     /// Creates a compiled template from name and source.
+    #[cfg(feature = "unstable_machinery")]
     pub fn from_name_and_source(
         name: &'source str,
         source: &'source str,
     ) -> Result<CompiledTemplate<'source>, Error> {
-        attach_basic_debug_info(Self::_from_name_and_source_impl(name, source), source)
+        Self::from_name_and_source_with_syntax(name, source, Default::default())
     }
 
-    fn _from_name_and_source_impl(
+    /// Creates a compiled template from name and source using the given settings.
+    pub fn from_name_and_source_with_syntax(
         name: &'source str,
         source: &'source str,
+        syntax: SyntaxConfig,
+    ) -> Result<CompiledTemplate<'source>, Error> {
+        attach_basic_debug_info(
+            Self::_from_name_settings_and_source_with_syntax_impl(name, source, syntax),
+            source,
+        )
+    }
+
+    fn _from_name_settings_and_source_with_syntax_impl(
+        name: &'source str,
+        source: &'source str,
+        syntax: SyntaxConfig,
     ) -> Result<CompiledTemplate<'source>, Error> {
         // the parser/compiler combination can create constants in which case
         // we can probably benefit from the value optimization a bit.
         let _guard = value::value_optimization();
-        let ast = ok!(parse(source, name));
+        let ast = ok!(parse_with_syntax(source, name, syntax));
         let mut gen = CodeGenerator::new(name, source);
         gen.compile_stmt(&ast);
         let buffer_size_hint = gen.buffer_size_hint();
diff --git a/tools/minijinja-dis/Cargo.toml b/tools/minijinja-dis/Cargo.toml
index 10787d5a..7e609b3a 100644
--- a/tools/minijinja-dis/Cargo.toml
+++ b/tools/minijinja-dis/Cargo.toml
@@ -7,4 +7,4 @@ edition = "2021"
 
 [dependencies]
 argh = "0.1.8"
-minijinja = { path = "../../minijinja", features = ["unstable_machinery"] }
+minijinja = { path = "../../minijinja", features = ["unstable_machinery", "custom_syntax"] }


Test patch:
diff --git a/minijinja/tests/test_lexer.rs b/minijinja/tests/test_lexer.rs
index 3e9bea1f..7ed61f6e 100644
--- a/minijinja/tests/test_lexer.rs
+++ b/minijinja/tests/test_lexer.rs
@@ -40,7 +40,8 @@ fn lookup_span(source: &str, span: Span) -> &str {
 fn test_lexer() {
     insta::glob!("lexer-inputs/*.txt", |path| {
         let contents = std::fs::read_to_string(path).unwrap();
-        let tokens: Result<Vec<_>, _> = tokenize(&contents, false).collect();
+
+        let tokens: Result<Vec<_>, _> = tokenize(&contents, false, Default::default()).collect();
         insta::with_settings!({
             description => contents.trim_end(),
             omit_expression => true
diff --git a/minijinja/tests/test_templates.rs b/minijinja/tests/test_templates.rs
index 16142bbb..c4ff1b12 100644
--- a/minijinja/tests/test_templates.rs
+++ b/minijinja/tests/test_templates.rs
@@ -424,3 +424,24 @@ fn test_flattening_sub_item_shielded_print() {
         r#"{"bad": <invalid value: can only flatten structs and maps (got an enum)>}"#
     );
 }
+
+#[test]
+#[cfg(feature = "custom_syntax")]
+fn test_custom_syntax() {
+    let mut env = Environment::new();
+    env.set_syntax(minijinja::Syntax {
+        block_start: "{".into(),
+        block_end: "}".into(),
+        variable_start: "${".into(),
+        variable_end: "}".into(),
+        comment_start: "{*".into(),
+        comment_end: "*}".into(),
+    })
+    .unwrap();
+
+    // this on the other hand is okay
+    let value = env
+        .render_str("{for x in range(3)}${x}{endfor}{* nothing *}", ())
+        .unwrap();
+    assert_eq!(value, r"012");
+}