@@ -15,21 +15,30 @@ impl fmt::Display for ParseError {
18enum LineContent {
19enum LineContent {
19 Normal(String),
20 Normal(String),
20 Raw(String),
21 Raw(String),
23struct Line {
25struct Line {
24 indent: usize,
26 indent: usize,
25 content: LineContent,
27 content: LineContent,
26 line_num: usize,
28 line_num: usize,
34+ body_lines: Vec<Line>,
29struct ParseContext {
37struct ParseContext {
30 page_title: Option<String>,
38 page_title: Option<String>,
31 variables: HashMap<String, String>,
39 variables: HashMap<String, String>,
32 defines: HashMap<String, Vec<Attribute>>,
40 defines: HashMap<String, Vec<Attribute>>,
41+ functions: HashMap<String, FnDef>,
35struct Parser {
44struct Parser {
@@ -44,6 +53,7 @@ pub fn parse(input: &str) -> Result<Document, ParseError> {
44 page_title: None,
53 page_title: None,
45 variables: HashMap::new(),
54 variables: HashMap::new(),
46 defines: HashMap::new(),
55 defines: HashMap::new(),
56+ functions: HashMap::new(),
48 let nodes = parser.parse_children(0, &mut ctx)?;
58 let nodes = parser.parse_children(0, &mut ctx)?;
49 Ok(Document {
59 Ok(Document {
@@ -150,15 +160,18 @@ impl Parser {
152 match self.parse_line(ctx)? {
162 match self.parse_line(ctx)? {
153− Some(node) => nodes.push(node),
163+ Some(new_nodes) => nodes.extend(new_nodes),
154− None => {} // directive consumed, no node
164+ None => {}
158 Ok(nodes)
168 Ok(nodes)
173+ ctx: &mut ParseContext,
161− fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Node>, ParseError> {
174+ ) -> Result<Option<Vec<Node>>, ParseError> {
162 let line_num = self.lines[self.pos].line_num;
175 let line_num = self.lines[self.pos].line_num;
163 let current_indent = self.lines[self.pos].indent;
176 let current_indent = self.lines[self.pos].indent;
@@ -166,7 +179,7 @@ impl Parser {
166 if let LineContent::Raw(s) = &self.lines[self.pos].content {
179 if let LineContent::Raw(s) = &self.lines[self.pos].content {
167 let content = s.clone();
180 let content = s.clone();
168 self.pos += 1;
181 self.pos += 1;
169− return Ok(Some(Node::Raw(content)));
182+ return Ok(Some(vec![Node::Raw(content)]));
172 // Normal content — clone to release borrow
185 // Normal content — clone to release borrow
@@ -208,17 +221,123 @@ impl Parser {
208 return Ok(None);
221 return Ok(None);
224+ // --- Function definition ---
226+ if let Some(rest) = content.strip_prefix("@fn ") {
227+ let parts: Vec<&str> = rest.split_whitespace().collect();
228+ if parts.is_empty() {
229+ return Err(ParseError {
231+ message: "@fn requires a name".to_string(),
234+ let name = parts[0].to_string();
235+ let params: Vec<String> = parts[1..]
237+ .map(|p| p.strip_prefix('$').unwrap_or(p).to_string())
240+ // Collect body lines (all lines indented deeper than @fn)
241+ let mut body_lines = Vec::new();
242+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
243+ body_lines.push(self.lines[self.pos].clone());
247+ ctx.functions.insert(name, FnDef { params, body_lines });
251+ // --- Function call ---
253+ if content.starts_with('@') {
254+ let name = extract_element_name(&content);
255+ if ctx.functions.contains_key(name) {
257+ self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
258+ return Ok(Some(nodes));
211 // --- Elements ---
262 // --- Elements ---
213 if content.starts_with('@') || content.starts_with('[') {
264 if content.starts_with('@') || content.starts_with('[') {
214 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
265 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
215− return Ok(Some(node));
266+ return Ok(Some(vec![node]));
218 // --- Bare text ---
269 // --- Bare text ---
220 let segments = parse_text_segments(&content, ctx);
271 let segments = parse_text_segments(&content, ctx);
221− Ok(Some(Node::Text(segments)))
272+ Ok(Some(vec![Node::Text(segments)]))
279+ current_indent: usize,
281+ ctx: &mut ParseContext,
282+ ) -> Result<Vec<Node>, ParseError> {
283+ // Parse [param value, ...] arguments
284+ let rest = &content[1 + name.len()..];
285+ let rest = rest.trim_start();
287+ let args = if rest.starts_with('[') {
288+ let (attrs, _) = parse_attr_brackets(rest, line_num, ctx)?;
294+ // Clone function definition (releases borrow on ctx)
295+ let fn_def = ctx.functions.get(name).unwrap().clone();
297+ // Parse caller's children
298+ let caller_children = self.parse_children(current_indent + 1, ctx)?;
300+ // Save variable state, inject function parameters
301+ let saved_vars = ctx.variables.clone();
302+ for (i, param) in fn_def.params.iter().enumerate() {
305+ .find(|a| a.key == *param)
306+ .and_then(|a| a.value.clone())
307+ .or_else(|| args.get(i).and_then(|a| a.value.clone()))
308+ .unwrap_or_default();
309+ ctx.variables.insert(param.clone(), value);
312+ // Normalize body indentation so it parses from indent 0
313+ let min_indent = fn_def
319+ let adjusted: Vec<Line> = fn_def
323+ indent: l.indent - min_indent,
324+ content: l.content.clone(),
325+ line_num: l.line_num,
329+ // Parse body with params in scope
330+ let mut body_parser = Parser {
334+ let body_nodes = body_parser.parse_children(0, ctx)?;
336+ // Restore variables
337+ ctx.variables = saved_vars;
339+ // Replace @children with caller's children
340+ Ok(replace_children_nodes(body_nodes, &caller_children))
224 fn parse_element_line(
343 fn parse_element_line(
@@ -251,10 +370,39 @@ impl Parser {
373+// ---------------------------------------------------------------------------
374+// @children replacement
375+// ---------------------------------------------------------------------------
377+fn replace_children_nodes(nodes: Vec<Node>, caller_children: &[Node]) -> Vec<Node> {
378+ let mut result = Vec::new();
381+ Node::Element(elem) if elem.kind == ElementKind::Children => {
382+ result.extend(caller_children.iter().cloned());
384+ Node::Element(mut elem) => {
385+ elem.children = replace_children_nodes(elem.children, caller_children);
386+ result.push(Node::Element(elem));
388+ other => result.push(other),
254// ---------------------------------------------------------------------------
394// ---------------------------------------------------------------------------
255// Element parsing
395// Element parsing
256// ---------------------------------------------------------------------------
396// ---------------------------------------------------------------------------
398+fn extract_element_name(content: &str) -> &str {
399+ let without_at = &content[1..];
400+ match without_at.find(|c: char| c == ' ' || c == '[') {
401+ Some(i) => &without_at[..i],
258fn parse_single_element(
406fn parse_single_element(
259 content: &str,
407 content: &str,
260 line_num: usize,
408 line_num: usize,
@@ -329,6 +477,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
329 "paragraph" | "p" => Ok(ElementKind::Paragraph),
477 "paragraph" | "p" => Ok(ElementKind::Paragraph),
330 "image" | "img" => Ok(ElementKind::Image),
478 "image" | "img" => Ok(ElementKind::Image),
331 "link" => Ok(ElementKind::Link),
479 "link" => Ok(ElementKind::Link),
480+ "children" => Ok(ElementKind::Children),
332 _ => Err(ParseError {
481 _ => Err(ParseError {
333 line: line_num,
482 line: line_num,
334 message: format!("unknown element @{}", s),
483 message: format!("unknown element @{}", s),
@@ -560,7 +709,9 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
560 let mut i = 0;
709 let mut i = 0;
562 while i < chars.len() {
711 while i < chars.len() {
713+ && i + 1 < chars.len()
563− if chars[i] == '$' && i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
714+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
565 let start = i + 1;
716 let start = i + 1;
566 let mut end = start;
717 let mut end = start;