fur_cli/frs/
parser.rs

1use std::fs;
2use crate::frs::ast::{Thread, Message, ScriptItem, Command};
3use crate::frs::avatars::{
4    load_avatars, 
5};
6
7/// Pure parser: read .frs into a Thread struct (no side effects)
8pub fn parse_frs(path: &str) -> Thread {
9    let raw = fs::read_to_string(path).expect("❌ Could not read .frs file");
10    let lines: Vec<String> = raw
11        .lines()
12        .map(|l| l.trim().to_string())
13        .filter(|l| !l.is_empty() && !l.starts_with('#'))
14        .collect();
15
16    let mut i = 0usize;
17
18    // ---- header: new "Title"
19    let title = loop {
20        if i >= lines.len() {
21            panic!("❌ Missing `new \"Title\"` at top of file");
22        }
23        let line = &lines[i];
24        if line.starts_with("new ") {
25            break extract_quoted(line).unwrap_or_else(|| {
26                panic!("❌ Could not parse conversation title from: {}", line)
27            });
28        }
29        i += 1;
30    };
31    let mut conversation = Thread {
32        title,
33        tags: vec![],
34        items: vec![],
35    };
36    i += 1;
37
38    // ---- header meta (any order): user, tags ...
39    // We keep scanning header lines until the first content line ("jot"/"branch") appears.
40    let mut default_user: Option<String> = None;
41
42    while i < lines.len() {
43        let line = &lines[i];
44
45        // stop when content starts
46        if line.starts_with("jot") || line.starts_with("branch") {
47            break;
48        }
49
50        if line.starts_with("user") {
51            // Accept both: `user = name` and `user name`
52            if let Some(eq_pos) = line.find('=') {
53                // user = andrew
54                let val = line[eq_pos + 1..].trim();
55                if val.is_empty() {
56                    panic!("❌ Could not parse `user = <name>` line");
57                }
58                default_user = Some(val.to_string());
59            } else {
60                // user andrew
61                let parts: Vec<&str> = line.split_whitespace().collect();
62                if parts.len() == 2 {
63                    default_user = Some(parts[1].to_string());
64                } else {
65                    panic!("❌ Could not parse `user <name>` line");
66                }
67            }
68            i += 1;
69            continue;
70        }
71
72        if line.starts_with("tags") {
73            if let Some(tags) = parse_tags_line(line) {
74                conversation.tags = tags;
75            }
76            i += 1;
77            continue;
78        }
79
80        // Unknown header directive — stop treating as header block
81        break;
82    }
83
84    // Fallback to avatars.json main if user not defined
85    let default_user = if let Some(u) = default_user {
86        u
87    } else {
88        let avatars = load_avatars();
89        if let Some(main) = avatars.get("main").and_then(|v| v.as_str()) {
90            main.to_string()
91        } else {
92            panic!("❌ Please define main avatar with `user = <name>` or set one with `fur avatar <name>`.");
93        }
94    };
95
96    // ---- parse content into items
97    conversation.items = parse_block(&lines, &mut i, false, &default_user);
98    conversation
99}
100
101// ------------------ Helpers ------------------
102
103fn parse_block(
104    lines: &[String],
105    i: &mut usize,
106    stop_at_closing_brace: bool,
107    default_user: &str,
108) -> Vec<ScriptItem> {
109    let mut items: Vec<ScriptItem> = Vec::new();
110
111    while *i < lines.len() {
112        let line = &lines[*i];
113
114        if stop_at_closing_brace && line.starts_with('}') {
115            *i += 1;
116            break;
117        }
118
119        if line.starts_with("jot") {
120            if let Some(msg) = parse_jot_line(lines, i, default_user) {
121                items.push(ScriptItem::Message(msg));
122            }
123            continue;
124        }
125
126        if is_command_line(line) {
127            let cmd = parse_command_line(line, *i + 1);
128            items.push(ScriptItem::Command(cmd));
129            *i += 1;
130            continue;
131        }
132
133        if is_branch_open(line) {
134            *i += 1; // consume "branch {"
135            if items.is_empty() {
136                eprintln!("❌ branch with no preceding jot at line {}", i);
137                let _ = parse_block(lines, i, true, default_user);
138                continue;
139            }
140            let children_block = parse_block(lines, i, true, default_user);
141            if let Some(ScriptItem::Message(last)) = items.last_mut() {
142                let children: Vec<Message> = children_block
143                    .into_iter()
144                    .filter_map(|si| {
145                        if let ScriptItem::Message(m) = si {
146                            Some(m)
147                        } else {
148                            None
149                        }
150                    })
151                    .collect();
152                last.branches.push(children.clone());
153                // Also flatten into children for compatibility
154                last.children.extend(children);
155            }
156            continue;
157        }
158
159        if line.starts_with('}') {
160            *i += 1;
161            continue;
162        }
163
164        // Unknown/stray line — stop parsing at this level
165        if stop_at_closing_brace {
166            break;
167        } else {
168            eprintln!("⚠️ Unrecognized line: {}", line);
169            *i += 1;
170        }
171    }
172
173    items
174}
175
176fn is_branch_open(line: &str) -> bool {
177    line == "branch {" || line.starts_with("branch {")
178}
179
180/// Collect multi-line quoted text starting at current line.
181/// Advances `i` until the closing `"` is found.
182fn collect_multiline_quoted(lines: &[String], i: &mut usize) -> Option<String> {
183    let mut buf = String::new();
184    let mut started = false;
185
186    while *i < lines.len() {
187        let line = &lines[*i];
188
189        if !started {
190            // find the first quote
191            if let Some(start) = line.find('"') {
192                started = true;
193                let after = &line[start + 1..];
194                if let Some(end) = after.find('"') {
195                    // opening and closing quote on same line
196                    buf.push_str(&after[..end]);
197                    *i += 1;
198                    return Some(buf);
199                } else {
200                    buf.push_str(after);
201                }
202            }
203        } else {
204            buf.push('\n');
205            if let Some(end) = line.find('"') {
206                buf.push_str(&line[..end]);
207                *i += 1;
208                return Some(buf);
209            } else {
210                buf.push_str(line);
211            }
212        }
213
214        *i += 1;
215    }
216
217    None
218}
219
220
221fn parse_tags_line(line: &str) -> Option<Vec<String>> {
222    let start = line.find('[')?;
223    let end = line.rfind(']')?;
224    let inner = &line[start + 1..end];
225    let tags = inner
226        .split(',')
227        .map(|s| s.trim().trim_matches('"').to_string())
228        .filter(|s| !s.is_empty())
229        .collect::<Vec<_>>();
230    Some(tags)
231}
232
233fn make_message(
234    avatar: &str,
235    text: Option<String>,
236    file: Option<String>,
237    attachment: Option<String>,
238) -> Message {
239    Message {
240        avatar: avatar.to_string(),
241        text,
242        file,
243        attachment,
244        children: vec![],
245        branches: vec![],
246    }
247}
248
249fn parse_text_jot(lines: &[String], i: &mut usize, avatar: &str) -> Option<Message> {
250    collect_multiline_quoted(lines, i)
251        .map(|text| make_message(avatar, Some(text), None, None))
252}
253
254fn parse_file_jot(line: &str, i: &mut usize, avatar: &str) -> Option<Message> {
255    let path = extract_quoted(line)
256        .or_else(|| line.split_whitespace().last().map(|s| s.to_string()))
257        .unwrap_or_default();
258    *i += 1;
259    Some(make_message(avatar, None, Some(path), None))
260}
261
262fn parse_attach_jot(line: &str, i: &mut usize, avatar: &str) -> Option<Message> {
263    let path = extract_quoted(line)
264        .or_else(|| line.split_whitespace().last().map(|s| s.to_string()))
265        .unwrap_or_default();
266    *i += 1;
267    Some(make_message(avatar, None, None, Some(path)))
268}
269
270fn parse_jot_line(lines: &[String], i: &mut usize, default_avatar: &str) -> Option<Message> {
271    let line = &lines[*i];
272    let mut parts = line.split_whitespace();
273    let first = parts.next()?;
274    if first != "jot" {
275        return None;
276    }
277
278    let second = parts.next().unwrap_or("");
279
280    // Case A: default avatar
281    if second == "--file" {
282        return parse_file_jot(line, i, default_avatar);
283    }
284    if second == "--attach" {
285        return parse_attach_jot(line, i, default_avatar);
286    }
287    if second.starts_with('"') {
288        return parse_text_jot(lines, i, default_avatar);
289    }
290
291    // Case B: explicit avatar
292    let avatar = second.to_string();
293    if line.contains("--file") {
294        return parse_file_jot(line, i, &avatar);
295    }
296    if line.contains("--attach") {
297        return parse_attach_jot(line, i, &avatar);
298    }
299    parse_text_jot(lines, i, &avatar)
300}
301
302
303fn extract_quoted(line: &str) -> Option<String> {
304    let start = line.find('"')?;
305    let end = line[start + 1..].find('"')? + start + 1;
306    Some(line[start + 1..end].to_string())
307}
308
309fn is_command_line(line: &str) -> bool {
310    line.starts_with("timeline")
311        || line.starts_with("tree")
312        || line.starts_with("status")
313        || line.starts_with("store")
314}
315
316fn parse_command_line(line: &str, line_number: usize) -> Command {
317    let mut parts = line.split_whitespace();
318    let name = parts.next().unwrap_or("").to_string();
319    let args = parts.map(|s| s.to_string()).collect();
320    Command { name, args, line_number }
321}