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