@@ -0,0 +1,587 @@
1+use std::collections::HashMap;
7+pub struct ParseError {
12+impl fmt::Display for ParseError {
13+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
14+ write!(f, "line {}: {}", self.line, self.message)
25+ content: LineContent,
30+ page_title: Option<String>,
31+ variables: HashMap<String, String>,
32+ defines: HashMap<String, Vec<Attribute>>,
40+pub fn parse(input: &str) -> Result<Document, ParseError> {
41+ let lines = preprocess(input);
42+ let mut parser = Parser { lines, pos: 0 };
43+ let mut ctx = ParseContext {
45+ variables: HashMap::new(),
46+ defines: HashMap::new(),
48+ let nodes = parser.parse_children(0, &mut ctx)?;
50+ page_title: ctx.page_title,
51+ variables: ctx.variables,
52+ defines: ctx.defines,
57+// ---------------------------------------------------------------------------
58+// Preprocessing: strip comments/blanks, collapse @raw blocks
59+// ---------------------------------------------------------------------------
61+fn preprocess(input: &str) -> Vec<Line> {
62+ let raw_lines: Vec<&str> = input.lines().collect();
63+ let mut lines = Vec::new();
66+ while i < raw_lines.len() {
67+ let line = raw_lines[i];
68+ let trimmed = line.trim();
70+ if trimmed.is_empty() || trimmed.starts_with("--") {
75+ let indent = line.len() - line.trim_start().len();
77+ // Handle @raw """..."""
78+ if trimmed.starts_with("@raw") {
79+ let after_raw = trimmed[4..].trim_start();
80+ if after_raw.starts_with("\"\"\"") {
81+ let after_open = &after_raw[3..];
83+ // Single-line: @raw """content"""
84+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
85+ let content = &after_open[..after_open.len() - 3];
88+ content: LineContent::Raw(content.to_string()),
95+ // Multiline: collect until closing """
96+ let mut raw_content = String::new();
97+ if !after_open.is_empty() {
98+ raw_content.push_str(after_open);
99+ raw_content.push('\n');
102+ while i < raw_lines.len() {
103+ if raw_lines[i].trim() == "\"\"\"" {
107+ raw_content.push_str(raw_lines[i]);
108+ raw_content.push('\n');
114+ content: LineContent::Raw(
115+ raw_content.trim_end_matches('\n').to_string(),
125+ content: LineContent::Normal(trimmed.to_string()),
134+// ---------------------------------------------------------------------------
136+// ---------------------------------------------------------------------------
142+ ctx: &mut ParseContext,
143+ ) -> Result<Vec<Node>, ParseError> {
144+ let mut nodes = Vec::new();
146+ while self.pos < self.lines.len() {
147+ let indent = self.lines[self.pos].indent;
148+ if indent < min_indent {
152+ match self.parse_line(ctx)? {
153+ Some(node) => nodes.push(node),
154+ None => {} // directive consumed, no node
161+ fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Node>, ParseError> {
162+ let line_num = self.lines[self.pos].line_num;
163+ let current_indent = self.lines[self.pos].indent;
165+ // Handle raw content
166+ if let LineContent::Raw(s) = &self.lines[self.pos].content {
167+ let content = s.clone();
169+ return Ok(Some(Node::Raw(content)));
172+ // Normal content — clone to release borrow
173+ let content = match &self.lines[self.pos].content {
174+ LineContent::Normal(s) => s.clone(),
175+ _ => unreachable!(),
179+ // --- Directives ---
181+ if let Some(rest) = content.strip_prefix("@page ") {
182+ ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
186+ if let Some(rest) = content.strip_prefix("@let ") {
187+ let rest = rest.trim();
188+ if let Some((name, value)) = rest.split_once(' ') {
190+ .insert(name.to_string(), substitute_vars(value.trim(), &ctx.variables));
195+ if let Some(rest) = content.strip_prefix("@define ") {
196+ let rest = rest.trim();
197+ if let Some(bracket_start) = rest.find('[') {
198+ let name = rest[..bracket_start].trim();
199+ let attrs_str = &rest[bracket_start..];
200+ let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
201+ ctx.defines.insert(name.to_string(), attrs);
206+ if content.starts_with("@include ") {
207+ // TODO: implement file includes
213+ if content.starts_with('@') || content.starts_with('[') {
214+ let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
215+ return Ok(Some(node));
218+ // --- Bare text ---
220+ let segments = parse_text_segments(&content, ctx);
221+ Ok(Some(Node::Text(segments)))
224+ fn parse_element_line(
227+ current_indent: usize,
229+ ctx: &mut ParseContext,
230+ ) -> Result<Node, ParseError> {
231+ let segments = split_chain(content);
233+ // Parse each segment into an Element
234+ let mut elements: Vec<Element> = Vec::new();
235+ for seg in &segments {
236+ let elem = parse_single_element(seg.trim(), line_num, ctx)?;
237+ elements.push(elem);
240+ // Parse indented children (belong to the innermost element)
241+ let children = self.parse_children(current_indent + 1, ctx)?;
243+ // Build chain right-to-left: rightmost gets children, each wraps the next
244+ let mut current_children = children;
245+ for mut elem in elements.into_iter().rev() {
246+ elem.children.extend(current_children);
247+ current_children = vec![Node::Element(elem)];
250+ Ok(current_children.into_iter().next().unwrap())
254+// ---------------------------------------------------------------------------
256+// ---------------------------------------------------------------------------
258+fn parse_single_element(
262+) -> Result<Element, ParseError> {
263+ let (kind, rest) = if content.starts_with('[') {
265+ (ElementKind::El, content.to_string())
266+ } else if content.starts_with('@') {
267+ let without_at = &content[1..];
268+ match without_at.find(|c: char| c == ' ' || c == '[') {
270+ let kind_str = &without_at[..i];
271+ let rest = if without_at.as_bytes()[i] == b'[' {
272+ without_at[i..].to_string()
274+ without_at[i + 1..].to_string()
276+ (parse_element_kind(kind_str, line_num)?, rest)
278+ None => (parse_element_kind(without_at, line_num)?, String::new()),
281+ return Err(ParseError {
283+ message: format!("expected @element or [attrs], got: {}", content),
287+ // Parse optional [attrs]
288+ let (attrs, rest) = if rest.starts_with('[') {
289+ parse_attr_brackets(&rest, line_num, ctx)?
294+ let rest = rest.trim().to_string();
296+ // For @link, first token of rest is URL, remainder is inline text
297+ let mut children = Vec::new();
298+ let argument = if rest.is_empty() {
300+ } else if kind == ElementKind::Link {
301+ let rest_sub = substitute_vars(&rest, &ctx.variables);
302+ if let Some((url, text)) = rest_sub.split_once(' ') {
303+ let text = text.trim();
304+ if !text.is_empty() {
305+ children.push(Node::Text(parse_text_segments(text, ctx)));
307+ Some(url.to_string())
312+ Some(substitute_vars(&rest, &ctx.variables))
323+fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
325+ "row" => Ok(ElementKind::Row),
326+ "column" | "col" => Ok(ElementKind::Column),
327+ "el" => Ok(ElementKind::El),
328+ "text" => Ok(ElementKind::Text),
329+ "paragraph" | "p" => Ok(ElementKind::Paragraph),
330+ "image" | "img" => Ok(ElementKind::Image),
331+ "link" => Ok(ElementKind::Link),
332+ _ => Err(ParseError {
334+ message: format!("unknown element @{}", s),
339+// ---------------------------------------------------------------------------
341+// ---------------------------------------------------------------------------
343+fn parse_attr_brackets(
347+) -> Result<(Vec<Attribute>, String), ParseError> {
349+ let mut end_pos = 0;
351+ for (i, c) in input.char_indices() {
366+ return Err(ParseError {
368+ message: "unclosed '[' in attribute list".to_string(),
372+ let attrs_inner = &input[1..end_pos];
373+ let remaining = input[end_pos + 1..].trim().to_string();
374+ let attrs = parse_attr_list(attrs_inner, ctx);
376+ Ok((attrs, remaining))
379+fn parse_attr_list(input: &str, ctx: &ParseContext) -> Vec<Attribute> {
380+ let mut attrs = Vec::new();
382+ for part in split_commas(input) {
383+ let part = part.trim();
384+ if part.is_empty() {
388+ // $define reference — expand
389+ if part.starts_with('$') {
390+ let name = &part[1..];
391+ if let Some(define_attrs) = ctx.defines.get(name) {
392+ attrs.extend(define_attrs.clone());
397+ // Substitute variables in value
398+ let part = substitute_vars(part, &ctx.variables);
400+ if let Some((key, value)) = part.split_once(' ') {
401+ attrs.push(Attribute {
402+ key: key.trim().to_string(),
403+ value: Some(value.trim().to_string()),
406+ attrs.push(Attribute {
407+ key: part.to_string(),
416+fn split_commas(input: &str) -> Vec<&str> {
417+ let mut parts = Vec::new();
421+ for (i, c) in input.char_indices() {
425+ ',' if depth == 0 => {
426+ parts.push(&input[start..i]);
432+ parts.push(&input[start..]);
436+// ---------------------------------------------------------------------------
437+// Chain splitting (the > operator)
438+// ---------------------------------------------------------------------------
440+fn split_chain(content: &str) -> Vec<String> {
441+ let mut segments = Vec::new();
442+ let mut current = String::new();
443+ let chars: Vec<char> = content.chars().collect();
445+ let mut bracket_depth = 0;
447+ while i < chars.len() {
448+ if chars[i] == '[' {
451+ if chars[i] == ']' {
455+ // Match " > @" outside brackets
456+ if bracket_depth == 0
457+ && i + 3 < chars.len()
459+ && chars[i + 1] == '>'
460+ && chars[i + 2] == ' '
461+ && chars[i + 3] == '@'
463+ segments.push(current.trim().to_string());
464+ current = String::new();
465+ i += 3; // skip " > ", keep the "@"
469+ current.push(chars[i]);
473+ if !current.trim().is_empty() {
474+ segments.push(current.trim().to_string());
480+// ---------------------------------------------------------------------------
481+// Text segment parsing (inline {...} elements)
482+// ---------------------------------------------------------------------------
484+fn parse_text_segments(input: &str, ctx: &ParseContext) -> Vec<TextSegment> {
485+ let mut segments = Vec::new();
486+ let mut current_text = String::new();
487+ let chars: Vec<char> = input.chars().collect();
490+ while i < chars.len() {
492+ && i + 1 < chars.len()
493+ && chars[i + 1] == '@'
495+ // Flush accumulated plain text
496+ if !current_text.is_empty() {
497+ segments.push(TextSegment::Plain(substitute_vars(
501+ current_text.clear();
506+ let start = i + 1; // after {
509+ if i >= chars.len() {
512+ if chars[i] == '{' {
514+ } else if chars[i] == '}' {
523+ let inner: String = chars[start..i].iter().collect();
526+ if let Ok(elem) = parse_single_element(&inner, 0, ctx) {
527+ segments.push(TextSegment::Inline(elem));
529+ current_text.push('{');
530+ current_text.push_str(&inner);
531+ current_text.push('}');
534+ current_text.push(chars[i]);
539+ if !current_text.is_empty() {
540+ segments.push(TextSegment::Plain(substitute_vars(
549+// ---------------------------------------------------------------------------
550+// Variable substitution
551+// ---------------------------------------------------------------------------
553+fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
554+ if !input.contains('$') {
555+ return input.to_string();
558+ let mut result = String::new();
559+ let chars: Vec<char> = input.chars().collect();
562+ while i < chars.len() {
563+ if chars[i] == '$' && i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
566+ let mut end = start;
567+ while end < chars.len()
568+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
572+ let name: String = chars[start..end].iter().collect();
573+ if let Some(value) = vars.get(&name) {
574+ result.push_str(value);
577+ result.push_str(&name);
581+ result.push(chars[i]);