Skip to main content

mps/
parser.rs

1//! Position-based stack parser for `.mps` files.
2//!
3//! Mirrors Ruby's `Engines::Parser.parse_mps_file_to_elements_hash` exactly.
4//! Returns an [`IndexMap`] keyed by dotted ref-path (`"20260501.1.2"`).
5
6use std::path::Path;
7use indexmap::IndexMap;
8use crate::constants::{at_regexp, end_curly_regexp};
9use crate::elements::Element;
10use crate::error::MpsError;
11
12/// Parses a .mps file into a flat ordered map keyed by dotted ref-path (e.g. "1746000000.1.2").
13///
14/// Algorithm mirrors Ruby's Engines::Parser.parse_mps_file_to_elements_hash exactly:
15///   1. Wrap file content in a synthetic @mps[]{\n...\n} root.
16///   2. Single-pass: at each pos, find the nearest @element[args]{ open and } close.
17///   3. Whichever starts earlier wins.
18///   4. Open → push stack frame; Close → pop, emit element keyed by dotted ref path.
19pub fn parse_file(path: &Path) -> Result<IndexMap<String, Element>, MpsError> {
20    let content = std::fs::read_to_string(path)
21        .map_err(|e| MpsError::ParseError {
22            file: path.display().to_string(),
23            msg:  e.to_string(),
24        })?;
25
26    let basename = path
27        .file_name()
28        .and_then(|n| n.to_str())
29        .unwrap_or("0");
30
31    // Base ref = YYYYMMDD as integer (same as Ruby's MPS_FILE_NAME_CLIPPER.call(...).to_i).
32    // Ruby's .to_i on "20260501.1746113538" stops at the dot → 20260501.
33    // We extract just the first 8-digit date portion.
34    let base_ref: u64 = if basename.len() >= 8 {
35        basename[..8].parse().unwrap_or(0)
36    } else {
37        0
38    };
39
40    let wrapped = format!("@mps[]{{\n{}\n}}", content);
41    parse_wrapped(&wrapped, base_ref)
42}
43
44/// Parse a raw `.mps` content string (without a surrounding `@mps{}` wrapper).
45/// Uses base_ref 0 so ref-paths are `0.1`, `0.2`, etc. Intended for unit tests.
46pub fn parse_str(content: &str) -> IndexMap<String, Element> {
47    let wrapped = format!("@mps[]{{\n{}\n}}", content);
48    parse_wrapped(&wrapped, 0).unwrap_or_default()
49}
50
51/// Parse a pre-wrapped string. Exported for tests that supply in-memory content.
52pub fn parse_wrapped(wrapped: &str, base_ref: u64) -> Result<IndexMap<String, Element>, MpsError> {
53    let open_re  = at_regexp();
54    let close_re = end_curly_regexp();
55
56    struct Frame {
57        sign:          String,
58        args:          String,
59        body_start:    usize,
60        child_counter: u64,
61        ref_path:      Vec<u64>,
62    }
63
64    let mut elements: IndexMap<String, Element> = IndexMap::new();
65    let mut stack: Vec<Frame> = Vec::new();
66    let mut pos = 0usize;
67
68    loop {
69        if pos >= wrapped.len() { break; }
70
71        let open_m  = open_re.find_at(wrapped, pos);
72        let close_m = close_re.find_at(wrapped, pos);
73
74        let use_open = match (open_m, close_m) {
75            (None, None)       => break,
76            (Some(_), None)    => true,
77            (None, Some(_))    => false,
78            (Some(o), Some(c)) => o.start() < c.start(),
79        };
80
81        if use_open {
82            let om = open_m.unwrap();
83
84            // Build ref_path for this new frame.
85            let ref_path = if stack.is_empty() {
86                vec![base_ref]
87            } else {
88                let parent = stack.last_mut().unwrap();
89                parent.child_counter += 1;
90                let mut p = parent.ref_path.clone();
91                p.push(parent.child_counter);
92                p
93            };
94
95            let caps = open_re.captures_at(wrapped, om.start()).unwrap();
96            let sign = caps.name("element_sign")
97                .map_or("", |m| m.as_str())
98                .to_string();
99            let args = caps.name("args")
100                .map_or("", |m| m.as_str())
101                .to_string();
102
103            stack.push(Frame { sign, args, body_start: om.end(), child_counter: 0, ref_path });
104            pos = om.end();
105        } else {
106            let cm = close_m.unwrap();
107            if stack.is_empty() { break; }
108
109            let frame    = stack.pop().unwrap();
110            let body_str = wrapped[frame.body_start..cm.start()].to_string();
111            let ref_key  = frame.ref_path.iter()
112                .map(|n| n.to_string())
113                .collect::<Vec<_>>()
114                .join(".");
115
116            let el = Element::from_parts(&frame.sign, frame.args, frame.ref_path, body_str);
117            elements.insert(ref_key, el);
118            pos = cm.end();
119        }
120    }
121
122    Ok(elements)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::elements::ElementKind;
129    use std::io::Write;
130
131    fn parse_content(content: &str) -> IndexMap<String, Element> {
132        let wrapped = format!("@mps[]{{\n{}\n}}", content);
133        parse_wrapped(&wrapped, 20260101).unwrap()
134    }
135
136    #[test]
137    fn test_empty_file() {
138        let els = parse_content("");
139        assert_eq!(els.len(), 1, "only root @mps wrapper");
140        let (key, el) = els.iter().next().unwrap();
141        assert_eq!(key, "20260101");
142        assert!(el.is_mps_group());
143    }
144
145    #[test]
146    fn test_single_task() {
147        let els = parse_content("@task[work]{\n  Do the thing\n}");
148        assert_eq!(els.len(), 2);
149        let task = els.get("20260101.1").unwrap();
150        assert_eq!(task.kind(), ElementKind::Task);
151        assert!(task.tags().contains(&"work".to_string()));
152    }
153
154    #[test]
155    fn test_multiple_elements_sequential_refs() {
156        let els = parse_content("@task{ First }\n@note{ Second }");
157        assert!(els.contains_key("20260101.1"), "first child");
158        assert!(els.contains_key("20260101.2"), "second child");
159    }
160
161    #[test]
162    fn test_nested_mps_block() {
163        let els = parse_content("@mps{\n  @task{ Nested task }\n}");
164        assert!(els.contains_key("20260101.1"),   "@mps child");
165        assert!(els.contains_key("20260101.1.1"), "@task inside @mps");
166    }
167
168    #[test]
169    fn test_args_captured() {
170        let els = parse_content("@task[work, status: done]{ Done thing }");
171        let el = els.get("20260101.1").unwrap();
172        if let Element::Task { data, .. } = el {
173            assert!(data.is_done());
174            assert_eq!(data.tags, vec!["work"]);
175        } else {
176            panic!("expected Task variant");
177        }
178    }
179
180    #[test]
181    fn test_optional_brackets() {
182        let els = parse_content("@task{ No brackets }");
183        assert_eq!(els.get("20260101.1").unwrap().kind(), ElementKind::Task);
184    }
185
186    #[test]
187    fn test_unknown_element() {
188        let els = parse_content("@widget{ Unknown type }");
189        assert_eq!(els.get("20260101.1").unwrap().kind(), ElementKind::Unknown);
190    }
191
192    #[test]
193    fn test_deeply_nested() {
194        let content = "@mps{\n  @mps{\n    @task{ Deep }\n  }\n}";
195        let els = parse_content(content);
196        assert!(els.contains_key("20260101.1.1.1"), "deeply nested task ref");
197    }
198
199    #[test]
200    fn test_parse_real_file() {
201        let dir = tempfile::tempdir().unwrap();
202        let path = dir.path().join("20260101.1700000000.mps");
203        let mut f = std::fs::File::create(&path).unwrap();
204        writeln!(f, "@task[work]{{ Do the thing }}").unwrap();
205        writeln!(f, "@note{{ A note }}").unwrap();
206        drop(f);
207
208        let els = parse_file(&path).unwrap();
209        assert_eq!(els.len(), 3); // root mps + task + note
210    }
211}