Skip to main content

fur_cli/frs/
parser.rs

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