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