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