1use std::fs;
2use crate::frs::ast::{Thread, Message, ScriptItem, Command};
3use crate::frs::avatars::{
4 load_avatars,
5};
6
7pub 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 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 let mut default_user: Option<String> = None;
41
42 while i < lines.len() {
43 let line = &lines[i];
44
45 if line.starts_with("jot") || line.starts_with("branch") {
47 break;
48 }
49
50 if line.starts_with("user") {
51 if let Some(eq_pos) = line.find('=') {
53 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();
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 break;
82 }
83
84 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 conversation.items = parse_block(&lines, &mut i, false, &default_user);
98 conversation
99}
100
101fn 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; 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 last.children.extend(children);
155 }
156 continue;
157 }
158
159 if line.starts_with('}') {
160 *i += 1;
161 continue;
162 }
163
164 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
180fn 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 if let Some(start) = line.find('"') {
192 started = true;
193 let after = &line[start + 1..];
194 if let Some(end) = after.find('"') {
195 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 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 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}