Skip to main content

opys_engine/mdprism/
parse.rs

1//! Parser: DSL source → [`Schema`]. Line-oriented; indentation builds the body
2//! tree. See `docs/structure-dsl-spec.md` for the grammar.
3
4use super::error::SchemaError;
5use super::schema::*;
6
7/// Parse a schema from DSL source.
8pub fn parse_schema(src: &str) -> Result<Schema, SchemaError> {
9    let lines: Vec<&str> = src.lines().collect();
10    let mut opts = SchemaOpts::default();
11    let mut i = 0;
12
13    // Phase 1: leading `%`-directives and `<? … ?>` / blank comment lines.
14    while i < lines.len() {
15        let (content, _) = split_desc(lines[i]);
16        let t = content.trim();
17        if t.is_empty() {
18            i += 1;
19        } else if let Some(rest) = t.strip_prefix('%') {
20            apply_directive(rest, i + 1, &mut opts)?;
21            i += 1;
22        } else {
23            break;
24        }
25    }
26
27    // Phase 2: optional frontmatter block.
28    let mut frontmatter = Vec::new();
29    if i < lines.len() && split_desc(lines[i]).0.trim() == "---" {
30        i += 1;
31        loop {
32            let line = lines.get(i).ok_or_else(|| {
33                SchemaError::new(i, 1, "unterminated frontmatter (missing `---`)")
34            })?;
35            let (content, desc) = split_desc(line);
36            if content.trim() == "---" {
37                i += 1;
38                break;
39            }
40            if !content.trim().is_empty() {
41                let mut f = parse_field(content, i + 1)?;
42                f.desc = desc;
43                frontmatter.push(f);
44            }
45            i += 1;
46        }
47    }
48
49    // Phase 3: body nodes, flat with indents, then assembled into a tree.
50    let mut flat: Vec<(usize, Node)> = Vec::new();
51    while i < lines.len() {
52        let line = lines[i];
53        let (content, desc) = split_desc(line);
54        if content.trim().is_empty() {
55            i += 1;
56            continue;
57        }
58        let indent = content.len() - content.trim_start().len();
59        let node = parse_node(content.trim_start(), desc, i + 1)?;
60        flat.push((indent, node));
61        i += 1;
62    }
63    let mut pos = 0;
64    let body = build_tree(&flat, &mut pos, -1);
65
66    Ok(Schema {
67        opts,
68        frontmatter,
69        body,
70    })
71}
72
73// ---- directives ----------------------------------------------------------
74
75fn apply_directive(rest: &str, line: usize, opts: &mut SchemaOpts) -> Result<(), SchemaError> {
76    let (k, v) = rest
77        .split_once('=')
78        .ok_or_else(|| SchemaError::new(line, 1, "directive needs `key = value`"))?;
79    let (k, v) = (k.trim(), v.trim());
80    match (k, v) {
81        ("ordered", "true") => opts.ordered = true,
82        ("ordered", "false") => opts.ordered = false,
83        ("strict", "true") => opts.strict = true,
84        ("strict", "false") => opts.strict = false,
85        ("frontmatter", "open") => opts.frontmatter_open = true,
86        ("frontmatter", "closed") => opts.frontmatter_open = false,
87        _ => {
88            return Err(SchemaError::new(
89                line,
90                1,
91                format!("unknown directive `%{rest}`"),
92            ))
93        }
94    }
95    Ok(())
96}
97
98// ---- frontmatter ---------------------------------------------------------
99
100fn parse_field(content: &str, line: usize) -> Result<FieldSchema, SchemaError> {
101    let (left, ty_src) = content
102        .split_once(':')
103        .ok_or_else(|| SchemaError::new(line, 1, "frontmatter field needs `key: type`"))?;
104
105    // left = key[?] [@alias]
106    let left = left.trim();
107    let key_end = left
108        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
109        .unwrap_or(left.len());
110    if key_end == 0 {
111        return Err(SchemaError::new(line, 1, "frontmatter field needs a key"));
112    }
113    let key = left[..key_end].to_string();
114    let mut rest = left[key_end..].trim_start();
115    let optional = if let Some(r) = rest.strip_prefix('?') {
116        rest = r.trim_start();
117        true
118    } else {
119        false
120    };
121    let alias = if let Some(r) = rest.strip_prefix('@') {
122        r.trim().to_string()
123    } else {
124        key.clone()
125    };
126
127    let ty = parse_field_type(ty_src.trim(), line)?;
128    Ok(FieldSchema {
129        key,
130        alias,
131        optional,
132        ty,
133        desc: None,
134    })
135}
136
137fn parse_field_type(s: &str, line: usize) -> Result<FieldType, SchemaError> {
138    match s {
139        "string" => return Ok(FieldType::Str),
140        "int" => return Ok(FieldType::Int),
141        "bool" => return Ok(FieldType::Bool),
142        "date" => return Ok(FieldType::Date),
143        _ => {}
144    }
145    if let Some(inner) = s.strip_prefix("enum(").and_then(|s| s.strip_suffix(')')) {
146        let vals = inner
147            .split(',')
148            .map(|v| v.trim().to_string())
149            .filter(|v| !v.is_empty())
150            .collect();
151        return Ok(FieldType::Enum(vals));
152    }
153    if let Some(inner) = s.strip_prefix('[') {
154        // `[T]` with an optional trailing cardinality (e.g. `[string]+`).
155        let close = inner
156            .find(']')
157            .ok_or_else(|| SchemaError::new(line, 1, "list type missing `]`"))?;
158        let elem = parse_field_type(inner[..close].trim(), line)?;
159        return Ok(FieldType::List(Box::new(elem)));
160    }
161    if s.starts_with('/') {
162        return Ok(FieldType::Regex(parse_regex(s).0));
163    }
164    Err(SchemaError::new(line, 1, format!("unknown type `{s}`")))
165}
166
167// ---- body nodes ----------------------------------------------------------
168
169fn parse_node(content: &str, desc: Option<String>, line: usize) -> Result<Node, SchemaError> {
170    let (kind, head_src) = detect_marker(content)
171        .ok_or_else(|| SchemaError::new(line, 1, format!("unrecognized marker: `{content}`")))?;
172
173    let (card, rest) = parse_card(head_src);
174    let (name, rest) = parse_name(rest);
175    let label = parse_label(rest.trim());
176    let head = Head { name, card, desc };
177
178    Ok(match kind {
179        MarkerKind::Heading(level) => Node::Heading {
180            level,
181            title: label.unwrap_or(Match::Regex(".*".into())),
182            head,
183            children: Vec::new(),
184        },
185        MarkerKind::Prose => Node::Prose { text: label, head },
186        MarkerKind::Bullet => list(ListStyle::Bullet, label, head),
187        MarkerKind::Ordered => list(ListStyle::Ordered, label, head),
188        MarkerKind::Checklist => list(ListStyle::Checklist, label, head),
189    })
190}
191
192fn list(style: ListStyle, item: Option<Match>, head: Head) -> Node {
193    Node::List {
194        style,
195        item,
196        head,
197        children: Vec::new(),
198    }
199}
200
201enum MarkerKind {
202    Heading(u8),
203    Bullet,
204    Ordered,
205    Checklist,
206    Prose,
207}
208
209/// Detect the leading marker and return the kind + the remaining "head" text
210/// (everything after the marker and its following spaces).
211fn detect_marker(s: &str) -> Option<(MarkerKind, &str)> {
212    if let Some(rest) = s.strip_prefix("- [ ]") {
213        return Some((MarkerKind::Checklist, rest.trim_start()));
214    }
215    if s.starts_with('#') {
216        let level = s.chars().take_while(|&c| c == '#').count();
217        if (1..=6).contains(&level) {
218            let after = &s[level..];
219            // require a space (or end) after the hashes
220            if after.is_empty() || after.starts_with(' ') {
221                return Some((MarkerKind::Heading(level as u8), after.trim_start()));
222            }
223        }
224        return None;
225    }
226    if let Some(rest) = s.strip_prefix("- ") {
227        return Some((MarkerKind::Bullet, rest.trim_start()));
228    }
229    if s == "-" {
230        return Some((MarkerKind::Bullet, ""));
231    }
232    // ordered: digits then '.'
233    let digits = s.chars().take_while(|c| c.is_ascii_digit()).count();
234    if digits > 0 && s[digits..].starts_with('.') {
235        return Some((MarkerKind::Ordered, s[digits + 1..].trim_start()));
236    }
237    if let Some(rest) = s.strip_prefix('>') {
238        return Some((MarkerKind::Prose, rest.trim_start()));
239    }
240    None
241}
242
243fn parse_card(s: &str) -> (Card, &str) {
244    match s.as_bytes().first() {
245        Some(b'+') => (Card::Plus, &s[1..]),
246        Some(b'*') => (Card::Star, &s[1..]),
247        Some(b'?') => (Card::Optional, &s[1..]),
248        Some(b'{') => parse_range(s),
249        _ => (Card::Required, s),
250    }
251}
252
253fn parse_range(s: &str) -> (Card, &str) {
254    let Some(close) = s.find('}') else {
255        return (Card::Required, s);
256    };
257    let body = &s[1..close];
258    let (min_s, max_s) = match body.split_once(',') {
259        Some((a, b)) => (a.trim(), Some(b.trim())),
260        None => (body.trim(), None),
261    };
262    let Ok(min) = min_s.parse::<u32>() else {
263        return (Card::Required, s);
264    };
265    let max = match max_s {
266        None => Some(min),
267        Some("") => None,
268        Some(m) => match m.parse::<u32>() {
269            Ok(v) => Some(v),
270            Err(_) => return (Card::Required, s),
271        },
272    };
273    (Card::Range(min, max), &s[close + 1..])
274}
275
276fn parse_name(s: &str) -> (Option<String>, &str) {
277    let Some(rest) = s.strip_prefix('@') else {
278        return (None, s);
279    };
280    let end = rest
281        .find(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'))
282        .unwrap_or(rest.len());
283    if end == 0 {
284        return (None, s);
285    }
286    (Some(rest[..end].to_string()), &rest[end..])
287}
288
289fn parse_label(s: &str) -> Option<Match> {
290    let s = s.trim();
291    if s.is_empty() {
292        return None;
293    }
294    if s.starts_with('/') {
295        return Some(Match::Regex(parse_regex(s).0));
296    }
297    if let Some(rest) = s.strip_prefix('"') {
298        let lit = match rest.rsplit_once('"') {
299            Some((inner, _)) => inner,
300            None => rest,
301        };
302        return Some(Match::Literal(unescape(lit)));
303    }
304    Some(Match::Literal(unescape(s)))
305}
306
307/// Parse `/pattern/flags` → a regex pattern string (flags folded into `(?…)`).
308/// Returns `(pattern, rest_after)`.
309fn parse_regex(s: &str) -> (String, &str) {
310    debug_assert!(s.starts_with('/'));
311    let body = &s[1..];
312    // find the closing unescaped '/'
313    let mut end = None;
314    let bytes = body.as_bytes();
315    let mut k = 0;
316    while k < bytes.len() {
317        if bytes[k] == b'\\' {
318            k += 2;
319            continue;
320        }
321        if bytes[k] == b'/' {
322            end = Some(k);
323            break;
324        }
325        k += 1;
326    }
327    let Some(end) = end else {
328        return (body.to_string(), "");
329    };
330    let pat = &body[..end];
331    let after = &body[end + 1..];
332    let flag_len = after
333        .chars()
334        .take_while(|c| c.is_ascii_alphabetic())
335        .count();
336    let flags = &after[..flag_len];
337    let pattern = if flags.is_empty() {
338        pat.to_string()
339    } else {
340        format!("(?{flags}){pat}")
341    };
342    (pattern, &after[flag_len..])
343}
344
345/// Resolve backslash escapes: `\x` → `x`.
346fn unescape(s: &str) -> String {
347    let mut out = String::with_capacity(s.len());
348    let mut chars = s.chars();
349    while let Some(c) = chars.next() {
350        if c == '\\' {
351            if let Some(n) = chars.next() {
352                out.push(n);
353            }
354        } else {
355            out.push(c);
356        }
357    }
358    out
359}
360
361// ---- description / comment splitting -------------------------------------
362
363/// Split a line into its content and an optional `<? … ?>` description. The
364/// first unescaped `<?` starts the description; it runs to the trailing `?>`.
365fn split_desc(line: &str) -> (&str, Option<String>) {
366    let bytes = line.as_bytes();
367    let mut k = 0;
368    while k + 1 < bytes.len() {
369        if bytes[k] == b'\\' {
370            k += 2;
371            continue;
372        }
373        if bytes[k] == b'<' && bytes[k + 1] == b'?' {
374            let before = &line[..k];
375            let rest = &line[k + 2..];
376            let inner = rest.strip_suffix("?>").unwrap_or(rest);
377            return (before, Some(inner.trim().to_string()));
378        }
379        k += 1;
380    }
381    (line, None)
382}
383
384// ---- tree assembly -------------------------------------------------------
385
386/// Build the node tree from flat `(indent, node)` pairs: a node is a child of
387/// the most recent node with strictly smaller indent.
388fn build_tree(flat: &[(usize, Node)], pos: &mut usize, parent_indent: isize) -> Vec<Node> {
389    let mut out = Vec::new();
390    while *pos < flat.len() {
391        let indent = flat[*pos].0 as isize;
392        if indent <= parent_indent {
393            break;
394        }
395        let mut node = flat[*pos].1.clone();
396        *pos += 1;
397        let children = build_tree(flat, pos, indent);
398        node.set_children(children);
399        out.push(node);
400    }
401    out
402}