Repository: cobalt-org/liquid-rust (rust)

Issue:
Render Tag
Hi, sorry if this is a misunderstanding on my part.

But looking [online](https://shopify.github.io/liquid/tags/template/#include), the "include" tag is deprecated and the [render](https://shopify.github.io/liquid/tags/template/#render) tag should be used instead.

I was wondering what the status of this is and if there is any intention to add the new tag? Thanks

Pull request description:
feat: Add render tag
The main functionality missing is `as <name>` should be optional,
defaulting to the partial's file name.

For background, see
- https://shopify.dev/docs/api/liquid/tags/render
- https://github.com/Shopify/liquid/blob/master/lib/liquid/tags/render.rb
- https://github.com/Shopify/liquid/blob/master/test/integration/tags/render_tag_test.rb

Fixes #471

Patch:
diff --git a/crates/core/src/parser/filter.rs b/crates/core/src/parser/filter.rs
index 795e593..b35a08b 100644
--- a/crates/core/src/parser/filter.rs
+++ b/crates/core/src/parser/filter.rs
@@ -110,7 +110,7 @@ pub struct FilterArguments<'a> {
 /// }
 ///
 /// impl Filter for AtLeastFilter {
-///     fn evaluate(&self, input: &ValueViwe, runtime: &dyn Runtime) -> Result<Value> {
+///     fn evaluate(&self, input: &ValueView, runtime: &dyn Runtime) -> Result<Value> {
 ///         // Evaluate the `FilterParameters`
 ///         let args = self.args.evaluate(runtime)?;
 ///
diff --git a/crates/core/src/runtime/stack.rs b/crates/core/src/runtime/stack.rs
index e046011..e4c6335 100644
--- a/crates/core/src/runtime/stack.rs
+++ b/crates/core/src/runtime/stack.rs
@@ -2,6 +2,8 @@ use crate::error::Error;
 use crate::error::Result;
 use crate::model::{Object, ObjectView, ScalarCow, Value, ValueCow, ValueView};
 
+use super::Registers;
+
 /// Layer variables on top of the existing runtime
 pub struct StackFrame<P, O> {
     parent: P,
@@ -89,12 +91,14 @@ impl<P: super::Runtime, O: ObjectView> super::Runtime for StackFrame<P, O> {
     }
 }
 
-pub(crate) struct GlobalFrame<P> {
+/// A stack frame that only provides a sandboxed set of globals
+pub struct GlobalFrame<P> {
     parent: P,
     data: std::cell::RefCell<Object>,
 }
 
 impl<P: super::Runtime> GlobalFrame<P> {
+    /// Override globals for `parent`
     pub fn new(parent: P) -> Self {
         Self {
             parent,
@@ -238,3 +242,176 @@ impl<P: super::Runtime> super::Runtime for IndexFrame<P> {
         self.parent.registers()
     }
 }
+
+/// A [`StackFrame`] where variables are not recursively searched for,
+/// However, you can still access the parent's partials.
+pub struct SandboxedStackFrame<P, O> {
+    parent: P,
+    name: Option<crate::model::KString>,
+    data: O,
+    registers: Registers,
+}
+
+impl<P: super::Runtime, O: ObjectView> SandboxedStackFrame<P, O> {
+    /// Create a new [`SandboxedStackFrame`] from a parent and some data
+    pub fn new(parent: P, data: O) -> Self {
+        Self {
+            parent,
+            name: None,
+            data,
+            registers: Default::default(),
+        }
+    }
+
+    /// Name the current context
+    pub fn with_name<S: Into<crate::model::KString>>(mut self, name: S) -> Self {
+        self.name = Some(name.into());
+        self
+    }
+}
+
+impl<P: super::Runtime, O: ObjectView> super::Runtime for SandboxedStackFrame<P, O> {
+    fn partials(&self) -> &dyn super::PartialStore {
+        self.parent.partials()
+    }
+
+    fn name(&self) -> Option<crate::model::KStringRef<'_>> {
+        self.name
+            .as_ref()
+            .map(|n| n.as_ref())
+            .or_else(|| self.parent.name())
+    }
+
+    fn roots(&self) -> std::collections::BTreeSet<crate::model::KStringCow<'_>> {
+        let mut roots = std::collections::BTreeSet::new();
+        roots.extend(self.data.keys());
+        roots
+    }
+
+    fn try_get(&self, path: &[ScalarCow<'_>]) -> Option<ValueCow<'_>> {
+        let key = path.first()?;
+        let key = key.to_kstr();
+        let data = &self.data;
+        data.get(key.as_str())
+            .and_then(|_| crate::model::try_find(data.as_value(), path))
+    }
+
+    fn get(&self, path: &[ScalarCow<'_>]) -> Result<ValueCow<'_>> {
+        let key = path.first().ok_or_else(|| {
+            Error::with_msg("Unknown variable").context("requested variable", "nil")
+        })?;
+        let key = key.to_kstr();
+        let data = &self.data;
+        data.get(key.as_str())
+            .and_then(|_| crate::model::try_find(data.as_value(), path))
+            .map(|v| v.into_owned().into())
+            .ok_or_else(|| Error::with_msg("Unknown variable").context("requested variable", key))
+    }
+
+    fn set_global(
+        &self,
+        name: crate::model::KString,
+        val: crate::model::Value,
+    ) -> Option<crate::model::Value> {
+        self.parent.set_global(name, val)
+    }
+
+    fn set_index(&self, name: crate::model::KString, val: Value) -> Option<Value> {
+        self.parent.set_index(name, val)
+    }
+
+    fn get_index<'a>(&'a self, name: &str) -> Option<ValueCow<'a>> {
+        self.parent.get_index(name)
+    }
+
+    fn registers(&self) -> &super::Registers {
+        &self.registers
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::{runtime::RuntimeBuilder, Runtime};
+
+    use super::*;
+
+    #[test]
+    fn test_opaque_stack_frame_try_get() {
+        let globals = {
+            let mut o = Object::new();
+            o.insert("a".into(), Value::Scalar(1i64.into()));
+            o
+        };
+        let runtime = RuntimeBuilder::new().set_globals(&globals).build();
+        let opaque_stack_frame = SandboxedStackFrame::new(&runtime, {
+            let mut o = Object::new();
+            o.insert("b".into(), Value::Scalar(2i64.into()));
+            o
+        });
+
+        // Testing that you can access variables in the current frame, but not the parent
+        assert!(opaque_stack_frame.try_get(&["a".into()]).is_none());
+        assert!(opaque_stack_frame.try_get(&["b".into()]).is_some());
+
+        let stack_frame = StackFrame::new(opaque_stack_frame, {
+            let mut o = Object::new();
+            o.insert("c".into(), Value::Scalar(1i64.into()));
+            o
+        });
+
+        // Testing that a child of a OpaqueStackFrame can access access OpaqueStackFrame's variables but not the parent
+        assert!(stack_frame.try_get(&["a".into()]).is_none());
+        assert!(stack_frame.try_get(&["b".into()]).is_some());
+        assert!(stack_frame.try_get(&["c".into()]).is_some());
+    }
+
+    #[test]
+    fn test_opaque_stack_frame_get() {
+        let globals = {
+            let mut o = Object::new();
+            o.insert("a".into(), Value::Scalar(1i64.into()));
+            o
+        };
+        let runtime = RuntimeBuilder::new().set_globals(&globals).build();
+        let opaque_stack_frame = SandboxedStackFrame::new(&runtime, {
+            let mut o = Object::new();
+            o.insert("b".into(), Value::Scalar(2i64.into()));
+            o
+        });
+
+        // Testing that you can access variables in the current frame, but not the parent
+        assert!(opaque_stack_frame.get(&["a".into()]).is_err());
+        assert!(opaque_stack_frame.get(&["b".into()]).is_ok());
+
+        let stack_frame = StackFrame::new(opaque_stack_frame, {
+            let mut o = Object::new();
+            o.insert("c".into(), Value::Scalar(1i64.into()));
+            o
+        });
+
+        // Testing that a child of a OpaqueStackFrame can access access OpaqueStackFrame's variables but not the parent
+        assert!(stack_frame.get(&["a".into()]).is_err());
+        assert!(stack_frame.get(&["b".into()]).is_ok());
+        assert!(stack_frame.get(&["c".into()]).is_ok());
+    }
+
+    #[test]
+    fn test_opaque_stack_frame_roots() {
+        let globals = {
+            let mut o = Object::new();
+            o.insert("a".into(), Value::Scalar(1i64.into()));
+            o
+        };
+        let runtime = RuntimeBuilder::new().set_globals(&globals).build();
+        let opaque_stack_frame = SandboxedStackFrame::new(&runtime, {
+            let mut o = Object::new();
+            o.insert("b".into(), Value::Scalar(2i64.into()));
+            o
+        });
+        let roots = opaque_stack_frame.roots();
+
+        // Testing that the roots are not copied from the parent
+        assert!(!roots.contains("a"));
+        assert!(roots.contains("b"));
+    }
+}
diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs
index c91923d..8922648 100644
--- a/crates/derive/src/lib.rs
+++ b/crates/derive/src/lib.rs
@@ -45,7 +45,7 @@ use proc_macro::TokenStream;
 ///
 /// If you want to only accept a certain type of `Value` for a given argument,
 /// you may mark its type with `arg_type = "..."`. This will take away the burden
-/// and boilerplate of handling errors and unwraping the liquid `Value` into a rust
+/// and boilerplate of handling errors and unwrapping the liquid `Value` into a rust
 /// type.
 ///
 /// Right now, there is a default `arg_type`, "any", that accepts any value, as well
diff --git a/crates/lib/src/stdlib/blocks/for_block.rs b/crates/lib/src/stdlib/blocks/for_block.rs
index bb66508..2b03ee2 100644
--- a/crates/lib/src/stdlib/blocks/for_block.rs
+++ b/crates/lib/src/stdlib/blocks/for_block.rs
@@ -226,7 +226,7 @@ impl Renderable for For {
 }
 
 #[derive(Debug, Clone, ValueView, ObjectView)]
-struct ForloopObject<'p> {
+pub struct ForloopObject<'p> {
     length: i64,
     parentloop: Option<&'p dyn ValueView>,
     index0: i64,
@@ -238,7 +238,7 @@ struct ForloopObject<'p> {
 }
 
 impl<'p> ForloopObject<'p> {
-    fn new(i: usize, len: usize) -> Self {
+    pub fn new(i: usize, len: usize) -> Self {
         let i = i as i64;
         let len = len as i64;
         let first = i == 0;
@@ -520,7 +520,7 @@ fn evaluate_attr(attr: &Option<Expression>, runtime: &dyn Runtime) -> Result<Opt
 }
 
 #[derive(Clone, Debug)]
-enum RangeExpression {
+pub enum RangeExpression {
     Array(Expression),
     Counted(Expression, Expression),
 }
@@ -554,7 +554,7 @@ impl fmt::Display for RangeExpression {
 }
 
 #[derive(Clone, Debug)]
-enum Range<'r> {
+pub enum Range<'r> {
     Array(ValueCow<'r>),
     Counted(i64, i64),
 }
diff --git a/crates/lib/src/stdlib/blocks/mod.rs b/crates/lib/src/stdlib/blocks/mod.rs
index c1dd5f3..964142e 100644
--- a/crates/lib/src/stdlib/blocks/mod.rs
+++ b/crates/lib/src/stdlib/blocks/mod.rs
@@ -15,3 +15,7 @@ pub use self::if_block::IfBlock;
 pub use self::if_block::UnlessBlock;
 pub use self::ifchanged_block::IfChangedBlock;
 pub use self::raw_block::RawBlock;
+
+pub use self::for_block::ForloopObject;
+pub use self::for_block::Range;
+pub use self::for_block::RangeExpression;
diff --git a/crates/lib/src/stdlib/mod.rs b/crates/lib/src/stdlib/mod.rs
index f3598a5..6ac6642 100644
--- a/crates/lib/src/stdlib/mod.rs
+++ b/crates/lib/src/stdlib/mod.rs
@@ -5,3 +5,7 @@ mod tags;
 pub use blocks::*;
 pub use filters::*;
 pub use tags::*;
+
+pub use blocks::ForloopObject;
+pub use blocks::Range;
+pub use blocks::RangeExpression;
diff --git a/crates/lib/src/stdlib/tags/mod.rs b/crates/lib/src/stdlib/tags/mod.rs
index e26f6e6..a65af77 100644
--- a/crates/lib/src/stdlib/tags/mod.rs
+++ b/crates/lib/src/stdlib/tags/mod.rs
@@ -3,6 +3,7 @@ mod cycle_tag;
 mod include_tag;
 mod increment_tags;
 mod interrupt_tags;
+mod render_tag;
 
 pub use self::assign_tag::AssignTag;
 pub use self::cycle_tag::CycleTag;
@@ -11,3 +12,4 @@ pub use self::increment_tags::DecrementTag;
 pub use self::increment_tags::IncrementTag;
 pub use self::interrupt_tags::BreakTag;
 pub use self::interrupt_tags::ContinueTag;
+pub use self::render_tag::RenderTag;
diff --git a/crates/lib/src/stdlib/tags/render_tag.rs b/crates/lib/src/stdlib/tags/render_tag.rs
new file mode 100644
index 0000000..3fb9ebf
--- /dev/null
+++ b/crates/lib/src/stdlib/tags/render_tag.rs
@@ -0,0 +1,484 @@
+use std::io::Write;
+
+use liquid_core::error::ResultLiquidExt;
+use liquid_core::model::KString;
+use liquid_core::parser::TryMatchToken;
+use liquid_core::runtime::GlobalFrame;
+use liquid_core::runtime::Interrupt;
+use liquid_core::runtime::InterruptRegister;
+use liquid_core::runtime::SandboxedStackFrame;
+use liquid_core::Expression;
+use liquid_core::Language;
+use liquid_core::Renderable;
+use liquid_core::Runtime;
+use liquid_core::ValueView;
+use liquid_core::{Error, Result};
+use liquid_core::{ParseTag, TagReflection, TagTokenIter};
+
+use crate::stdlib::ForloopObject;
+use crate::stdlib::RangeExpression;
+
+#[derive(Copy, Clone, Debug, Default)]
+pub struct RenderTag;
+
+impl RenderTag {
+    pub fn new() -> Self {
+        Self::default()
+    }
+}
+
+impl TagReflection for RenderTag {
+    fn tag(&self) -> &str {
+        "render"
+    }
+
+    fn description(&self) -> &str {
+        "insert the rendered content of another template in this template"
+    }
+}
+
+impl ParseTag for RenderTag {
+    fn parse(
+        &self,
+        mut arguments: TagTokenIter,
+        _options: &Language,
+    ) -> Result<Box<dyn Renderable>> {
+        let partial = arguments.expect_next("Identifier or literal expected.")?;
+
+        let partial = partial.expect_value().into_result()?;
+
+        let mut token = arguments.next();
+        let mut vars: Vec<(KString, Expression)> = Vec::new();
+        let mut for_ = None;
+        match token.as_ref().map(|t| t.as_str()) {
+            Some("with") => {
+                let val = arguments
+                    .expect_next("expected value")?
+                    .expect_value()
+                    .into_result()?;
+
+                arguments
+                    .expect_next("\"as\" expected.")?
+                    .expect_str("as")
+                    .into_result_custom_msg("expected \"as\" to be used for the assignment")?;
+
+                vars.push((
+                    arguments
+                        .expect_next("expected identifier")?
+                        .expect_identifier()
+                        .into_result()?
+                        .to_string()
+                        .into(),
+                    val,
+                ));
+                token = arguments.next();
+            }
+            Some("for") => {
+                let range = arguments.expect_next("Array or range expected.")?;
+                let range = match range.expect_value() {
+                    TryMatchToken::Matches(array) => RangeExpression::Array(array),
+                    TryMatchToken::Fails(range) => match range.expect_range() {
+                        TryMatchToken::Matches((start, stop)) => {
+                            RangeExpression::Counted(start, stop)
+                        }
+                        TryMatchToken::Fails(range) => return range.raise_error().into_err(),
+                    },
+                };
+
+                arguments
+                    .expect_next("\"as\" expected")?
+                    .expect_str("as")
+                    .into_result_custom_msg("\"as\" expected")?;
+
+                let var_name = arguments
+                    .expect_next("Identifier expected.")?
+                    .expect_identifier()
+                    .into_result()?
+                    .to_string()
+                    .into();
+
+                token = arguments.next();
+                for_ = Some((range, var_name));
+            }
+            _ => {}
+        };
+
+        while let Some(t) = token {
+            t.expect_str(",")
+                .into_result_custom_msg("`,` is needed to separate variables")?;
+            token = arguments.next();
+            let Some(t) = token else {break;};
+
+            let id = t.expect_identifier().into_result()?.to_string();
+
+            arguments
+                .expect_next("\":\" expected.")?
+                .expect_str(":")
+                .into_result_custom_msg("expected \":\" to be used for the assignment")?;
+
+            vars.push((
+                id.into(),
+                arguments
+                    .expect_next("expected value")?
+                    .expect_value()
+                    .into_result()?,
+            ));
+
+            token = arguments.next();
+        }
+
+        arguments.expect_nothing()?;
+
+        Ok(Box::new(Render {
+            partial,
+            for_,
+            vars,
+        }))
+    }
+
+    fn reflection(&self) -> &dyn TagReflection {
+        self
+    }
+}
+
+#[derive(Debug)]
+struct Render {
+    partial: Expression,
+    for_: Option<(RangeExpression, KString)>,
+    vars: Vec<(KString, Expression)>,
+}
+
+impl Renderable for Render {
+    fn render_to(&self, writer: &mut dyn Write, runtime: &dyn Runtime) -> Result<()> {
+        let value = self.partial.evaluate(runtime)?;
+        if !value.is_scalar() {
+            return Error::with_msg("Can only `include` strings")
+                .context("partial", format!("{}", value.source()))
+                .into_err();
+        }
+        let name = value.to_kstr().into_owned();
+
+        if let Some((range, var_name)) = &self.for_ {
+            let range = range
+                .evaluate(runtime)
+                .trace_with(|| format!("{{% render {} %}}", self.partial).into())?;
+            let array = range.evaluate()?;
+
+            if !array.is_empty() {
+                let len = array.len();
+                for (i, v) in array.into_iter().enumerate() {
+                    let forloop = ForloopObject::new(i, len);
+
+                    let mut root = std::collections::HashMap::new();
+                    for (id, val) in &self.vars {
+                        let value = val
+                            .try_evaluate(runtime)
+                            .ok_or_else(|| Error::with_msg("failed to evaluate value"))?;
+
+                        root.insert(id.as_ref(), value);
+                    }
+                    root.insert("forloop".into(), liquid_core::ValueCow::Borrowed(&forloop));
+                    root.insert(var_name.as_ref(), v);
+
+                    let scope = GlobalFrame::new(SandboxedStackFrame::new(runtime, &root));
+
+                    let partial = scope
+                        .partials()
+                        .get(&name)
+                        .or_else(|_| scope.partials().get(&format!("{name}.liquid")))
+                        .trace_with(|| format!("{{% render {} %}}", self.partial).into())?;
+
+                    partial
+                        .render_to(writer, &scope)
+                        .trace_with(|| format!("{{% render {} %}}", self.partial).into())
+                        .context_key("index")
+                        .value_with(|| format!("{}", i + 1).into())?;
+
+                    // given that we're at the end of the loop body
+                    // already, dealing with a `continue` signal is just
+                    // clearing the interrupt and carrying on as normal. A
+                    // `break` requires some special handling, though.
+                    let current_interrupt =
+                        scope.registers().get_mut::<InterruptRegister>().reset();
+                    if let Some(Interrupt::Break) = current_interrupt {
+                        break;
+                    }
+                }
+            }
+        } else {
+            let mut root = std::collections::HashMap::new();
+            for (id, val) in &self.vars {
+                let value = val
+                    .try_evaluate(runtime)
+                    .ok_or_else(|| Error::with_msg("failed to evaluate value"))?;
+
+                root.insert(id.as_ref(), value);
+            }
+
+            let scope = GlobalFrame::new(SandboxedStackFrame::new(runtime, &root));
+
+            let partial = scope
+                .partials()
+                .get(&name)
+                .or_else(|_| scope.partials().get(&format!("{name}.liquid")))
+                .trace_with(|| format!("{{% render {} %}}", self.partial).into())?;
+
+            partial
+                .render_to(writer, &scope)
+                .trace_with(|| format!("{{% render {} %}}", self.partial).into())
+                .context_key_with(|| self.partial.to_string().into())
+                .value_with(|| name.to_string().into())?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use liquid_core::{
+        parser,
+        partials::{self, PartialCompiler},
+        runtime::{self, RuntimeBuilder},
+        Display_filter, Filter, FilterReflection, ParseFilter, Value,
+    };
+
+    use crate::stdlib::{self, AssignTag};
+
+    use super::*;
+
+    #[derive(Default, Debug, Clone, Copy)]
+    struct TestSource;
+
+    impl partials::PartialSource for TestSource {
+        fn contains(&self, _name: &str) -> bool {
+            true
+        }
+
+        fn names(&self) -> Vec<&str> {
+            vec![]
+        }
+
+        fn try_get<'a>(&'a self, name: &str) -> Option<std::borrow::Cow<'a, str>> {
+            match name {
+                "example.txt" => Some(r#"{{'whooo' | size}}{%comment%}What happens{%endcomment%} {%if num < numTwo%}wat{%else%}wot{%endif%} {%if num > numTwo%}wat{%else%}wot{%endif%}"#.into()),
+                "example_var.txt" => Some(r#"{{example_var}}"#.into()),
+                "example_multi_var.txt" => Some(r#"{{example_var}} {{example}}"#.into()),
+                "missing_extension.liquid" => Some(r#"{{example_var}}"#.into()),
+                _ => None
+            }
+        }
+    }
+
+    fn options() -> Language {
+        let mut options = Language::default();
+        options
+            .tags
+            .register("render".to_string(), RenderTag.into());
+        options
+            .tags
+            .register("assign".to_string(), AssignTag.into());
+        options
+            .blocks
+            .register("comment".to_string(), stdlib::CommentBlock.into());
+        options
+            .blocks
+            .register("if".to_string(), stdlib::IfBlock.into());
+        options
+    }
+
+    #[derive(Clone, ParseFilter, FilterReflection)]
+    #[filter(name = "size", description = "tests helper", parsed(SizeFilter))]
+    pub struct SizeFilterParser;
+
+    #[derive(Debug, Default, Display_filter)]
+    #[name = "size"]
+    pub struct SizeFilter;
+
+    impl Filter for SizeFilter {
+        fn evaluate(&self, input: &dyn ValueView, _runtime: &dyn Runtime) -> Result<Value> {
+            if let Some(x) = input.as_scalar() {
+                Ok(Value::scalar(x.to_kstr().len() as i64))
+            } else if let Some(x) = input.as_array() {
+                Ok(Value::scalar(x.size()))
+            } else if let Some(x) = input.as_object() {
+                Ok(Value::scalar(x.size()))
+            } else {
+                Ok(Value::scalar(0i64))
+            }
+        }
+    }
+
+    #[test]
+    fn render_for() {
+        let text = "{% render 'example_var.txt' for (0..5) as example_var %}";
+        let options = options();
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime).unwrap();
+        assert_eq!(output, "012345");
+    }
+
+    #[test]
+    fn render_with() {
+        let text = "{% render 'example_var.txt' with \"hello\" as example_var %}";
+        let options = options();
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime).unwrap();
+        assert_eq!(output, "hello");
+    }
+
+    #[test]
+    fn render_scope() {
+        let text = "{% assign numTwo = 10 %}{% render 'example.txt', num: 5 %}";
+        let mut options = options();
+        options
+            .filters
+            .register("size".to_string(), Box::new(SizeFilterParser));
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime);
+        assert!(output.is_err());
+    }
+
+    #[test]
+    fn render_tag_quotes() {
+        let text = "{% render 'example.txt', num: 5, numTwo: 10 %}";
+        let mut options = options();
+        options
+            .filters
+            .register("size".to_string(), Box::new(SizeFilterParser));
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime).unwrap();
+        assert_eq!(output, "5 wat wot");
+    }
+
+    #[test]
+    fn render_variable() {
+        let text = "{% render 'example_var.txt', example_var:\"hello\" %}";
+        let options = options();
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime).unwrap();
+        assert_eq!(output, "hello");
+    }
+
+    #[test]
+    fn include_multiple_variables() {
+        let text = "{% render 'example_multi_var.txt', example_var:\"hello\", example:\"world\" %}";
+        let options = options();
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime).unwrap();
+        assert_eq!(output, "hello world");
+    }
+
+    #[test]
+    fn include_multiple_variables_trailing_comma() {
+        let text = "{% render 'example_multi_var.txt', example_var:\"hello\", example:\"dogs\", %}";
+        let options = options();
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime).unwrap();
+        assert_eq!(output, "hello dogs");
+    }
+
+    #[test]
+    fn no_file() {
+        let text = "{% render 'file_does_not_exist.liquid' %}";
+        let mut options = options();
+        options
+            .filters
+            .register("size".to_string(), Box::new(SizeFilterParser));
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        runtime.set_global("num".into(), Value::scalar(5f64));
+        runtime.set_global("numTwo".into(), Value::scalar(10f64));
+        let output = template.render(&runtime);
+        assert!(output.is_err());
+    }
+
+    #[test]
+    fn without_extension() {
+        let text = "{% render 'missing_extension', example_var:\"hello\" %}";
+        let options = options();
+        let template = parser::parse(text, &options)
+            .map(runtime::Template::new)
+            .unwrap();
+
+        let partials = partials::OnDemandCompiler::<TestSource>::empty()
+            .compile(::std::sync::Arc::new(options))
+            .unwrap();
+        let runtime = RuntimeBuilder::new()
+            .set_partials(partials.as_ref())
+            .build();
+        let output = template.render(&runtime).unwrap();
+        assert_eq!(output, "hello");
+    }
+}
diff --git a/src/parser.rs b/src/parser.rs
index 9df83ec..212f038 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -51,6 +51,7 @@ where
             .tag(stdlib::IncludeTag)
             .tag(stdlib::IncrementTag)
             .tag(stdlib::DecrementTag)
+            .tag(stdlib::RenderTag)
             .block(stdlib::RawBlock)
             .block(stdlib::IfBlock)
             .block(stdlib::UnlessBlock)


Test patch:
diff --git a/tests/conformance_ruby/tags/mod.rs b/tests/conformance_ruby/tags/mod.rs
index b444509..6db2aa7 100644
--- a/tests/conformance_ruby/tags/mod.rs
+++ b/tests/conformance_ruby/tags/mod.rs
@@ -5,6 +5,7 @@ mod if_else_tag_test;
 mod include_tag_test;
 mod increment_tag_test;
 mod raw_tag_test;
+mod render_tag_test;
 mod standard_tag_test;
 mod statements_test;
 mod table_row_test;
diff --git a/tests/conformance_ruby/tags/render_tag_test.rs b/tests/conformance_ruby/tags/render_tag_test.rs
new file mode 100644
index 0000000..c7d9213
--- /dev/null
+++ b/tests/conformance_ruby/tags/render_tag_test.rs
@@ -0,0 +1,349 @@
+fn liquid(partials: liquid::Object) -> liquid::Parser {
+    let mut source = liquid::partials::InMemorySource::new();
+    for (key, value) in partials {
+        source.add(key.as_str(), value.into_scalar().unwrap().into_cow_str());
+    }
+    let partials = liquid::partials::EagerCompiler::new(source);
+    liquid::ParserBuilder::with_stdlib()
+        .partials(partials)
+        .build()
+        .unwrap()
+}
+
+#[test]
+fn test_render_with_no_arguments() {
+    assert_template_result!(
+        "rendered content",
+        "{% render 'source' %}",
+        o!({}),
+        liquid(o!({ "source": "rendered content" })),
+    );
+}
+
+#[test]
+fn test_render_tag_looks_for_file_system_in_registers_first() {
+    assert_template_result!(
+        "from register file system",
+        "{% render 'pick_a_source' %}",
+        o!({}),
+        liquid(o!({ "pick_a_source": "from register file system" })),
+    );
+}
+
+#[test]
+fn test_render_passes_named_arguments_into_inner_scope() {
+    assert_template_result!(
+        "My Product",
+        "{% render 'product', inner_product: outer_product %}",
+        o!({ "outer_product": { "title": "My Product" } }),
+        liquid(o!({ "product": "{{ inner_product.title }}" })),
+    );
+}
+
+#[test]
+fn test_render_accepts_literals_as_arguments() {
+    assert_template_result!(
+        "123",
+        "{% render 'snippet', price: 123 %}",
+        o!({}),
+        liquid(o!({ "snippet": "{{ price }}" })),
+    );
+}
+
+#[test]
+fn test_render_accepts_multiple_named_arguments() {
+    assert_template_result!(
+        "1 2",
+        "{% render 'snippet', one: 1, two: 2 %}",
+        o!({}),
+        liquid(o!({ "snippet": "{{ one }} {{ two }}" })),
+    );
+}
+
+#[test]
+fn test_render_does_not_inherit_parent_scope_variables() {
+    assert_template_result!(
+        "",
+        "{% assign outer_variable = 'should not be visible' %}{% render 'snippet' %}",
+        o!({}),
+        liquid(o!({ "snippet": "{% if outer_variable %}broken{% endif %}" })),
+    );
+}
+
+#[test]
+fn test_render_does_not_inherit_variable_with_same_name_as_snippet() {
+    assert_template_result!(
+        "",
+        "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}",
+        o!({}),
+        liquid(o!({ "snippet": "{% if snippet %}broken{% endif %}" })),
+    );
+}
+
+#[test]
+fn test_render_does_not_mutate_parent_scope() {
+    assert_template_result!(
+        "",
+        "{% render 'snippet' %}{% if inner %}broken{% endif %}",
+        o!({}),
+        liquid(o!({ "snippet": "{% assign inner = 1 %}" })),
+    );
+}
+
+#[test]
+fn test_nested_render_tag() {
+    assert_template_result!(
+        "one two",
+        "{% render 'one' %}",
+        o!({}),
+        liquid(o!({
+          "one": "one {% render 'two' %}",
+          "two": "two",
+        })),
+    );
+}
+
+/*
+#[test]
+fn test_recursively_rendered_template_does_not_produce_endless_loop() {
+  Liquid::Template.file_system = StubFileSystem.new("loop": "{% render 'loop' %}")
+
+  assert_raises(Liquid::StackLevelError) do
+    Template.parse("{% render 'loop' %}").render!
+  end
+}
+
+#[test]
+fn test_sub_contexts_count_towards_the_same_recursion_limit() {
+  Liquid::Template.file_system = StubFileSystem.new(
+    "loop_render": "{% render 'loop_render' %}",
+  )
+  assert_raises(Liquid::StackLevelError) do
+    Template.parse("{% render 'loop_render' %}").render!
+  end
+}
+
+#[test]
+fn test_dynamically_chosen_templates_are_not_allowed() {
+  assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
+}
+
+#[test]
+fn test_include_tag_caches_second_read_of_same_partial() {
+  file_system = StubFileSystem.new("snippet": "echo")
+  assert_equal(
+    "echoecho",
+    Template.parse("{% render 'snippet' %}{% render 'snippet' %}")
+    .render!({}, registers: { file_system: file_system }),
+  )
+  assert_equal(1, file_system.file_read_count)
+}
+
+#[test]
+fn test_render_tag_doesnt_cache_partials_across_renders() {
+  file_system = StubFileSystem.new("snippet": "my message")
+
+  assert_equal(
+    "my message",
+    Template.parse("{% include 'snippet' %}").render!({}, registers: { file_system: file_system }),
+  )
+  assert_equal(1, file_system.file_read_count)
+
+  assert_equal(
+    "my message",
+    Template.parse("{% include 'snippet' %}").render!({}, registers: { file_system: file_system }),
+  )
+  assert_equal(2, file_system.file_read_count)
+}
+*/
+
+#[test]
+fn test_render_tag_within_if_statement() {
+    assert_template_result!(
+        "my message",
+        "{% if true %}{% render 'snippet' %}{% endif %}",
+        o!({}),
+        liquid(o!({ "snippet": "my message" })),
+    );
+}
+
+#[test]
+fn test_break_through_render() {
+    let liquid = liquid(o!({ "break": "{% break %}" }));
+    assert_template_result!(
+        "1",
+        "{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}",
+        o!({}),
+        liquid
+    );
+    assert_template_result!(
+        "112233",
+        "{% for i in (1..3) %}{{ i }}{% render 'break' %}{{ i }}{% endfor %}",
+        o!({}),
+        liquid
+    );
+}
+
+#[test]
+#[should_panic] // `increment` without a variable is not supported yet
+fn test_increment_is_isolated_between_renders() {
+    assert_template_result!(
+        "010",
+        "{% increment %}{% increment %}{% render 'incr' %}",
+        o!({}),
+        liquid(o!({ "incr": "{% increment %}" })),
+    );
+}
+
+#[test]
+#[should_panic] // `decrement` without a variable is not supported yet
+fn test_decrement_is_isolated_between_renders() {
+    assert_template_result!(
+        "-1-2-1",
+        "{% decrement %}{% decrement %}{% render 'decr' %}",
+        o!({}),
+        liquid(o!({ "decr": "{% decrement %}" })),
+    );
+}
+
+#[test]
+#[should_panic] // We don't fail on `include` from within `render`
+fn test_includes_will_not_render_inside_render_tag() {
+    assert_template_result!(
+        "Liquid error (test_include line 1): include usage is not allowed in this context",
+        "{% render 'test_include' %}",
+        o!({}),
+        liquid(o!({
+          "foo": "bar",
+          "test_include": "{% include 'foo' %}",
+        })),
+    );
+}
+
+#[test]
+#[should_panic] // We don't fail on `include` from within `render`
+fn test_includes_will_not_render_inside_nested_sibling_tags() {
+    assert_template_result!(
+        "Liquid error (test_include line 1): include usage is not allowed in this context",
+        "{% render 'nested_render_with_sibling_include' %}",
+        o!({}),
+        liquid(o!({
+          "foo": "bar",
+          "nested_render_with_sibling_include": "{% render 'test_include' %}{% include 'foo' %}",
+          "test_include": "{% include 'foo' %}",
+        })),
+    );
+}
+
+#[test]
+#[should_panic] // Implicit name is not supported yet
+fn test_render_tag_with() {
+    assert_template_result!(
+        "Product: Draft 151cm ",
+        "{% render 'product' with products[0] %}",
+        o!({ "products": [{ "title": "Draft 151cm" }, { "title": "Element 155cm" }] }),
+        liquid(o!({
+          "product": "Product: {{ product.title }} ",
+          "product_alias": "Product: {{ product.title }} ",
+        })),
+    );
+}
+
+#[test]
+fn test_render_tag_with_alias() {
+    assert_template_result!(
+        "Product: Draft 151cm ",
+        "{% render 'product_alias' with products[0] as product %}",
+        o!({ "products": [{ "title": "Draft 151cm" }, { "title": "Element 155cm" }] }),
+        liquid(o!({
+          "product": "Product: {{ product.title }} ",
+          "product_alias": "Product: {{ product.title }} ",
+        })),
+    );
+}
+
+#[test]
+fn test_render_tag_for_alias() {
+    assert_template_result!(
+        "Product: Draft 151cm Product: Element 155cm ",
+        "{% render 'product_alias' for products as product %}",
+        o!({ "products": [{ "title": "Draft 151cm" }, { "title": "Element 155cm" }] }),
+        liquid(o!({
+          "product": "Product: {{ product.title }} ",
+          "product_alias": "Product: {{ product.title }} ",
+        })),
+    );
+}
+
+#[test]
+fn test_render_tag_for() {
+    assert_template_result!(
+        "Product: Draft 151cm Product: Element 155cm ",
+        "{% render 'product' for products as product %}",
+        o!({ "products": [{ "title": "Draft 151cm" }, { "title": "Element 155cm" }] }),
+        liquid(o!({
+          "product": "Product: {{ product.title }} ",
+          "product_alias": "Product: {{ product.title }} ",
+        })),
+    );
+}
+
+#[test]
+fn test_render_tag_forloop() {
+    assert_template_result!(
+        "Product: Draft 151cm first  index:1 Product: Element 155cm  last index:2 ",
+        "{% render 'product' for products as product %}",
+        o!({ "products": [{ "title": "Draft 151cm" }, { "title": "Element 155cm" }] }),
+        liquid(o!({
+          "product": "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} ",
+        })),
+    );
+}
+
+/*
+#[test]
+fn test_render_tag_for_drop() {
+  assert_template_result!(
+    "123",
+    "{% render 'loop' for loop as value %}",
+    o!({ "loop": TestEnumerable.new }),
+    liquid(o!({
+      "loop": "{{ value.foo }}",
+    },
+  )
+}
+
+#[test]
+fn test_render_tag_with_drop() {
+  assert_template_result!(
+    "TestEnumerable",
+    "{% render 'loop' with loop as value %}",
+    o!({ "loop": TestEnumerable.new }),
+    liquid(o!({
+      "loop": "{{ value }}",
+    },
+  )
+}
+
+#[test]
+fn test_render_tag_renders_error_with_template_name() {
+    assert_template_result!(
+        "Liquid error (foo line 1): standard error",
+        "{% render 'foo' with errors %}",
+        o!({ "errors": ErrorDrop.new }),
+        liquid(o!({ "foo": "{{ foo.standard_error }}" })),
+    );
+}
+
+#[test]
+fn test_render_tag_renders_error_with_template_name_from_template_factory() {
+  assert_template_result!(
+    "Liquid error (some/path/foo line 1): standard error",
+    "{% render 'foo' with errors %}",
+    { "errors": ErrorDrop.new },
+    liquid(o!({ "foo": "{{ foo.standard_error }}" },
+    template_factory: StubTemplateFactory.new,
+    render_errors: true,
+  )
+}
+*/
diff --git a/tests/test_helper.rs b/tests/test_helper.rs
index d9cb61d..19af85a 100644
--- a/tests/test_helper.rs
+++ b/tests/test_helper.rs
@@ -60,8 +60,12 @@ macro_rules! assert_template_result {
         assert_template_result!($expected, $template, $assigns, $liquid);
     };
     ($expected:expr, $template:expr, $assigns: expr, $liquid: expr) => {
-        let template = $liquid.parse($template.as_ref()).unwrap();
-        let rendered = template.render(&$assigns).unwrap();
+        let template = $liquid
+            .parse($template.as_ref())
+            .unwrap_or_else(|err| panic!("{err}"));
+        let rendered = template
+            .render(&$assigns)
+            .unwrap_or_else(|err| panic!("{err}"));
         assert_eq!($expected, rendered);
     };
 }