Skip to main content

fur_cli/frs/
parser.rs

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