1use crate::frs::ast::{Command, Message, ScriptItem, Thread};
2use crate::frs::avatars::load_avatars;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6pub 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 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 let mut default_user: Option<String> = None;
42
43 while i < lines.len() {
44 let line = &lines[i];
45
46 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 conversation.items = parse_block(&lines, &mut i, false, &default_user, frs_dir);
94 conversation
95}
96
97fn 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 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 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 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}