1use ratatui::style::{Color, Modifier, Style};
30use ratatui::text::{Line, Span};
31use serde_json::Value;
32use typesafe::{Choice, Noul, Question, Score};
33
34use crate::format::{CHOICE, DIM, NOUL, SCORE, WARN};
35use crate::session::Session;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Tag {
40 Blank,
41 State,
42 Rule,
43 Comment,
44 Model,
45 Noul,
46 Choice,
47 Score,
48 Raw,
49 Yes,
50 No,
51 Option,
52 Level,
53 Json,
54 Stray,
56}
57
58impl Tag {
59 pub fn label(self) -> &'static str {
60 match self {
61 Tag::Blank | Tag::Rule => "",
62 Tag::State => "state",
63 Tag::Comment => "#",
64 Tag::Model => "model",
65 Tag::Noul => "noul",
66 Tag::Choice => "choice",
67 Tag::Score => "score",
68 Tag::Raw => "raw",
69 Tag::Yes => "yes",
70 Tag::No => "no",
71 Tag::Option => "option",
72 Tag::Level => "level",
73 Tag::Json => "json",
74 Tag::Stray => "?",
75 }
76 }
77
78 pub fn color(self) -> Color {
79 match self {
80 Tag::Noul | Tag::Yes | Tag::No => NOUL,
81 Tag::Choice | Tag::Option => CHOICE,
82 Tag::Score | Tag::Level => SCORE,
83 Tag::Raw | Tag::Json => WARN,
84 Tag::Stray => Color::Red,
85 _ => DIM,
86 }
87 }
88
89 pub fn is_head(self) -> bool {
91 matches!(self, Tag::Noul | Tag::Choice | Tag::Score | Tag::Raw)
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Problem {
97 pub line: usize,
99 pub message: String,
100}
101
102#[derive(Debug, Default)]
105pub struct Parsed {
106 pub state: Value,
107 pub model: Option<String>,
108 pub questions: Vec<(String, Question)>,
109 pub tags: Vec<Tag>,
111 pub problems: Vec<Problem>,
112}
113
114impl Parsed {
115 pub fn ok(&self) -> bool {
116 self.problems.is_empty()
117 }
118
119 pub fn to_session(&self) -> Session {
120 Session {
121 state: self.state.clone(),
122 questions: self.questions.clone(),
123 model: self.model.clone(),
124 }
125 }
126
127 pub fn problem_at(&self, line: usize) -> Option<&Problem> {
129 self.problems.iter().find(|p| p.line == line)
130 }
131}
132
133struct Block {
136 line: usize,
137 name: String,
138 marker: char,
139 rest: String,
141 parts: Vec<(usize, String)>,
142}
143
144pub fn parse(text: &str) -> Parsed {
145 let lines: Vec<&str> = text.split('\n').collect();
146 let mut out = Parsed {
147 tags: vec![Tag::Blank; lines.len()],
148 ..Default::default()
149 };
150
151 let rule = lines.iter().position(|l| l.trim() == "---");
152 let state_end = rule.unwrap_or(lines.len());
153 for (i, line) in lines[..state_end].iter().enumerate() {
154 out.tags[i] = if line.trim().is_empty() {
155 Tag::Blank
156 } else {
157 Tag::State
158 };
159 }
160 out.state = state_value(&lines[..state_end].join("\n"));
161
162 let Some(rule) = rule else {
163 if lines.iter().any(|l| !l.trim().is_empty()) {
164 out.problems.push(Problem {
165 line: lines.len() - 1,
166 message: "no `---` yet — the questions go below one".into(),
167 });
168 }
169 return out;
170 };
171 out.tags[rule] = Tag::Rule;
172
173 let mut block: Option<Block> = None;
174 for (i, raw) in lines.iter().enumerate().skip(rule + 1) {
175 let line = raw.trim();
176 if line.is_empty() {
177 continue;
178 }
179 if line.starts_with('#') {
180 out.tags[i] = Tag::Comment;
181 continue;
182 }
183 if let Some(directive) = line.strip_prefix('@') {
184 let (key, arg) = directive
185 .split_once(char::is_whitespace)
186 .map(|(k, a)| (k, a.trim()))
187 .unwrap_or((directive, ""));
188 match key {
189 "model" if !arg.is_empty() => {
190 out.tags[i] = Tag::Model;
191 out.model = Some(arg.to_owned());
192 }
193 "model" => {
194 out.tags[i] = Tag::Stray;
195 out.problems.push(Problem {
196 line: i,
197 message: "`@model` needs a name, e.g. `@model jev-latest`".into(),
198 });
199 }
200 other => {
201 out.tags[i] = Tag::Stray;
202 out.problems.push(Problem {
203 line: i,
204 message: format!("unknown directive `@{other}`; only `@model` exists"),
205 });
206 }
207 }
208 continue;
209 }
210 if let Some((name, marker, rest)) = head(line) {
211 if let Some(done) = block.take() {
212 finish(done, &mut out);
213 }
214 block = Some(Block {
215 line: i,
216 name,
217 marker,
218 rest: rest.to_owned(),
219 parts: Vec::new(),
220 });
221 continue;
222 }
223 match block.as_mut() {
224 Some(b) => b.parts.push((i, line.to_owned())),
225 None => {
226 out.tags[i] = Tag::Stray;
227 out.problems.push(Problem {
228 line: i,
229 message: "not a question — start one with `name?` (yes/no) or `name:` (options or levels)".into(),
230 });
231 }
232 }
233 }
234 if let Some(done) = block.take() {
235 finish(done, &mut out);
236 }
237 out
238}
239
240fn head(line: &str) -> Option<(String, char, &str)> {
243 let end = line.find(['?', ':', '!'])?;
244 let (name, rest) = line.split_at(end);
245 if !is_name(name) {
246 return None;
247 }
248 let marker = rest.chars().next()?;
249 let after = &rest[1..];
250 if !(after.is_empty() || after.starts_with(char::is_whitespace)) {
251 return None;
252 }
253 if marker == ':' && is_criterion(name) {
254 return None;
255 }
256 Some((name.to_owned(), marker, after.trim()))
257}
258
259fn is_name(s: &str) -> bool {
260 let mut chars = s.chars();
261 matches!(chars.next(), Some(c) if c.is_alphabetic() || c == '_')
262 && chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
263}
264
265fn is_criterion(word: &str) -> bool {
266 matches!(
267 word.to_ascii_lowercase().as_str(),
268 "yes" | "no" | "true" | "false"
269 )
270}
271
272fn finish(b: Block, out: &mut Parsed) {
274 let mut problem = |line: usize, message: String| {
275 out.problems.push(Problem { line, message });
276 };
277 if out.questions.iter().any(|(n, _)| *n == b.name) {
278 out.tags[b.line] = Tag::Stray;
279 problem(
280 b.line,
281 format!("another question is already named `{}`", b.name),
282 );
283 return;
284 }
285
286 if b.marker == '!' {
287 out.tags[b.line] = Tag::Raw;
288 let mut text = b.rest.clone();
289 for (i, part) in &b.parts {
290 out.tags[*i] = Tag::Json;
291 text.push('\n');
292 text.push_str(part);
293 }
294 match serde_json::from_str::<Value>(&text) {
295 Ok(v) if v.get("type").and_then(Value::as_str).is_some_and(|t| !t.is_empty()) => {
296 out.questions.push((b.name, Question::Raw(v)));
297 }
298 Ok(_) => problem(
299 b.line,
300 "a raw question is a JSON object with a `type`, e.g. {\"type\": \"noul\", \"instructions\": \"…\"}"
301 .into(),
302 ),
303 Err(e) => problem(b.line, format!("raw question is not valid JSON: {e}")),
304 }
305 return;
306 }
307
308 let mut parts: Vec<(usize, String)> = Vec::new();
310 let mut inline = split_top(&b.rest, '|').into_iter().map(str::trim);
311 let instructions = inline.next().unwrap_or("").to_owned();
312 parts.extend(
313 inline
314 .filter(|p| !p.is_empty())
315 .map(|p| (b.line, p.to_owned())),
316 );
317 parts.extend(b.parts.iter().cloned());
318 for (_, p) in &mut parts {
319 if let Some(rest) = p
320 .strip_prefix("- ")
321 .or_else(|| p.strip_prefix("* "))
322 .or_else(|| p.strip_prefix("• "))
323 {
324 *p = rest.trim().to_owned();
325 }
326 }
327
328 if instructions.is_empty() {
329 out.tags[b.line] = Tag::Stray;
330 problem(
331 b.line,
332 format!(
333 "`{}` needs instructions after the `{}` — what should the model decide?",
334 b.name, b.marker
335 ),
336 );
337 return;
338 }
339 let instructions = value(&instructions);
340
341 let all_criteria = !parts.is_empty()
342 && parts.iter().all(|(_, p)| {
343 p.split_once(':')
344 .is_some_and(|(k, _)| is_criterion(k.trim()))
345 });
346
347 if b.marker == '?' || all_criteria {
348 out.tags[b.line] = Tag::Noul;
349 let mut q = Noul::new(instructions);
350 let mut bad = false;
351 for (i, part) in &parts {
352 match part.split_once(':').map(|(k, v)| (k.trim(), v.trim())) {
353 Some((k, v)) if is_criterion(k) => {
354 let yes = matches!(k.to_ascii_lowercase().as_str(), "yes" | "true");
355 out.tags[*i] = if yes { Tag::Yes } else { Tag::No };
356 q = if yes {
357 q.when_true(value(v))
358 } else {
359 q.when_false(value(v))
360 };
361 }
362 _ => {
363 bad = true;
364 out.tags[*i] = Tag::Stray;
365 problem(
366 *i,
367 "a yes/no question only takes `yes: …` and `no: …` lines".into(),
368 );
369 }
370 }
371 }
372 if !bad {
373 out.questions.push((b.name, q.into()));
374 }
375 return;
376 }
377
378 if parts.is_empty() {
379 out.tags[b.line] = Tag::Stray;
380 problem(
381 b.line,
382 "add options (`billing = Payments`) or ordered levels (`Calm < Annoyed < Furious`), or end the name with `?` for yes/no"
383 .into(),
384 );
385 return;
386 }
387
388 let is_choice = parts.iter().any(|(_, p)| split_top(p, '=').len() > 1);
389 let has_levels = parts
391 .iter()
392 .any(|(_, p)| split_top(p, '=').len() == 1 && split_top(p, '<').len() > 1);
393 if is_choice && has_levels {
394 out.tags[b.line] = Tag::Stray;
395 for (i, part) in &parts {
396 out.tags[*i] = if split_top(part, '=').len() > 1 {
397 Tag::Option
398 } else {
399 Tag::Level
400 };
401 }
402 problem(
403 b.line,
404 "options (`label = why`) and levels (`low < high`) are mixed — a question is a choice or a score, not both"
405 .into(),
406 );
407 return;
408 }
409 let is_score = !is_choice && has_levels;
410
411 if is_score {
412 out.tags[b.line] = Tag::Score;
413 let mut levels = Vec::new();
414 for (i, part) in &parts {
415 out.tags[*i] = Tag::Level;
416 levels.extend(
417 split_top(part, '<')
418 .into_iter()
419 .map(str::trim)
420 .filter(|l| !l.is_empty())
421 .map(value),
422 );
423 }
424 if levels.len() < 2 {
425 problem(
426 b.line,
427 "a score needs at least two levels, lowest first".into(),
428 );
429 return;
430 }
431 out.questions
432 .push((b.name, Score::new(instructions, levels).into()));
433 return;
434 }
435
436 out.tags[b.line] = Tag::Choice;
437 let mut q = Choice::new(instructions);
438 let mut count = 0;
439 let mut bad = false;
440 for (i, part) in &parts {
441 out.tags[*i] = Tag::Option;
442 let (label, desc) = match split_top(part, '=').as_slice() {
443 [l, d, ..] => (l.trim(), d.trim()),
444 _ => (part.as_str(), ""),
445 };
446 if label.is_empty() {
447 bad = true;
448 out.tags[*i] = Tag::Stray;
449 problem(*i, "an option needs a label before the `=`".into());
450 continue;
451 }
452 q = if desc.is_empty() {
453 q.label(label)
454 } else {
455 q.option(label, value(desc))
456 };
457 count += 1;
458 }
459 if bad {
460 return;
461 }
462 if count < 2 {
463 problem(
464 b.line,
465 "one option is not a choice — add another, or write ordered levels as `a < b < c`"
466 .into(),
467 );
468 return;
469 }
470 out.questions.push((b.name, q.into()));
471}
472
473fn split_top(text: &str, sep: char) -> Vec<&str> {
476 let mut parts = Vec::new();
477 let mut start = 0;
478 let mut quoted = false;
479 let mut escaped = false;
480 for (i, c) in text.char_indices() {
481 if escaped {
482 escaped = false;
483 continue;
484 }
485 match c {
486 '\\' if quoted => escaped = true,
487 '"' => quoted = !quoted,
488 c if c == sep && !quoted && (sep != '=' || parts.is_empty()) => {
489 parts.push(&text[start..i]);
490 start = i + c.len_utf8();
491 }
492 _ => {}
493 }
494 }
495 parts.push(&text[start..]);
496 parts
497}
498
499pub fn value(text: &str) -> Value {
502 let t = text.trim();
503 if (t.starts_with('{') || t.starts_with('[') || t.starts_with('"'))
504 && let Ok(v) = serde_json::from_str::<Value>(t)
505 {
506 return v;
507 }
508 Value::String(t.to_owned())
509}
510
511fn state_value(text: &str) -> Value {
512 let t = text.trim();
513 if (t.starts_with('{') || t.starts_with('['))
514 && let Ok(v) = serde_json::from_str::<Value>(t)
515 {
516 return v;
517 }
518 Value::String(t.to_owned())
519}
520
521pub fn render(session: &Session) -> String {
526 let mut out = String::new();
527 match &session.state {
528 Value::String(s) => out.push_str(s.trim_end()),
529 Value::Null => {}
530 other => out.push_str(&serde_json::to_string_pretty(other).unwrap_or_default()),
531 }
532 out.push_str("\n---\n");
533 if let Some(model) = &session.model {
534 out.push_str(&format!("@model {model}\n"));
535 }
536 for (i, (name, question)) in session.questions.iter().enumerate() {
537 if i > 0 || session.model.is_some() {
538 out.push('\n');
539 }
540 out.push_str(&render_question(name, question));
541 }
542 out
543}
544
545fn render_question(name: &str, question: &Question) -> String {
546 let v = serde_json::to_value(question).unwrap_or(Value::Null);
547 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
548 let instructions = v.get("instructions").map(part).unwrap_or_default();
549 let criteria = v.get("criteria");
550 let mut s = String::new();
551 match (question, kind) {
552 (Question::Raw(raw), _) => {
553 s.push_str(&format!("{name}! {raw}\n"));
554 }
555 (_, "noul") => {
556 s.push_str(&format!("{name}? {instructions}\n"));
557 if let Some(yes) = criteria.and_then(|c| c.get("true")) {
558 s.push_str(&format!(" yes: {}\n", part(yes)));
559 }
560 if let Some(no) = criteria.and_then(|c| c.get("false")) {
561 s.push_str(&format!(" no: {}\n", part(no)));
562 }
563 }
564 (_, "choice") => {
565 s.push_str(&format!("{name}: {instructions}\n"));
566 if let Some(map) = criteria.and_then(Value::as_object) {
567 for (label, desc) in map {
568 match desc {
569 Value::Null => s.push_str(&format!(" {label}\n")),
570 d => s.push_str(&format!(" {label} = {}\n", part(d))),
571 }
572 }
573 }
574 }
575 (_, "score") => {
576 s.push_str(&format!("{name}: {instructions}\n"));
577 let levels: Vec<String> = criteria
578 .and_then(Value::as_array)
579 .map(|a| a.iter().map(part).collect())
580 .unwrap_or_default();
581 let one_line = levels.join(" < ");
582 if one_line.chars().count() <= 60 {
583 s.push_str(&format!(" {one_line}\n"));
584 } else {
585 for (i, level) in levels.iter().enumerate() {
586 if i == 0 {
587 s.push_str(&format!(" {level}\n"));
588 } else {
589 s.push_str(&format!(" < {level}\n"));
590 }
591 }
592 }
593 }
594 _ => s.push_str(&format!("{name}! {v}\n")),
595 }
596 s
597}
598
599fn part(v: &Value) -> String {
602 match v {
603 Value::String(s)
604 if !s.contains(['|', '<', '=', '\n', '\r'])
605 && !s.starts_with(['{', '[', '"', '#', '@', '-', '*', '•'])
606 && head(s).is_none()
607 && s.trim() == s
608 && !s.is_empty() =>
609 {
610 s.clone()
611 }
612 other => other.to_string(),
613 }
614}
615
616pub fn highlight(text: &str) -> Vec<Line<'static>> {
618 let parsed = parse(text);
619 text.split('\n')
620 .zip(parsed.tags.iter())
621 .map(|(line, tag)| {
622 let style = match tag {
623 t if t.is_head() => Style::new().fg(t.color()).add_modifier(Modifier::BOLD),
624 Tag::State => Style::new(),
625 Tag::Rule | Tag::Comment | Tag::Blank => Style::new().fg(DIM),
626 t => Style::new().fg(t.color()),
627 };
628 Line::from(vec![Span::raw(" "), Span::styled(line.to_owned(), style)])
629 })
630 .collect()
631}