patx/relay-lang

Allow multiline expressions inside grouping delimiters

Commit a9f2b30 · Harrison Erd · 2026-02-10T15:03:26-05:00

Changeset
a9f2b30fb96aa7c415e1c97f32f29be1504c7c85
Parents
a138eba7106672e4f7ce0d94132cff040afa6c66

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/Cargo.toml b/Cargo.toml
index c3b3fd7..41b7867 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,3 +18,4 @@ indexmap = "2"
 async-recursion = "1"
 mongodb = { version = "2.8", features = ["tokio-runtime"] }
 futures = "0.3"
+minijinja = "2"
diff --git a/README.md b/README.md
index 01aa093..b8d120f 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ Relay is designed for building high-performance web services, APIs, and network
 - **HTTP Client**: Simple, async HTTP requests built-in
 - **MongoDB Integration**: Native async MongoDB support
 - **Session Management**: Built-in session handling with HttpOnly cookies
-- **Template Rendering**: Lightweight Jinja-style templating (`{{ variable }}`)
+- **Template Rendering**: MiniJinja-powered templates available in any string expression (`{{ variable }}`)
 - **Type Hints**: Optional runtime type checking for function parameters
 - **List Comprehensions**: Python-style inline list transforms with optional filtering
 - **Destructuring Assignment**: Unpack lists/strings/dicts into multiple variables
@@ -970,21 +970,32 @@ fn submit(data)
     return app.redirect("/success")
 ```
 
-#### Template Rendering
+#### Template Rendering (MiniJinja)
 
-Return strings with `{{ variable }}` syntax for simple templating:
+Relay uses **MiniJinja** (Rust implementation of Jinja2) for template interpolation in strings.
+
+Template strings are evaluated anywhere in the interpreter (not only in web handlers) when a string contains both `{{` and `}}`.
 
 ```relay
 name = "Relay"
 version = "0.1"
 
+title = "{{ name }} v{{ version }}"
+print(title)  // Relay v0.1
+
+items = ["a", "b", "c"]
+print("Count: {{ items | length }}")
+```
+
+Web handlers use the same engine:
+
+```relay
 @app.get("/")
 fn index()
     return "<h1>{{ name }} v{{ version }}</h1>"
-    // Renders: <h1>Relay v0.1</h1>
 ```
 
-Templates have access to all variables in the handler's scope.
+Templates can reference values in the current scope and support MiniJinja expressions/filters.
 
 #### `WebServer()`
 Create a web server instance.
diff --git a/src/main.rs b/src/main.rs
index 80e9ce0..8ceb08f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -31,8 +31,8 @@ use axum::{
 
 use async_recursion::async_recursion;
 use futures::{stream::TryStreamExt, SinkExt, StreamExt};
-use html_escape::encode_safe;
 use indexmap::IndexMap;
+use minijinja::{context, Environment};
 use mongodb::{
     bson::{self, Bson, Document},
     Client as MongoClient, Collection as MongoCollection, Database as MongoDatabase,
@@ -252,6 +252,7 @@ struct Lexer<'a> {
     indent_stack: Vec<usize>,
     pending_dedents: usize,
     at_line_start: bool,
+    grouping_depth: usize,
 }
 
 impl<'a> Lexer<'a> {
@@ -264,6 +265,7 @@ impl<'a> Lexer<'a> {
             indent_stack: vec![0],
             pending_dedents: 0,
             at_line_start: true,
+            grouping_depth: 0,
         }
     }
 
@@ -286,9 +288,12 @@ impl<'a> Lexer<'a> {
                 if has_tabs {
                     return Err(self.err("Tabs are not allowed (spaces only)"));
                 }
-                // blank line or comment-only line => ignore indentation
-                if self.peek_is_newline() || self.peek_is_comment_start() {
-                    // no indent changes
+
+                // Inside (), [] and {}, indentation/newlines are ignored for implicit line joining.
+                if self.grouping_depth > 0 {
+                    self.at_line_start = false;
+                } else if self.peek_is_newline() || self.peek_is_comment_start() {
+                    // blank line or comment-only line => ignore indentation
                 } else {
                     if spaces % 4 != 0 {
                         return Err(self.err("Indentation must be 4 spaces per level"));
@@ -309,8 +314,8 @@ impl<'a> Lexer<'a> {
                             continue;
                         }
                     }
+                    self.at_line_start = false;
                 }
-                self.at_line_start = false;
             }
 
             self.skip_ws_inline();
@@ -331,34 +336,48 @@ impl<'a> Lexer<'a> {
             let c = self.peek_char().unwrap();
             if c == '\n' {
                 self.advance_char();
-                out.push(self.tok(Tok::Newline));
                 self.at_line_start = true;
+                if self.grouping_depth == 0 {
+                    out.push(self.tok(Tok::Newline));
+                }
                 continue;
             }
 
             let kind = match c {
                 '(' => {
                     self.advance_char();
+                    self.grouping_depth += 1;
                     Tok::LParen
                 }
                 ')' => {
                     self.advance_char();
+                    if self.grouping_depth > 0 {
+                        self.grouping_depth -= 1;
+                    }
                     Tok::RParen
                 }
                 '[' => {
                     self.advance_char();
+                    self.grouping_depth += 1;
                     Tok::LBracket
                 }
                 ']' => {
                     self.advance_char();
+                    if self.grouping_depth > 0 {
+                        self.grouping_depth -= 1;
+                    }
                     Tok::RBracket
                 }
                 '{' => {
                     self.advance_char();
+                    self.grouping_depth += 1;
                     Tok::LBrace
                 }
                 '}' => {
                     self.advance_char();
+                    if self.grouping_depth > 0 {
+                        self.grouping_depth -= 1;
+                    }
                     Tok::RBrace
                 }
                 ',' => {
@@ -1456,27 +1475,15 @@ impl Env {
 
 // ========================= Template strings =========================
 
-fn render_template(s: &str, locals: &HashMap<String, Value>) -> String {
-    // Replace {{ key }} with HTML-escaped value.repr()
-    let mut out = String::new();
-    let mut i = 0;
-    while let Some(start) = s[i..].find("{{") {
-        let start = i + start;
-        out.push_str(&s[i..start]);
-        if let Some(end) = s[start + 2..].find("}}") {
-            let end = start + 2 + end;
-            let key = s[start + 2..end].trim();
-            if let Some(v) = locals.get(key) {
-                out.push_str(&encode_safe(&v.repr()));
-            }
-            i = end + 2;
-        } else {
-            out.push_str(&s[start..]);
-            return out;
-        }
+fn render_template(s: &str, locals: &HashMap<String, Value>) -> RResult<String> {
+    let mut ctx = serde_json::Map::new();
+    for (key, value) in locals {
+        ctx.insert(key.clone(), value_to_json(value));
     }
-    out.push_str(&s[i..]);
-    out
+
+    Environment::new()
+        .render_str(s, context!(..ctx))
+        .map_err(|e| RelayError::Runtime(format!("Template render error: {e}")))
 }
 
 // ========================= Evaluator (async, implicit Deferred) =========================
@@ -1827,7 +1834,7 @@ impl Evaluator {
                         let env = self.env.lock().await;
                         env.snapshot_merged()
                     };
-                    Ok(Value::Str(render_template(s, &locals)))
+                    Ok(Value::Str(render_template(s, &locals)?))
                 } else {
                     Ok(Value::Str(s.clone()))
                 }