//! Fetch mode (`--fetch`): render a template against the Glances API. //! //! The Python implementation uses Jinja2. This module implements the small //! template subset used by the shipped templates (expressions, filters, //! `for`/`if` blocks, dotted access, calls). use anyhow::{Result, bail}; use glances_core::plugin::Plugin; use glances_core::{Args, py_str}; use glances_plugins::engine::Engine; use indexmap::IndexMap; use serde_json::Value; /// Default template (same as the Python `DEFAULT_FETCH_TEMPLATE`). const DEFAULT_TEMPLATE: &str = r#" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ✨ {{ gl.system['hostname'] }}{{ ' | ' + gl.ip['address'] if gl.ip['address'] else '' }} | Uptime: {{ gl.uptime }} ⚙️ {{ gl.system['hr_name'] }} 💡 LOAD {{ '%0.2f'| format(gl.load['min1']) }}/min1 |\ {{ '%0.2f'| format(gl.load['min5']) }}/min5 |\ {{ '%0.2f'| format(gl.load['min15']) }}/min15 ⚡ CPU {{ gl.bar(gl.cpu['total']) }} {{ gl.cpu['total'] }}% of {{ gl.core['log'] }} cores 🧠 MEM {{ gl.bar(gl.mem['percent']) }} {{ gl.mem['percent'] }}% ({{ gl.auto_unit(gl.mem['used']) }} /\ {{ gl.auto_unit(gl.mem['total']) }}) {% for fs in gl.fs.keys() %}\ 💾 {% if loop.index == 1 %}DISK{% else %} {% endif %}\ {{ gl.bar(gl.fs[fs]['percent']) }} {{ gl.fs[fs]['percent'] }}% ({{ gl.auto_unit(gl.fs[fs]['used']) }} /\ {{ gl.auto_unit(gl.fs[fs]['size']) }}) for {{ fs }} {% endfor %}\ {% for net in gl.network.keys() %}\ 📡 {% if loop.index == 1 %}NET{% else %} {% endif %}\ ↓ {{ gl.auto_unit(gl.network[net]['bytes_recv_rate_per_sec']) }}b/s\ ↑ {{ gl.auto_unit(gl.network[net]['bytes_sent_rate_per_sec']) }}b/s for {{ net }} {% endfor %}\ 🔥 TOP PROCESS by CPU {% for process in gl.top_process() %}\ {{ loop.index }}️⃣ {{ process['name'][:20] }}{{ ' ' * (20 - process['name'][:20] | length) }}\ ⚡ {{ process['cpu_percent'] }}% CPU\ {{ ' ' * (8 - (gl.auto_unit(process['cpu_percent']) | length)) }}\ 🧠 {{ gl.auto_unit(process['memory_info']['rss']) }}B MEM {% endfor %}\ 🔥 TOP PROCESS by MEM {% for process in gl.top_process(sorted_by='memory_percent', sorted_by_secondary='cpu_percent') %}\ {{ loop.index }}️⃣ {{ process['name'][:20] }}{{ ' ' * (20 - process['name'][:20] | length) }}\ 🧠 {{ gl.auto_unit(process['memory_info']['rss']) }}B MEM\ {{ ' ' * (7 - (gl.auto_unit(process['memory_info']['rss']) | length)) }}\ ⚡ {{ process['cpu_percent'] }}% CPU {% endfor %}\ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ "#; #[derive(Debug, Clone, PartialEq)] enum JVal { Null, Bool(bool), Num(f64), Str(String), List(Vec), Map(IndexMap), /// A builtin function of the API object. Builtin(&'static str), } impl JVal { fn truthy(&self) -> bool { match self { JVal::Null => false, JVal::Bool(b) => *b, JVal::Num(n) => *n != 0.0, JVal::Str(s) => !s.is_empty(), JVal::List(l) => !l.is_empty(), JVal::Map(m) => !m.is_empty(), JVal::Builtin(_) => true, } } fn as_num(&self) -> f64 { match self { JVal::Num(n) => *n, JVal::Bool(b) => f64::from(*b as u8), JVal::Str(s) => s.parse().unwrap_or(0.0), _ => 0.0, } } fn to_text(&self) -> String { match self { JVal::Null => "".to_string(), JVal::Bool(b) => if *b { "True" } else { "False" }.to_string(), JVal::Num(n) => { if n.fract() == 0.0 { format!("{}", *n as i64) } else { format!("{n}") } } JVal::Str(s) => s.clone(), JVal::List(_) | JVal::Map(_) => String::new(), JVal::Builtin(_) => String::new(), } } } fn json_to_jval(value: &Value) -> JVal { match value { Value::Null => JVal::Null, Value::Bool(b) => JVal::Bool(*b), Value::Number(n) => JVal::Num(n.as_f64().unwrap_or(0.0)), Value::String(s) => JVal::Str(s.clone()), Value::Array(items) => JVal::List(items.iter().map(json_to_jval).collect()), Value::Object(map) => JVal::Map( map.iter() .map(|(k, v)| (k.clone(), json_to_jval(v))) .collect(), ), } } /// API object exposed to the template as `gl`. struct Api<'a> { engine: &'a Engine, #[allow(dead_code)] args: &'a Args, } impl<'a> Api<'a> { fn new(engine: &'a Engine, args: &'a Args) -> Self { Self { engine, args } } fn plugin_stats(&self, name: &str) -> JVal { self.engine .get_plugin(name) .map(|p| json_to_jval(p.stats())) .unwrap_or(JVal::Null) } /// Build the `gl` object. fn object(&self) -> JVal { let mut map: IndexMap = IndexMap::new(); map.insert("system".to_string(), self.plugin_stats("system")); map.insert("ip".to_string(), self.plugin_stats("ip")); map.insert("uptime".to_string(), self.plugin_stats("uptime")); map.insert("load".to_string(), self.plugin_stats("load")); map.insert("cpu".to_string(), self.plugin_stats("cpu")); map.insert("core".to_string(), self.plugin_stats("core")); map.insert("mem".to_string(), self.plugin_stats("mem")); // fs and network are exposed as dicts keyed by mount point/interface. map.insert( "fs".to_string(), self.plugin_stats_as_dict("fs", "mnt_point"), ); map.insert( "network".to_string(), self.plugin_stats_as_dict("network", "interface_name"), ); map.insert("bar".to_string(), JVal::Builtin("bar")); map.insert("auto_unit".to_string(), JVal::Builtin("auto_unit")); map.insert("top_process".to_string(), JVal::Builtin("top_process")); JVal::Map(map) } fn plugin_stats_as_dict(&self, plugin: &str, key: &str) -> JVal { let mut map = IndexMap::new(); if let Some(plugin) = self.engine.get_plugin(plugin) && let Some(items) = plugin.stats().as_array() { for item in items { if let Some(identity) = item.get(key).and_then(Value::as_str) { map.insert(identity.to_string(), json_to_jval(item)); } } } JVal::Map(map) } fn top_process(&self, sorted_by: &str, sorted_by_secondary: &str, limit: usize) -> JVal { let procs = glances_core::processes::processes().lock().unwrap(); let mut list: Vec = procs .get_list(false) .iter() .filter(|p| { let cmdline = p .get("cmdline") .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(|v| v.as_str()) .collect::>() .join(" ") }) .unwrap_or_default(); !cmdline.contains("glances") }) .cloned() .collect(); glances_core::processes::sort_stats(&mut list, sorted_by, sorted_by_secondary, true); JVal::List(list.iter().take(limit).map(json_to_jval).collect()) } } // --------------------------------------------------------------------------- // Template parser // --------------------------------------------------------------------------- #[derive(Debug)] enum Node { Text(String), Expr(String), For { var: String, iterable: String, body: Vec, }, If { cond: String, then_body: Vec, else_body: Vec, }, } #[derive(Debug)] enum Token { Text(String), Expr(String), Stmt(String), } fn tokenize(template: &str) -> Result> { let mut tokens = Vec::new(); let mut chars = template.chars().peekable(); let mut text = String::new(); while let Some(c) = chars.next() { // Jinja line continuation. if c == '\\' && chars.peek() == Some(&'\n') { chars.next(); continue; } if c == '{' { match chars.peek() { Some('{') => { chars.next(); if !text.is_empty() { tokens.push(Token::Text(std::mem::take(&mut text))); } let mut expr = String::new(); while let Some(c2) = chars.next() { if c2 == '}' && chars.peek() == Some(&'}') { chars.next(); break; } expr.push(c2); } tokens.push(Token::Expr(expr.trim().to_string())); continue; } Some('%') => { chars.next(); if !text.is_empty() { tokens.push(Token::Text(std::mem::take(&mut text))); } let mut stmt = String::new(); while let Some(c2) = chars.next() { if c2 == '%' && chars.peek() == Some(&'}') { chars.next(); break; } stmt.push(c2); } tokens.push(Token::Stmt(stmt.trim().to_string())); continue; } _ => {} } } text.push(c); } if !text.is_empty() { tokens.push(Token::Text(text)); } Ok(tokens) } fn parse_block(tokens: &[Token], index: &mut usize, stop: &[&str]) -> Result> { let mut nodes = Vec::new(); while *index < tokens.len() { match &tokens[*index] { Token::Text(text) => { nodes.push(Node::Text(text.clone())); *index += 1; } Token::Expr(expr) => { nodes.push(Node::Expr(expr.clone())); *index += 1; } Token::Stmt(stmt) => { if stop .iter() .any(|s| stmt == s || stmt.starts_with(&format!("{s} "))) { return Ok(nodes); } if let Some(rest) = stmt.strip_prefix("for ") { let (var, iterable) = rest.split_once(" in ").unwrap_or((rest, "")); *index += 1; let body = parse_block(tokens, index, &["endfor"])?; if *index < tokens.len() { *index += 1; // consume endfor } nodes.push(Node::For { var: var.trim().to_string(), iterable: iterable.trim().to_string(), body, }); continue; } if let Some(cond) = stmt.strip_prefix("if ") { *index += 1; let then_body = parse_block(tokens, index, &["else", "endif"])?; let mut else_body = Vec::new(); if *index < tokens.len() { let current = &tokens[*index]; let is_else = matches!(current, Token::Stmt(s) if s == "else"); *index += 1; if is_else { else_body = parse_block(tokens, index, &["endif"])?; if *index < tokens.len() { *index += 1; } } } nodes.push(Node::If { cond: cond.trim().to_string(), then_body, else_body, }); continue; } // Unknown statement: ignore it. *index += 1; } } } Ok(nodes) } fn parse_template(template: &str) -> Result> { let tokens = tokenize(template)?; let mut index = 0; let nodes = parse_block(&tokens, &mut index, &[])?; if index < tokens.len() { bail!("unexpected template statement"); } Ok(nodes) } // --------------------------------------------------------------------------- // Expression evaluator // --------------------------------------------------------------------------- struct ExprParser<'a> { input: &'a [u8], pos: usize, } impl<'a> ExprParser<'a> { fn new(input: &'a str) -> Self { Self { input: input.as_bytes(), pos: 0, } } fn skip_ws(&mut self) { while self.pos < self.input.len() && (self.input[self.pos] as char).is_whitespace() { self.pos += 1; } } fn peek_word(&mut self) -> String { self.skip_ws(); let start = self.pos; while self.pos < self.input.len() { let c = self.input[self.pos] as char; if c.is_alphanumeric() || c == '_' { self.pos += 1; } else { break; } } String::from_utf8_lossy(&self.input[start..self.pos]).to_string() } fn parse(&mut self, ctx: &EvalContext) -> Result { let value = self.parse_ternary(ctx)?; Ok(value) } fn parse_ternary(&mut self, ctx: &EvalContext) -> Result { let value = self.parse_or(ctx)?; let save = self.pos; let word = self.peek_word(); if word == "if" { let cond = self.parse_or(ctx)?; let word = self.peek_word(); if word == "else" { let other = self.parse_ternary(ctx)?; return Ok(if cond.truthy() { value } else { other }); } self.pos = save; } else { self.pos = save; } Ok(value) } fn parse_or(&mut self, ctx: &EvalContext) -> Result { let mut value = self.parse_and(ctx)?; loop { let save = self.pos; if self.peek_word() == "or" { let right = self.parse_and(ctx)?; value = JVal::Bool(value.truthy() || right.truthy()); } else { self.pos = save; return Ok(value); } } } fn parse_and(&mut self, ctx: &EvalContext) -> Result { let mut value = self.parse_not(ctx)?; loop { let save = self.pos; if self.peek_word() == "and" { let right = self.parse_not(ctx)?; value = JVal::Bool(value.truthy() && right.truthy()); } else { self.pos = save; return Ok(value); } } } fn parse_not(&mut self, ctx: &EvalContext) -> Result { let save = self.pos; if self.peek_word() == "not" { let value = self.parse_not(ctx)?; return Ok(JVal::Bool(!value.truthy())); } self.pos = save; self.parse_comparison(ctx) } fn parse_comparison(&mut self, ctx: &EvalContext) -> Result { let left = self.parse_additive(ctx)?; self.skip_ws(); for op in ["==", "!=", ">=", "<=", ">", "<"] { if self.input[self.pos..].starts_with(op.as_bytes()) { self.pos += op.len(); let right = self.parse_additive(ctx)?; let result = match op { "==" => { left == right || (left.to_text() == right.to_text() && !matches!(left, JVal::Map(_) | JVal::List(_))) } "!=" => left != right, ">" => left.as_num() > right.as_num(), "<" => left.as_num() < right.as_num(), ">=" => left.as_num() >= right.as_num(), "<=" => left.as_num() <= right.as_num(), _ => false, }; return Ok(JVal::Bool(result)); } } Ok(left) } fn parse_additive(&mut self, ctx: &EvalContext) -> Result { let mut value = self.parse_multiplicative(ctx)?; loop { self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b'+' { self.pos += 1; let right = self.parse_multiplicative(ctx)?; value = match (&value, &right) { (JVal::Num(a), JVal::Num(b)) => JVal::Num(a + b), _ => JVal::Str(value.to_text() + &right.to_text()), }; } else if self.pos < self.input.len() && self.input[self.pos] == b'-' { self.pos += 1; let right = self.parse_multiplicative(ctx)?; value = JVal::Num(value.as_num() - right.as_num()); } else { return Ok(value); } } } fn parse_multiplicative(&mut self, ctx: &EvalContext) -> Result { let mut value = self.parse_unary(ctx)?; loop { self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b'*' { self.pos += 1; let right = self.parse_unary(ctx)?; value = match (&value, &right) { (JVal::Str(s), JVal::Num(n)) | (JVal::Num(n), JVal::Str(s)) => { JVal::Str(s.repeat(n.max(0.0) as usize)) } _ => JVal::Num(value.as_num() * right.as_num()), }; } else if self.pos < self.input.len() && self.input[self.pos] == b'/' { self.pos += 1; let right = self.parse_unary(ctx)?; let divisor = right.as_num(); value = JVal::Num(if divisor == 0.0 { 0.0 } else { value.as_num() / divisor }); } else { return Ok(value); } } } fn parse_unary(&mut self, ctx: &EvalContext) -> Result { self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b'-' { self.pos += 1; let value = self.parse_unary(ctx)?; return Ok(JVal::Num(-value.as_num())); } self.parse_postfix(ctx) } fn parse_postfix(&mut self, ctx: &EvalContext) -> Result { let mut value = self.parse_primary(ctx)?; loop { self.skip_ws(); if self.pos >= self.input.len() { return Ok(value); } match self.input[self.pos] { b'.' => { self.pos += 1; let name = self.peek_word(); self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b'(' { // Method call on a dict/list or builtin function call. self.pos += 1; let (args, kwargs) = self.parse_args(ctx)?; let member = match &value { JVal::Map(map) => map.get(&name).cloned().unwrap_or(JVal::Null), _ => JVal::Null, }; if let JVal::Builtin(builtin) = &member { value = ctx.call_builtin(builtin, &args, &kwargs); continue; } value = match (&value, name.as_str()) { (JVal::Map(map), "keys") => { JVal::List(map.keys().map(|k| JVal::Str(k.clone())).collect()) } (JVal::Map(map), "values") => { JVal::List(map.values().cloned().collect()) } (JVal::Map(map), "items") => JVal::List( map.iter() .map(|(k, v)| JVal::List(vec![JVal::Str(k.clone()), v.clone()])) .collect(), ), (JVal::Map(map), "get") => { let key = args.first().map(|v| v.to_text()).unwrap_or_default(); map.get(&key) .cloned() .unwrap_or_else(|| args.get(1).cloned().unwrap_or(JVal::Null)) } _ => JVal::Null, }; continue; } value = match &value { JVal::Map(map) => map.get(&name).cloned().unwrap_or(JVal::Null), _ => JVal::Null, }; } b'[' => { self.pos += 1; self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b':' { // Slice [:n] self.pos += 1; let end = self.parse_ternary(ctx)?.as_num() as usize; self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b']' { self.pos += 1; } if let JVal::Str(s) = &value { let chars: Vec = s.chars().collect(); value = JVal::Str(chars[..end.min(chars.len())].iter().collect()); } } else { let key = self.parse_ternary(ctx)?; self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b']' { self.pos += 1; } value = match &value { JVal::Map(map) => { map.get(&key.to_text()).cloned().unwrap_or(JVal::Null) } JVal::List(items) => { let index = key.as_num() as usize; items.get(index).cloned().unwrap_or(JVal::Null) } JVal::Str(s) => { let chars: Vec = s.chars().collect(); let index = key.as_num() as usize; chars .get(index) .map(|c| JVal::Str(c.to_string())) .unwrap_or(JVal::Null) } _ => JVal::Null, }; } } b'(' => { self.pos += 1; let (args, kwargs) = self.parse_args(ctx)?; value = self.call(value, &args, &kwargs, ctx); } b'|' => { self.pos += 1; let filter = self.peek_word(); let mut args: Vec = Vec::new(); self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b'(' { self.pos += 1; loop { self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b')' { self.pos += 1; break; } let arg = self.parse_ternary(ctx)?; args.push(arg); self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b',' { self.pos += 1; } } } value = self.apply_filter(&filter, value, &args); } _ => return Ok(value), } } } fn call( &self, callee: JVal, args: &[JVal], kwargs: &IndexMap, ctx: &EvalContext, ) -> JVal { match callee { JVal::Builtin(name) => ctx.call_builtin(name, args, kwargs), _ => JVal::Null, } } fn apply_filter(&self, name: &str, value: JVal, args: &[JVal]) -> JVal { match name { "length" => JVal::Num(match &value { JVal::Str(s) => s.chars().count() as f64, JVal::List(l) => l.len() as f64, JVal::Map(m) => m.len() as f64, _ => 0.0, }), "format" => { let mut result = value.to_text(); for arg in args { // Only the first (already embedded) value is formatted. let replacement = format_c(arg, &result); result = replacement; } JVal::Str(result) } "upper" => JVal::Str(value.to_text().to_uppercase()), "lower" => JVal::Str(value.to_text().to_lowercase()), _ => value, } } fn parse_args(&mut self, ctx: &EvalContext) -> Result<(Vec, IndexMap)> { let mut args: Vec = Vec::new(); let mut kwargs: IndexMap = IndexMap::new(); loop { self.skip_ws(); if self.pos >= self.input.len() { break; } if self.input[self.pos] == b')' { self.pos += 1; break; } let save = self.pos; let word = self.peek_word(); let mut is_kwarg = false; if !word.is_empty() { self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b'=' { is_kwarg = true; self.pos += 1; } } if is_kwarg { let value = self.parse_ternary(ctx)?; kwargs.insert(word, value); } else { self.pos = save; let before = self.pos; let value = self.parse_ternary(ctx)?; args.push(value); if self.pos == before { // Defensive: never loop forever on an unexpected token. self.pos += 1; } } self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b',' { self.pos += 1; } } Ok((args, kwargs)) } fn parse_primary(&mut self, ctx: &EvalContext) -> Result { self.skip_ws(); if self.pos >= self.input.len() { return Ok(JVal::Null); } let c = self.input[self.pos] as char; if c == '(' { self.pos += 1; let value = self.parse_ternary(ctx)?; self.skip_ws(); if self.pos < self.input.len() && self.input[self.pos] == b')' { self.pos += 1; } return Ok(value); } if c == '\'' || c == '"' { let quote = self.input[self.pos]; self.pos += 1; let start = self.pos; while self.pos < self.input.len() && self.input[self.pos] != quote { self.pos += 1; } let text = String::from_utf8_lossy(&self.input[start..self.pos]).to_string(); self.pos += 1; return Ok(JVal::Str(text)); } if c.is_ascii_digit() { let start = self.pos; while self.pos < self.input.len() && ((self.input[self.pos] as char).is_ascii_digit() || self.input[self.pos] == b'.') { self.pos += 1; } let text = String::from_utf8_lossy(&self.input[start..self.pos]).to_string(); return Ok(JVal::Num(text.parse().unwrap_or(0.0))); } let word = self.peek_word(); match word.as_str() { "" => Ok(JVal::Null), "true" | "True" => Ok(JVal::Bool(true)), "false" | "False" => Ok(JVal::Bool(false)), "none" | "None" | "null" => Ok(JVal::Null), _ => Ok(ctx.lookup(&word)), } } } /// Poor man's `%`-format: only `%0.2f`, `%d`, `%s` and `%x.yf` are needed. fn format_c(value: &JVal, format: &str) -> String { if let Some(spec) = format.strip_prefix('%') { let spec = spec .trim_end_matches('f') .trim_end_matches('d') .trim_end_matches('s'); let precision = spec .split('.') .nth(1) .and_then(|p| p.parse::().ok()) .unwrap_or(6); let width = spec .split('.') .next() .and_then(|w| w.trim_start_matches('0').parse::().ok()) .unwrap_or(0); if format.ends_with('d') { return format!("{:>width$}", value.as_num() as i64); } if format.ends_with('s') { return value.to_text(); } return format!("{:>width$.precision$}", value.as_num()); } value.to_text() } struct EvalContext<'a> { api: &'a Api<'a>, variables: Vec>, loop_var: Option>, } impl<'a> EvalContext<'a> { fn lookup(&self, name: &str) -> JVal { if name == "loop" && let Some(loop_var) = &self.loop_var { return JVal::Map(loop_var.clone()); } if name == "gl" { return self.api.object(); } for scope in self.variables.iter().rev() { if let Some(value) = scope.get(name) { return value.clone(); } } JVal::Null } fn call_builtin(&self, name: &str, args: &[JVal], kwargs: &IndexMap) -> JVal { match name { "bar" => { let value = args.first().map(|v| v.as_num()).unwrap_or(0.0); JVal::Str(glances_core::units::bar_custom(value, 18, "■", "□", false)) } "auto_unit" => { let value = args.first().map(|v| v.as_num()).unwrap_or(0.0); let low_precision = kwargs .get("low_precision") .map(|v| v.truthy()) .unwrap_or(false); let min_symbol = kwargs .get("min_symbol") .map(|v| v.to_text()) .unwrap_or_else(|| "K".to_string()); JVal::Str(glances_core::auto_unit( value, low_precision, &min_symbol, "-", )) } "top_process" => { let sorted_by = kwargs .get("sorted_by") .map(|v| v.to_text()) .unwrap_or_else(|| "cpu_percent".to_string()); let sorted_by_secondary = kwargs .get("sorted_by_secondary") .map(|v| v.to_text()) .unwrap_or_else(|| "memory_percent".to_string()); let limit = args.first().map(|v| v.as_num() as usize).unwrap_or(3); self.api .top_process(&sorted_by, &sorted_by_secondary, limit) } _ => JVal::Null, } } } fn render_nodes(nodes: &[Node], ctx: &mut EvalContext) -> Result { let mut out = String::new(); for node in nodes { match node { Node::Text(text) => out.push_str(text), Node::Expr(expr) => { let mut parser = ExprParser::new(expr); let value = parser.parse(ctx)?; out.push_str(&value.to_text()); } Node::For { var, iterable, body, } => { let mut parser = ExprParser::new(iterable); let collection = parser.parse(ctx)?; let items: Vec = match collection { JVal::List(items) => items, JVal::Map(map) => map.keys().map(|k| JVal::Str(k.clone())).collect(), _ => Vec::new(), }; let length = items.len(); for (index, item) in items.into_iter().enumerate() { let mut scope = IndexMap::new(); scope.insert(var.clone(), item); let mut loop_var = IndexMap::new(); loop_var.insert("index".to_string(), JVal::Num((index + 1) as f64)); loop_var.insert("index0".to_string(), JVal::Num(index as f64)); loop_var.insert("first".to_string(), JVal::Bool(index == 0)); loop_var.insert("last".to_string(), JVal::Bool(index + 1 == length)); loop_var.insert("length".to_string(), JVal::Num(length as f64)); ctx.variables.push(scope); ctx.loop_var = Some(loop_var); out.push_str(&render_nodes(body, ctx)?); ctx.variables.pop(); ctx.loop_var = None; } } Node::If { cond, then_body, else_body, } => { let mut parser = ExprParser::new(cond); let value = parser.parse(ctx)?; if value.truthy() { out.push_str(&render_nodes(then_body, ctx)?); } else { out.push_str(&render_nodes(else_body, ctx)?); } } } } Ok(out) } /// Run the fetch mode: render the template and exit. pub fn run_fetch(engine: &mut Engine, args: &Args) -> Result<()> { engine.update(); let template = if args.fetch_template.is_empty() { DEFAULT_TEMPLATE.to_string() } else { std::fs::read_to_string(&args.fetch_template).map_err(|e| { anyhow::anyhow!("cannot read fetch template '{}': {e}", args.fetch_template) })? }; let nodes = parse_template(&template)?; let api = Api::new(engine, args); let mut ctx = EvalContext { api: &api, variables: Vec::new(), loop_var: None, }; let output = render_nodes(&nodes, &mut ctx)?; print!("{output}"); Ok(()) } #[allow(dead_code)] fn unused(_: &dyn Plugin, _: &str) -> String { py_str(&Value::Null) }