@@ -0,0 +1,587 @@
1+use std::collections::HashMap;
2+use std::fmt;
3+
4+use crate::ast::*;
5+
6+#[derive(Debug)]
7+pub struct ParseError {
8+ pub line: usize,
9+ pub message: String,
10+}
11+
12+impl fmt::Display for ParseError {
13+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
14+ write!(f, "line {}: {}", self.line, self.message)
15+ }
16+}
17+
18+enum LineContent {
19+ Normal(String),
20+ Raw(String),
21+}
22+
23+struct Line {
24+ indent: usize,
25+ content: LineContent,
26+ line_num: usize,
27+}
28+
29+struct ParseContext {
30+ page_title: Option<String>,
31+ variables: HashMap<String, String>,
32+ defines: HashMap<String, Vec<Attribute>>,
33+}
34+
35+struct Parser {
36+ lines: Vec<Line>,
37+ pos: usize,
38+}
39+
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 {
44+ page_title: None,
45+ variables: HashMap::new(),
46+ defines: HashMap::new(),
47+ };
48+ let nodes = parser.parse_children(0, &mut ctx)?;
49+ Ok(Document {
50+ page_title: ctx.page_title,
51+ variables: ctx.variables,
52+ defines: ctx.defines,
53+ nodes,
54+ })
55+}
56+
57+// ---------------------------------------------------------------------------
58+// Preprocessing: strip comments/blanks, collapse @raw blocks
59+// ---------------------------------------------------------------------------
60+
61+fn preprocess(input: &str) -> Vec<Line> {
62+ let raw_lines: Vec<&str> = input.lines().collect();
63+ let mut lines = Vec::new();
64+ let mut i = 0;
65+
66+ while i < raw_lines.len() {
67+ let line = raw_lines[i];
68+ let trimmed = line.trim();
69+
70+ if trimmed.is_empty() || trimmed.starts_with("--") {
71+ i += 1;
72+ continue;
73+ }
74+
75+ let indent = line.len() - line.trim_start().len();
76+
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..];
82+
83+ // Single-line: @raw """content"""
84+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
85+ let content = &after_open[..after_open.len() - 3];
86+ lines.push(Line {
87+ indent,
88+ content: LineContent::Raw(content.to_string()),
89+ line_num: i + 1,
90+ });
91+ i += 1;
92+ continue;
93+ }
94+
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');
100+ }
101+ i += 1;
102+ while i < raw_lines.len() {
103+ if raw_lines[i].trim() == "\"\"\"" {
104+ i += 1;
105+ break;
106+ }
107+ raw_content.push_str(raw_lines[i]);
108+ raw_content.push('\n');
109+ i += 1;
110+ }
111+
112+ lines.push(Line {
113+ indent,
114+ content: LineContent::Raw(
115+ raw_content.trim_end_matches('\n').to_string(),
116+ ),
117+ line_num: i,
118+ });
119+ continue;
120+ }
121+ }
122+
123+ lines.push(Line {
124+ indent,
125+ content: LineContent::Normal(trimmed.to_string()),
126+ line_num: i + 1,
127+ });
128+ i += 1;
129+ }
130+
131+ lines
132+}
133+
134+// ---------------------------------------------------------------------------
135+// Parser
136+// ---------------------------------------------------------------------------
137+
138+impl Parser {
139+ fn parse_children(
140+ &mut self,
141+ min_indent: usize,
142+ ctx: &mut ParseContext,
143+ ) -> Result<Vec<Node>, ParseError> {
144+ let mut nodes = Vec::new();
145+
146+ while self.pos < self.lines.len() {
147+ let indent = self.lines[self.pos].indent;
148+ if indent < min_indent {
149+ break;
150+ }
151+
152+ match self.parse_line(ctx)? {
153+ Some(node) => nodes.push(node),
154+ None => {} // directive consumed, no node
155+ }
156+ }
157+
158+ Ok(nodes)
159+ }
160+
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;
164+
165+ // Handle raw content
166+ if let LineContent::Raw(s) = &self.lines[self.pos].content {
167+ let content = s.clone();
168+ self.pos += 1;
169+ return Ok(Some(Node::Raw(content)));
170+ }
171+
172+ // Normal content — clone to release borrow
173+ let content = match &self.lines[self.pos].content {
174+ LineContent::Normal(s) => s.clone(),
175+ _ => unreachable!(),
176+ };
177+ self.pos += 1;
178+
179+ // --- Directives ---
180+
181+ if let Some(rest) = content.strip_prefix("@page ") {
182+ ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
183+ return Ok(None);
184+ }
185+
186+ if let Some(rest) = content.strip_prefix("@let ") {
187+ let rest = rest.trim();
188+ if let Some((name, value)) = rest.split_once(' ') {
189+ ctx.variables
190+ .insert(name.to_string(), substitute_vars(value.trim(), &ctx.variables));
191+ }
192+ return Ok(None);
193+ }
194+
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);
202+ }
203+ return Ok(None);
204+ }
205+
206+ if content.starts_with("@include ") {
207+ // TODO: implement file includes
208+ return Ok(None);
209+ }
210+
211+ // --- Elements ---
212+
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));
216+ }
217+
218+ // --- Bare text ---
219+
220+ let segments = parse_text_segments(&content, ctx);
221+ Ok(Some(Node::Text(segments)))
222+ }
223+
224+ fn parse_element_line(
225+ &mut self,
226+ content: &str,
227+ current_indent: usize,
228+ line_num: usize,
229+ ctx: &mut ParseContext,
230+ ) -> Result<Node, ParseError> {
231+ let segments = split_chain(content);
232+
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);
238+ }
239+
240+ // Parse indented children (belong to the innermost element)
241+ let children = self.parse_children(current_indent + 1, ctx)?;
242+
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)];
248+ }
249+
250+ Ok(current_children.into_iter().next().unwrap())
251+ }
252+}
253+
254+// ---------------------------------------------------------------------------
255+// Element parsing
256+// ---------------------------------------------------------------------------
257+
258+fn parse_single_element(
259+ content: &str,
260+ line_num: usize,
261+ ctx: &ParseContext,
262+) -> Result<Element, ParseError> {
263+ let (kind, rest) = if content.starts_with('[') {
264+ // Implicit @el
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 == '[') {
269+ Some(i) => {
270+ let kind_str = &without_at[..i];
271+ let rest = if without_at.as_bytes()[i] == b'[' {
272+ without_at[i..].to_string()
273+ } else {
274+ without_at[i + 1..].to_string()
275+ };
276+ (parse_element_kind(kind_str, line_num)?, rest)
277+ }
278+ None => (parse_element_kind(without_at, line_num)?, String::new()),
279+ }
280+ } else {
281+ return Err(ParseError {
282+ line: line_num,
283+ message: format!("expected @element or [attrs], got: {}", content),
284+ });
285+ };
286+
287+ // Parse optional [attrs]
288+ let (attrs, rest) = if rest.starts_with('[') {
289+ parse_attr_brackets(&rest, line_num, ctx)?
290+ } else {
291+ (Vec::new(), rest)
292+ };
293+
294+ let rest = rest.trim().to_string();
295+
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() {
299+ None
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)));
306+ }
307+ Some(url.to_string())
308+ } else {
309+ Some(rest_sub)
310+ }
311+ } else {
312+ Some(substitute_vars(&rest, &ctx.variables))
313+ };
314+
315+ Ok(Element {
316+ kind,
317+ attrs,
318+ argument,
319+ children,
320+ })
321+}
322+
323+fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
324+ match s {
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 {
333+ line: line_num,
334+ message: format!("unknown element @{}", s),
335+ }),
336+ }
337+}
338+
339+// ---------------------------------------------------------------------------
340+// Attribute parsing
341+// ---------------------------------------------------------------------------
342+
343+fn parse_attr_brackets(
344+ input: &str,
345+ line_num: usize,
346+ ctx: &ParseContext,
347+) -> Result<(Vec<Attribute>, String), ParseError> {
348+ let mut depth = 0;
349+ let mut end_pos = 0;
350+
351+ for (i, c) in input.char_indices() {
352+ match c {
353+ '[' => depth += 1,
354+ ']' => {
355+ depth -= 1;
356+ if depth == 0 {
357+ end_pos = i;
358+ break;
359+ }
360+ }
361+ _ => {}
362+ }
363+ }
364+
365+ if depth != 0 {
366+ return Err(ParseError {
367+ line: line_num,
368+ message: "unclosed '[' in attribute list".to_string(),
369+ });
370+ }
371+
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);
375+
376+ Ok((attrs, remaining))
377+}
378+
379+fn parse_attr_list(input: &str, ctx: &ParseContext) -> Vec<Attribute> {
380+ let mut attrs = Vec::new();
381+
382+ for part in split_commas(input) {
383+ let part = part.trim();
384+ if part.is_empty() {
385+ continue;
386+ }
387+
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());
393+ continue;
394+ }
395+ }
396+
397+ // Substitute variables in value
398+ let part = substitute_vars(part, &ctx.variables);
399+
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()),
404+ });
405+ } else {
406+ attrs.push(Attribute {
407+ key: part.to_string(),
408+ value: None,
409+ });
410+ }
411+ }
412+
413+ attrs
414+}
415+
416+fn split_commas(input: &str) -> Vec<&str> {
417+ let mut parts = Vec::new();
418+ let mut start = 0;
419+ let mut depth = 0;
420+
421+ for (i, c) in input.char_indices() {
422+ match c {
423+ '[' => depth += 1,
424+ ']' => depth -= 1,
425+ ',' if depth == 0 => {
426+ parts.push(&input[start..i]);
427+ start = i + 1;
428+ }
429+ _ => {}
430+ }
431+ }
432+ parts.push(&input[start..]);
433+ parts
434+}
435+
436+// ---------------------------------------------------------------------------
437+// Chain splitting (the > operator)
438+// ---------------------------------------------------------------------------
439+
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();
444+ let mut i = 0;
445+ let mut bracket_depth = 0;
446+
447+ while i < chars.len() {
448+ if chars[i] == '[' {
449+ bracket_depth += 1;
450+ }
451+ if chars[i] == ']' {
452+ bracket_depth -= 1;
453+ }
454+
455+ // Match " > @" outside brackets
456+ if bracket_depth == 0
457+ && i + 3 < chars.len()
458+ && chars[i] == ' '
459+ && chars[i + 1] == '>'
460+ && chars[i + 2] == ' '
461+ && chars[i + 3] == '@'
462+ {
463+ segments.push(current.trim().to_string());
464+ current = String::new();
465+ i += 3; // skip " > ", keep the "@"
466+ continue;
467+ }
468+
469+ current.push(chars[i]);
470+ i += 1;
471+ }
472+
473+ if !current.trim().is_empty() {
474+ segments.push(current.trim().to_string());
475+ }
476+
477+ segments
478+}
479+
480+// ---------------------------------------------------------------------------
481+// Text segment parsing (inline {...} elements)
482+// ---------------------------------------------------------------------------
483+
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();
488+ let mut i = 0;
489+
490+ while i < chars.len() {
491+ if chars[i] == '{'
492+ && i + 1 < chars.len()
493+ && chars[i + 1] == '@'
494+ {
495+ // Flush accumulated plain text
496+ if !current_text.is_empty() {
497+ segments.push(TextSegment::Plain(substitute_vars(
498+ ¤t_text,
499+ &ctx.variables,
500+ )));
501+ current_text.clear();
502+ }
503+
504+ // Find matching }
505+ let mut depth = 0;
506+ let start = i + 1; // after {
507+ i += 1;
508+ loop {
509+ if i >= chars.len() {
510+ break;
511+ }
512+ if chars[i] == '{' {
513+ depth += 1;
514+ } else if chars[i] == '}' {
515+ if depth == 0 {
516+ break;
517+ }
518+ depth -= 1;
519+ }
520+ i += 1;
521+ }
522+
523+ let inner: String = chars[start..i].iter().collect();
524+ i += 1; // skip }
525+
526+ if let Ok(elem) = parse_single_element(&inner, 0, ctx) {
527+ segments.push(TextSegment::Inline(elem));
528+ } else {
529+ current_text.push('{');
530+ current_text.push_str(&inner);
531+ current_text.push('}');
532+ }
533+ } else {
534+ current_text.push(chars[i]);
535+ i += 1;
536+ }
537+ }
538+
539+ if !current_text.is_empty() {
540+ segments.push(TextSegment::Plain(substitute_vars(
541+ ¤t_text,
542+ &ctx.variables,
543+ )));
544+ }
545+
546+ segments
547+}
548+
549+// ---------------------------------------------------------------------------
550+// Variable substitution
551+// ---------------------------------------------------------------------------
552+
553+fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
554+ if !input.contains('$') {
555+ return input.to_string();
556+ }
557+
558+ let mut result = String::new();
559+ let chars: Vec<char> = input.chars().collect();
560+ let mut i = 0;
561+
562+ while i < chars.len() {
563+ if chars[i] == '$' && i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
564+ {
565+ let start = i + 1;
566+ let mut end = start;
567+ while end < chars.len()
568+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
569+ {
570+ end += 1;
571+ }
572+ let name: String = chars[start..end].iter().collect();
573+ if let Some(value) = vars.get(&name) {
574+ result.push_str(value);
575+ } else {
576+ result.push('$');
577+ result.push_str(&name);
578+ }
579+ i = end;
580+ } else {
581+ result.push(chars[i]);
582+ i += 1;
583+ }
584+ }
585+
586+ result
587+}