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 pre-wrapped string. Exported for tests that supply in-memory content.
45pub fn parse_wrapped(wrapped: &str, base_ref: u64) -> Result<IndexMap<String, Element>, MpsError> {
46    let open_re  = at_regexp();
47    let close_re = end_curly_regexp();
48
49    struct Frame {
50        sign:          String,
51        args:          String,
52        body_start:    usize,
53        child_counter: u64,
54        ref_path:      Vec<u64>,
55    }
56
57    let mut elements: IndexMap<String, Element> = IndexMap::new();
58    let mut stack: Vec<Frame> = Vec::new();
59    let mut pos = 0usize;
60
61    loop {
62        if pos >= wrapped.len() { break; }
63
64        let open_m  = open_re.find_at(wrapped, pos);
65        let close_m = close_re.find_at(wrapped, pos);
66
67        let use_open = match (open_m, close_m) {
68            (None, None)       => break,
69            (Some(_), None)    => true,
70            (None, Some(_))    => false,
71            (Some(o), Some(c)) => o.start() < c.start(),
72        };
73
74        if use_open {
75            let om = open_m.unwrap();
76
77            // Build ref_path for this new frame.
78            let ref_path = if stack.is_empty() {
79                vec![base_ref]
80            } else {
81                let parent = stack.last_mut().unwrap();
82                parent.child_counter += 1;
83                let mut p = parent.ref_path.clone();
84                p.push(parent.child_counter);
85                p
86            };
87
88            let caps = open_re.captures_at(wrapped, om.start()).unwrap();
89            let sign = caps.name("element_sign")
90                .map_or("", |m| m.as_str())
91                .to_string();
92            let args = caps.name("args")
93                .map_or("", |m| m.as_str())
94                .to_string();
95
96            stack.push(Frame { sign, args, body_start: om.end(), child_counter: 0, ref_path });
97            pos = om.end();
98        } else {
99            let cm = close_m.unwrap();
100            if stack.is_empty() { break; }
101
102            let frame    = stack.pop().unwrap();
103            let body_str = wrapped[frame.body_start..cm.start()].to_string();
104            let ref_key  = frame.ref_path.iter()
105                .map(|n| n.to_string())
106                .collect::<Vec<_>>()
107                .join(".");
108
109            let el = Element::from_parts(&frame.sign, frame.args, frame.ref_path, body_str);
110            elements.insert(ref_key, el);
111            pos = cm.end();
112        }
113    }
114
115    Ok(elements)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::elements::ElementKind;
122    use std::io::Write;
123
124    fn parse_content(content: &str) -> IndexMap<String, Element> {
125        let wrapped = format!("@mps[]{{\n{}\n}}", content);
126        parse_wrapped(&wrapped, 20260101).unwrap()
127    }
128
129    #[test]
130    fn test_empty_file() {
131        let els = parse_content("");
132        assert_eq!(els.len(), 1, "only root @mps wrapper");
133        let (key, el) = els.iter().next().unwrap();
134        assert_eq!(key, "20260101");
135        assert!(el.is_mps_group());
136    }
137
138    #[test]
139    fn test_single_task() {
140        let els = parse_content("@task[work]{\n  Do the thing\n}");
141        assert_eq!(els.len(), 2);
142        let task = els.get("20260101.1").unwrap();
143        assert_eq!(task.kind(), ElementKind::Task);
144        assert!(task.tags().contains(&"work".to_string()));
145    }
146
147    #[test]
148    fn test_multiple_elements_sequential_refs() {
149        let els = parse_content("@task{ First }\n@note{ Second }");
150        assert!(els.contains_key("20260101.1"), "first child");
151        assert!(els.contains_key("20260101.2"), "second child");
152    }
153
154    #[test]
155    fn test_nested_mps_block() {
156        let els = parse_content("@mps{\n  @task{ Nested task }\n}");
157        assert!(els.contains_key("20260101.1"),   "@mps child");
158        assert!(els.contains_key("20260101.1.1"), "@task inside @mps");
159    }
160
161    #[test]
162    fn test_args_captured() {
163        let els = parse_content("@task[work, status: done]{ Done thing }");
164        let el = els.get("20260101.1").unwrap();
165        if let Element::Task { data, .. } = el {
166            assert!(data.is_done());
167            assert_eq!(data.tags, vec!["work"]);
168        } else {
169            panic!("expected Task variant");
170        }
171    }
172
173    #[test]
174    fn test_optional_brackets() {
175        let els = parse_content("@task{ No brackets }");
176        assert_eq!(els.get("20260101.1").unwrap().kind(), ElementKind::Task);
177    }
178
179    #[test]
180    fn test_unknown_element() {
181        let els = parse_content("@widget{ Unknown type }");
182        assert_eq!(els.get("20260101.1").unwrap().kind(), ElementKind::Unknown);
183    }
184
185    #[test]
186    fn test_deeply_nested() {
187        let content = "@mps{\n  @mps{\n    @task{ Deep }\n  }\n}";
188        let els = parse_content(content);
189        assert!(els.contains_key("20260101.1.1.1"), "deeply nested task ref");
190    }
191
192    #[test]
193    fn test_parse_real_file() {
194        let dir = tempfile::tempdir().unwrap();
195        let path = dir.path().join("20260101.1700000000.mps");
196        let mut f = std::fs::File::create(&path).unwrap();
197        writeln!(f, "@task[work]{{ Do the thing }}").unwrap();
198        writeln!(f, "@note{{ A note }}").unwrap();
199        drop(f);
200
201        let els = parse_file(&path).unwrap();
202        assert_eq!(els.len(), 3); // root mps + task + note
203    }
204}