1use std::collections::HashMap;
2use std::io::Write;
3
4use liquid_core::error::{ResultLiquidExt, ResultLiquidReplaceExt};
5use liquid_core::parser::TagToken;
6use liquid_core::parser::TryMatchToken;
7use liquid_core::Expression;
8use liquid_core::Language;
9use liquid_core::Renderable;
10use liquid_core::Runtime;
11use liquid_core::ValueView;
12use liquid_core::{Error, Result};
13use liquid_core::{ParseTag, TagReflection, TagTokenIter};
14
15#[derive(Copy, Clone, Debug, Default)]
16pub struct CycleTag;
17
18impl CycleTag {
19 pub fn new() -> Self {
20 Self
21 }
22}
23
24impl TagReflection for CycleTag {
25 fn tag(&self) -> &'static str {
26 "cycle"
27 }
28
29 fn description(&self) -> &'static str {
30 ""
31 }
32}
33
34impl ParseTag for CycleTag {
35 fn parse(
36 &self,
37 arguments: TagTokenIter<'_>,
38 options: &Language,
39 ) -> Result<Box<dyn Renderable>> {
40 parse_cycle(arguments, options).map(|opt| Box::new(opt) as Box<dyn Renderable>)
41 }
42
43 fn reflection(&self) -> &dyn TagReflection {
44 self
45 }
46}
47
48fn parse_cycle(mut arguments: TagTokenIter<'_>, _options: &Language) -> Result<Cycle> {
50 let mut name = String::new();
51 let mut values = Vec::new();
52
53 let first = arguments.expect_next("Identifier or value expected")?;
54 let second = arguments.next();
55 match second.as_ref().map(TagToken::as_str) {
56 Some(":") => {
57 name = match first.expect_identifier() {
58 TryMatchToken::Matches(name) => name.to_owned(),
59 TryMatchToken::Fails(name) => match name.expect_literal() {
60 TryMatchToken::Matches(name) => name.to_kstr().into_string(),
63 TryMatchToken::Fails(name) => return name.raise_error().into_err(),
64 },
65 };
66 }
67 Some(",") | None => {
68 values.push(first.expect_value().into_result()?);
70 }
71 Some(_) => {
72 return second
73 .expect("is some")
74 .raise_custom_error("\":\" or \",\" expected.")
75 .into_err();
76 }
77 }
78
79 while let Some(a) = arguments.next() {
80 values.push(a.expect_value().into_result()?);
81
82 let next = arguments.next();
83 match next.as_ref().map(TagToken::as_str) {
84 Some(",") => {}
85 None => break,
86 Some(_) => {
87 return next
88 .expect("is some")
89 .raise_custom_error("\",\" expected.")
90 .into_err();
91 }
92 }
93 }
94
95 if name.is_empty() {
96 name = itertools::join(values.iter(), "-");
97 }
98
99 arguments.expect_nothing()?;
101
102 Ok(Cycle { name, values })
103}
104
105#[derive(Clone, Debug)]
106struct Cycle {
107 name: String,
108 values: Vec<Expression>,
109}
110
111impl Cycle {
112 fn trace(&self) -> String {
113 format!(
114 "{{% cycle {} %}}",
115 itertools::join(self.values.iter(), ", ")
116 )
117 }
118}
119
120impl Renderable for Cycle {
121 fn render_to(&self, writer: &mut dyn Write, runtime: &dyn Runtime) -> Result<()> {
122 let expr = runtime
123 .registers()
124 .get_mut::<CycleRegister>()
125 .cycle(&self.name, &self.values)
126 .trace_with(|| self.trace().into())?;
127 let value = expr.evaluate(runtime).trace_with(|| self.trace().into())?;
128 write!(writer, "{}", value.render()).replace("Failed to render")?;
129 Ok(())
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Default)]
134struct CycleRegister {
135 cycles: HashMap<String, usize>,
137}
138
139impl CycleRegister {
140 fn cycle<'e>(&mut self, name: &str, values: &'e [Expression]) -> Result<&'e Expression> {
141 let index = self.cycle_index(name, values.len());
142 if index >= values.len() {
143 return Error::with_msg(
144 "cycle index out of bounds, most likely from mismatched cycles",
145 )
146 .context("index", format!("{index}"))
147 .context("count", format!("{}", values.len()))
148 .into_err();
149 }
150
151 Ok(&values[index])
152 }
153
154 fn cycle_index(&mut self, name: &str, max: usize) -> usize {
155 let i = self.cycles.entry(name.to_owned()).or_insert(0);
156 let j = *i;
157 *i = (*i + 1) % max;
158 j
159 }
160}
161
162#[cfg(test)]
163mod test {
164 use super::*;
165
166 use liquid_core::model::Value;
167 use liquid_core::parser;
168 use liquid_core::runtime;
169 use liquid_core::runtime::RuntimeBuilder;
170
171 fn options() -> Language {
172 let mut options = Language::default();
173 options.tags.register("cycle".to_owned(), CycleTag.into());
174 options
175 }
176
177 #[test]
178 fn unnamed_cycle_gets_a_name() {
179 let tag = parser::Tag::new("{% cycle this, cycle, has, no, name %}").unwrap();
180 let cycle = parse_cycle(tag.into_tokens(), &options()).unwrap();
181 assert!(!cycle.name.is_empty());
182 }
183
184 #[test]
185 fn named_values_are_independent() {
186 let text = concat!(
187 "{% cycle 'a': 'one', 'two', 'three' %}\n",
188 "{% cycle 'a': 'one', 'two', 'three' %}\n",
189 "{% cycle 'b': 'one', 'two', 'three' %}\n",
190 "{% cycle 'b': 'one', 'two', 'three' %}\n"
191 );
192 let template = parser::parse(text, &options())
193 .map(runtime::Template::new)
194 .unwrap();
195
196 let runtime = RuntimeBuilder::new().build();
197 let output = template.render(&runtime);
198
199 assert_eq!(output.unwrap(), "one\ntwo\none\ntwo\n");
200 }
201
202 #[test]
203 fn values_are_cycled() {
204 let text = concat!(
205 "{% cycle 'one', 'two', 'three' %}\n",
206 "{% cycle 'one', 'two', 'three' %}\n",
207 "{% cycle 'one', 'two', 'three' %}\n",
208 "{% cycle 'one', 'two', 'three' %}\n"
209 );
210 let template = parser::parse(text, &options())
211 .map(runtime::Template::new)
212 .unwrap();
213
214 let runtime = RuntimeBuilder::new().build();
215 let output = template.render(&runtime);
216
217 assert_eq!(output.unwrap(), "one\ntwo\nthree\none\n");
218 }
219
220 #[test]
221 fn values_can_be_variables() {
222 let text = concat!(
223 "{% cycle alpha, beta, gamma %}\n",
224 "{% cycle alpha, beta, gamma %}\n",
225 "{% cycle alpha, beta, gamma %}\n",
226 "{% cycle alpha, beta, gamma %}\n"
227 );
228 let template = parser::parse(text, &options())
229 .map(runtime::Template::new)
230 .unwrap();
231
232 let runtime = RuntimeBuilder::new().build();
233 runtime.set_global("alpha".into(), Value::scalar(1f64));
234 runtime.set_global("beta".into(), Value::scalar(2f64));
235 runtime.set_global("gamma".into(), Value::scalar(3f64));
236
237 let output = template.render(&runtime);
238
239 assert_eq!(output.unwrap(), "1\n2\n3\n1\n");
240 }
241
242 #[test]
243 fn bad_cycle_indices_dont_crash() {
244 let text = concat!("{% cycle c: 1, 2 %}\n", "{% cycle c: 1 %}\n");
247
248 let runtime = RuntimeBuilder::new().build();
249 let template = parser::parse(text, &options())
250 .map(runtime::Template::new)
251 .unwrap();
252 let output = template.render(&runtime);
253 assert!(output.is_err());
254 }
255}