1use ratatui::style::{Color, Modifier, Style};
32use ratatui::text::{Line, Span};
33use serde_json::Value;
34use typesafe::{Choice, Noul, Question, Score};
35
36use crate::format::{ACCENT, CHOICE, DIM, NOUL, SCORE, WARN};
37use crate::session::Session;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Tag {
42 Blank,
43 State,
44 Rule,
45 Comment,
46 Model,
47 Noul,
48 Choice,
49 Score,
50 Raw,
51 Yes,
52 No,
53 Option,
54 Level,
55 Json,
56 Bar,
58 Stray,
60}
61
62impl Tag {
63 pub fn label(self) -> &'static str {
64 match self {
65 Tag::Blank | Tag::Rule => "",
66 Tag::State => "state",
67 Tag::Comment => "#",
68 Tag::Model => "model",
69 Tag::Noul => "noul",
70 Tag::Choice => "choice",
71 Tag::Score => "score",
72 Tag::Raw => "raw",
73 Tag::Yes => "yes",
74 Tag::No => "no",
75 Tag::Option => "option",
76 Tag::Level => "level",
77 Tag::Json => "json",
78 Tag::Bar => "bar",
79 Tag::Stray => "?",
80 }
81 }
82
83 pub fn color(self) -> Color {
84 match self {
85 Tag::Noul | Tag::Yes | Tag::No => NOUL,
86 Tag::Choice | Tag::Option => CHOICE,
87 Tag::Score | Tag::Level => SCORE,
88 Tag::Raw | Tag::Json => WARN,
89 Tag::Bar => ACCENT,
90 Tag::Stray => Color::Red,
91 _ => DIM,
92 }
93 }
94
95 pub fn is_head(self) -> bool {
97 matches!(self, Tag::Noul | Tag::Choice | Tag::Score | Tag::Raw)
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Problem {
103 pub line: usize,
105 pub message: String,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct BlockSpan {
111 pub head: usize,
113 pub last: usize,
115 pub bar: Option<usize>,
117}
118
119#[derive(Debug, Default)]
122pub struct Parsed {
123 pub state: Value,
124 pub model: Option<String>,
125 pub questions: Vec<(String, Question)>,
126 pub bars: Vec<(String, f64)>,
128 pub blocks: Vec<(String, BlockSpan)>,
130 pub tags: Vec<Tag>,
132 pub problems: Vec<Problem>,
133}
134
135impl Parsed {
136 pub fn ok(&self) -> bool {
137 self.problems.is_empty()
138 }
139
140 pub fn to_session(&self) -> Session {
141 Session {
142 state: self.state.clone(),
143 questions: self.questions.clone(),
144 model: self.model.clone(),
145 bars: self.bars.clone(),
146 }
147 }
148
149 pub fn block(&self, name: &str) -> Option<BlockSpan> {
151 self.blocks
152 .iter()
153 .find(|(n, _)| n == name)
154 .map(|(_, block)| *block)
155 }
156
157 pub fn bar(&self, name: &str) -> Option<f64> {
159 self.bars
160 .iter()
161 .find(|(n, _)| n == name)
162 .map(|(_, bar)| *bar)
163 }
164
165 pub fn problem_at(&self, line: usize) -> Option<&Problem> {
167 self.problems.iter().find(|p| p.line == line)
168 }
169}
170
171struct Block {
174 line: usize,
175 name: String,
176 marker: char,
177 rest: String,
179 parts: Vec<(usize, String)>,
180 bars: Vec<(usize, Directive, f64)>,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum Directive {
187 Threshold,
188 Confidence,
189}
190
191pub fn parse(text: &str) -> Parsed {
192 let lines: Vec<&str> = text.split('\n').collect();
193 let mut out = Parsed {
194 tags: vec![Tag::Blank; lines.len()],
195 ..Default::default()
196 };
197
198 let rule = lines.iter().position(|l| l.trim() == "---");
199 let state_end = rule.unwrap_or(lines.len());
200 for (i, line) in lines[..state_end].iter().enumerate() {
201 out.tags[i] = if line.trim().is_empty() {
202 Tag::Blank
203 } else {
204 Tag::State
205 };
206 }
207 out.state = state_value(&lines[..state_end].join("\n"));
208
209 let Some(rule) = rule else {
210 if lines.iter().any(|l| !l.trim().is_empty()) {
211 out.problems.push(Problem {
212 line: lines.len() - 1,
213 message: "no `---` yet — the questions go below one".into(),
214 });
215 }
216 return out;
217 };
218 out.tags[rule] = Tag::Rule;
219
220 let mut block: Option<Block> = None;
221 for (i, raw) in lines.iter().enumerate().skip(rule + 1) {
222 let line = raw.trim();
223 if line.is_empty() {
224 continue;
225 }
226 if line.starts_with('#') {
227 out.tags[i] = Tag::Comment;
228 continue;
229 }
230 if let Some(directive) = line.strip_prefix('@') {
231 let (key, arg) = directive
232 .split_once(char::is_whitespace)
233 .map(|(k, a)| (k, a.trim()))
234 .unwrap_or((directive, ""));
235 match key {
236 "threshold" | "confidence" => {
237 let directive = if key == "threshold" {
238 Directive::Threshold
239 } else {
240 Directive::Confidence
241 };
242 match (parse_bar(arg), block.as_mut()) {
243 (None, _) => {
244 out.tags[i] = Tag::Stray;
245 out.problems.push(Problem {
246 line: i,
247 message: format!(
248 "`@{key}` takes a number from 0 to 1, e.g. `@{key} 0.6`"
249 ),
250 });
251 }
252 (Some(_), None) => {
253 out.tags[i] = Tag::Stray;
254 out.problems.push(Problem {
255 line: i,
256 message: match directive {
257 Directive::Threshold => "`@threshold` belongs under a question — put it below the `name?` line it sets",
258 Directive::Confidence => "`@confidence` belongs under a question — put it below the choice or score it gates",
259 }
260 .into(),
261 });
262 }
263 (Some(bar), Some(b)) => b.bars.push((i, directive, bar)),
264 }
265 }
266 "model" if !arg.is_empty() => {
267 out.tags[i] = Tag::Model;
268 out.model = Some(arg.to_owned());
269 }
270 "model" => {
271 out.tags[i] = Tag::Stray;
272 out.problems.push(Problem {
273 line: i,
274 message: "`@model` needs a name, e.g. `@model jev-latest`".into(),
275 });
276 }
277 other => {
278 out.tags[i] = Tag::Stray;
279 out.problems.push(Problem {
280 line: i,
281 message: format!(
282 "unknown directive `@{other}`; there is `@model`, and `@threshold` or `@confidence` under a question"
283 ),
284 });
285 }
286 }
287 continue;
288 }
289 if let Some((name, marker, rest)) = head(line) {
290 if let Some(done) = block.take() {
291 finish(done, &mut out);
292 }
293 block = Some(Block {
294 line: i,
295 name,
296 marker,
297 rest: rest.to_owned(),
298 parts: Vec::new(),
299 bars: Vec::new(),
300 });
301 continue;
302 }
303 match block.as_mut() {
304 Some(b) => b.parts.push((i, line.to_owned())),
305 None => {
306 out.tags[i] = Tag::Stray;
307 out.problems.push(Problem {
308 line: i,
309 message: "not a question — start one with `name?` (yes/no) or `name:` (options or levels)".into(),
310 });
311 }
312 }
313 }
314 if let Some(done) = block.take() {
315 finish(done, &mut out);
316 }
317 out
318}
319
320fn head(line: &str) -> Option<(String, char, &str)> {
323 let end = line.find(['?', ':', '!'])?;
324 let (name, rest) = line.split_at(end);
325 if !is_name(name) {
326 return None;
327 }
328 let marker = rest.chars().next()?;
329 let after = &rest[1..];
330 if !(after.is_empty() || after.starts_with(char::is_whitespace)) {
331 return None;
332 }
333 if marker == ':' && is_criterion(name) {
334 return None;
335 }
336 Some((name.to_owned(), marker, after.trim()))
337}
338
339fn is_name(s: &str) -> bool {
340 let mut chars = s.chars();
341 matches!(chars.next(), Some(c) if c.is_alphabetic() || c == '_')
342 && chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
343}
344
345fn is_criterion(word: &str) -> bool {
346 matches!(
347 word.to_ascii_lowercase().as_str(),
348 "yes" | "no" | "true" | "false"
349 )
350}
351
352pub fn parse_bar(text: &str) -> Option<f64> {
355 let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
356 let plain = match text.split_once('.') {
357 Some(("", fraction)) => !fraction.is_empty() && digits(fraction),
358 Some((whole, fraction)) => digits(whole) && digits(fraction),
359 None => !text.is_empty() && digits(text),
360 };
361 if !plain {
362 return None;
363 }
364 let n: f64 = text.parse().ok()?;
365 (0.0..=1.0).contains(&n).then_some(n)
366}
367
368fn kind_of(question: &Question) -> &'static str {
370 match question {
371 Question::Noul(_) => "noul",
372 Question::Choice(_) => "choice",
373 Question::Score(_) => "score",
374 _ => "raw",
375 }
376}
377
378fn finish(b: Block, out: &mut Parsed) {
380 let before = out.questions.len();
381 let (line, name, parts, bars) = (b.line, b.name.clone(), b.parts.clone(), b.bars.clone());
382 finish_question(b, out);
383 let kind = if out.questions.len() > before {
384 out.questions.last().map(|(_, q)| kind_of(q))
385 } else {
386 None
387 };
388 let mut last = parts.iter().map(|(i, _)| *i).fold(line, usize::max);
389 let mut bar_at: Option<usize> = None;
390 for (i, directive, value) in bars {
391 last = last.max(i);
392 out.tags[i] = Tag::Bar;
393 let Some(kind) = kind else { continue };
395 let refused = if let Some(at) = bar_at {
396 Some(format!("`{name}` already has a bar on line {}", at + 1))
397 } else if kind == "raw" {
398 Some("a raw question takes no bar — jev cannot read its answer".to_owned())
399 } else if kind == "noul" && directive == Directive::Confidence {
400 Some("a yes/no question takes `@threshold`, not `@confidence`".to_owned())
401 } else if kind != "noul" && directive == Directive::Threshold {
402 Some("a choice or a score takes `@confidence`, not `@threshold`".to_owned())
403 } else {
404 None
405 };
406 match refused {
407 Some(message) => {
408 out.tags[i] = Tag::Stray;
409 out.problems.push(Problem { line: i, message });
410 }
411 None => {
412 bar_at = Some(i);
413 out.bars.push((name.clone(), value));
414 }
415 }
416 }
417 if kind.is_some() {
418 out.blocks.push((
419 name,
420 BlockSpan {
421 head: line,
422 last,
423 bar: bar_at,
424 },
425 ));
426 }
427}
428
429fn finish_question(b: Block, out: &mut Parsed) {
430 let mut problem = |line: usize, message: String| {
431 out.problems.push(Problem { line, message });
432 };
433 if out.questions.iter().any(|(n, _)| *n == b.name) {
434 out.tags[b.line] = Tag::Stray;
435 problem(
436 b.line,
437 format!("another question is already named `{}`", b.name),
438 );
439 return;
440 }
441
442 if b.marker == '!' {
443 out.tags[b.line] = Tag::Raw;
444 let mut text = b.rest.clone();
445 for (i, part) in &b.parts {
446 out.tags[*i] = Tag::Json;
447 text.push('\n');
448 text.push_str(part);
449 }
450 match serde_json::from_str::<Value>(&text) {
451 Ok(v) if v.get("type").and_then(Value::as_str).is_some_and(|t| !t.is_empty()) => {
452 out.questions.push((b.name, Question::Raw(v)));
453 }
454 Ok(_) => problem(
455 b.line,
456 "a raw question is a JSON object with a `type`, e.g. {\"type\": \"noul\", \"instructions\": \"…\"}"
457 .into(),
458 ),
459 Err(e) => problem(b.line, format!("raw question is not valid JSON: {e}")),
460 }
461 return;
462 }
463
464 let mut parts: Vec<(usize, String)> = Vec::new();
466 let mut inline = split_top(&b.rest, '|').into_iter().map(str::trim);
467 let instructions = inline.next().unwrap_or("").to_owned();
468 parts.extend(
469 inline
470 .filter(|p| !p.is_empty())
471 .map(|p| (b.line, p.to_owned())),
472 );
473 parts.extend(b.parts.iter().cloned());
474 for (_, p) in &mut parts {
475 if let Some(rest) = p
476 .strip_prefix("- ")
477 .or_else(|| p.strip_prefix("* "))
478 .or_else(|| p.strip_prefix("• "))
479 {
480 *p = rest.trim().to_owned();
481 }
482 }
483
484 if instructions.is_empty() {
485 out.tags[b.line] = Tag::Stray;
486 problem(
487 b.line,
488 format!(
489 "`{}` needs instructions after the `{}` — what should the model decide?",
490 b.name, b.marker
491 ),
492 );
493 return;
494 }
495 let instructions = value(&instructions);
496
497 let all_criteria = !parts.is_empty()
498 && parts.iter().all(|(_, p)| {
499 p.split_once(':')
500 .is_some_and(|(k, _)| is_criterion(k.trim()))
501 });
502
503 if b.marker == '?' || all_criteria {
504 out.tags[b.line] = Tag::Noul;
505 let mut q = Noul::new(instructions);
506 let mut bad = false;
507 for (i, part) in &parts {
508 match part.split_once(':').map(|(k, v)| (k.trim(), v.trim())) {
509 Some((k, v)) if is_criterion(k) => {
510 let yes = matches!(k.to_ascii_lowercase().as_str(), "yes" | "true");
511 out.tags[*i] = if yes { Tag::Yes } else { Tag::No };
512 q = if yes {
513 q.when_true(value(v))
514 } else {
515 q.when_false(value(v))
516 };
517 }
518 _ => {
519 bad = true;
520 out.tags[*i] = Tag::Stray;
521 problem(
522 *i,
523 "a yes/no question only takes `yes: …` and `no: …` lines".into(),
524 );
525 }
526 }
527 }
528 if !bad {
529 out.questions.push((b.name, q.into()));
530 }
531 return;
532 }
533
534 if parts.is_empty() {
535 out.tags[b.line] = Tag::Stray;
536 problem(
537 b.line,
538 "add options (`billing = Payments`) or ordered levels (`Calm < Annoyed < Furious`), or end the name with `?` for yes/no"
539 .into(),
540 );
541 return;
542 }
543
544 let is_choice = parts.iter().any(|(_, p)| split_top(p, '=').len() > 1);
545 let has_levels = parts
547 .iter()
548 .any(|(_, p)| split_top(p, '=').len() == 1 && split_top(p, '<').len() > 1);
549 if is_choice && has_levels {
550 out.tags[b.line] = Tag::Stray;
551 for (i, part) in &parts {
552 out.tags[*i] = if split_top(part, '=').len() > 1 {
553 Tag::Option
554 } else {
555 Tag::Level
556 };
557 }
558 problem(
559 b.line,
560 "options (`label = why`) and levels (`low < high`) are mixed — a question is a choice or a score, not both"
561 .into(),
562 );
563 return;
564 }
565 let is_score = !is_choice && has_levels;
566
567 if is_score {
568 out.tags[b.line] = Tag::Score;
569 let mut levels = Vec::new();
570 for (i, part) in &parts {
571 out.tags[*i] = Tag::Level;
572 levels.extend(
573 split_top(part, '<')
574 .into_iter()
575 .map(str::trim)
576 .filter(|l| !l.is_empty())
577 .map(value),
578 );
579 }
580 if levels.len() < 2 {
581 problem(
582 b.line,
583 "a score needs at least two levels, lowest first".into(),
584 );
585 return;
586 }
587 out.questions
588 .push((b.name, Score::new(instructions, levels).into()));
589 return;
590 }
591
592 out.tags[b.line] = Tag::Choice;
593 let mut q = Choice::new(instructions);
594 let mut count = 0;
595 let mut bad = false;
596 for (i, part) in &parts {
597 out.tags[*i] = Tag::Option;
598 let (label, desc) = match split_top(part, '=').as_slice() {
599 [l, d, ..] => (l.trim(), d.trim()),
600 _ => (part.as_str(), ""),
601 };
602 if label.is_empty() {
603 bad = true;
604 out.tags[*i] = Tag::Stray;
605 problem(*i, "an option needs a label before the `=`".into());
606 continue;
607 }
608 q = if desc.is_empty() {
609 q.label(label)
610 } else {
611 q.option(label, value(desc))
612 };
613 count += 1;
614 }
615 if bad {
616 return;
617 }
618 if count < 2 {
619 problem(
620 b.line,
621 "one option is not a choice — add another, or write ordered levels as `a < b < c`"
622 .into(),
623 );
624 return;
625 }
626 out.questions.push((b.name, q.into()));
627}
628
629fn split_top(text: &str, sep: char) -> Vec<&str> {
632 let mut parts = Vec::new();
633 let mut start = 0;
634 let mut quoted = false;
635 let mut escaped = false;
636 for (i, c) in text.char_indices() {
637 if escaped {
638 escaped = false;
639 continue;
640 }
641 match c {
642 '\\' if quoted => escaped = true,
643 '"' => quoted = !quoted,
644 c if c == sep && !quoted && (sep != '=' || parts.is_empty()) => {
645 parts.push(&text[start..i]);
646 start = i + c.len_utf8();
647 }
648 _ => {}
649 }
650 }
651 parts.push(&text[start..]);
652 parts
653}
654
655pub fn value(text: &str) -> Value {
658 let t = text.trim();
659 if (t.starts_with('{') || t.starts_with('[') || t.starts_with('"'))
660 && let Ok(v) = serde_json::from_str::<Value>(t)
661 {
662 return v;
663 }
664 Value::String(t.to_owned())
665}
666
667fn state_value(text: &str) -> Value {
668 let t = text.trim();
669 if (t.starts_with('{') || t.starts_with('['))
670 && let Ok(v) = serde_json::from_str::<Value>(t)
671 {
672 return v;
673 }
674 Value::String(t.to_owned())
675}
676
677pub fn render(session: &Session) -> String {
682 let mut out = String::new();
683 match &session.state {
684 Value::String(s) => out.push_str(s.trim_end()),
685 Value::Null => {}
686 other => out.push_str(&serde_json::to_string_pretty(other).unwrap_or_default()),
687 }
688 out.push_str("\n---\n");
689 if let Some(model) = &session.model {
690 out.push_str(&format!("@model {model}\n"));
691 }
692 for (i, (name, question)) in session.questions.iter().enumerate() {
693 if i > 0 || session.model.is_some() {
694 out.push('\n');
695 }
696 out.push_str(&render_question(name, question));
697 if let (Some(bar), Some(directive)) = (session.bar(name), bar_directive(question)) {
698 out.push_str(&format!(" {directive} {bar}\n"));
699 }
700 }
701 out
702}
703
704fn bar_directive(question: &Question) -> Option<&'static str> {
706 match kind_of(question) {
707 "noul" => Some("@threshold"),
708 "choice" | "score" => Some("@confidence"),
709 _ => None,
710 }
711}
712
713pub fn set_bars(text: &str, bars: &[(String, f64)]) -> String {
720 let parsed = parse(text);
721 let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
722 let cr = if text.contains("\r\n") { "\r" } else { "" };
723 let leading = |line: &str| -> String {
724 line.chars()
725 .take_while(|c| *c == ' ' || *c == '\t')
726 .collect()
727 };
728 let mut inserts: Vec<(usize, String)> = Vec::new();
729 for (name, value) in bars {
730 let directive = parsed
731 .questions
732 .iter()
733 .find(|(n, _)| n == name)
734 .and_then(|(_, q)| bar_directive(q));
735 let (Some(block), Some(directive)) = (parsed.block(name), directive) else {
736 continue;
737 };
738 if let Some(at) = block.bar {
739 let old = &lines[at];
740 let end = if old.ends_with('\r') { "\r" } else { "" };
741 lines[at] = format!("{}{directive} {value}{end}", leading(old));
742 continue;
743 }
744 let indent = (block.head + 1..=block.last)
745 .find(|i| !matches!(parsed.tags.get(*i), None | Some(Tag::Blank | Tag::Comment)))
746 .map_or_else(|| " ".to_owned(), |i| leading(&lines[i]));
747 inserts.push((block.last, format!("{indent}{directive} {value}{cr}")));
748 }
749 inserts.sort_by_key(|x| std::cmp::Reverse(x.0));
750 for (after, line) in inserts {
751 lines.insert(after + 1, line);
752 }
753 lines.join("\n")
754}
755
756fn render_question(name: &str, question: &Question) -> String {
757 let v = serde_json::to_value(question).unwrap_or(Value::Null);
758 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
759 let instructions = v.get("instructions").map(part).unwrap_or_default();
760 let criteria = v.get("criteria");
761 let mut s = String::new();
762 match (question, kind) {
763 (Question::Raw(raw), _) => {
764 s.push_str(&format!("{name}! {raw}\n"));
765 }
766 (_, "noul") => {
767 s.push_str(&format!("{name}? {instructions}\n"));
768 if let Some(yes) = criteria.and_then(|c| c.get("true")) {
769 s.push_str(&format!(" yes: {}\n", part(yes)));
770 }
771 if let Some(no) = criteria.and_then(|c| c.get("false")) {
772 s.push_str(&format!(" no: {}\n", part(no)));
773 }
774 }
775 (_, "choice") => {
776 s.push_str(&format!("{name}: {instructions}\n"));
777 if let Some(map) = criteria.and_then(Value::as_object) {
778 for (label, desc) in map {
779 match desc {
780 Value::Null => s.push_str(&format!(" {label}\n")),
781 d => s.push_str(&format!(" {label} = {}\n", part(d))),
782 }
783 }
784 }
785 }
786 (_, "score") => {
787 s.push_str(&format!("{name}: {instructions}\n"));
788 let levels: Vec<String> = criteria
789 .and_then(Value::as_array)
790 .map(|a| a.iter().map(part).collect())
791 .unwrap_or_default();
792 let one_line = levels.join(" < ");
793 if one_line.chars().count() <= 60 {
794 s.push_str(&format!(" {one_line}\n"));
795 } else {
796 for (i, level) in levels.iter().enumerate() {
797 if i == 0 {
798 s.push_str(&format!(" {level}\n"));
799 } else {
800 s.push_str(&format!(" < {level}\n"));
801 }
802 }
803 }
804 }
805 _ => s.push_str(&format!("{name}! {v}\n")),
806 }
807 s
808}
809
810fn part(v: &Value) -> String {
813 match v {
814 Value::String(s)
815 if !s.contains(['|', '<', '=', '\n', '\r'])
816 && !s.starts_with(['{', '[', '"', '#', '@', '-', '*', '•'])
817 && head(s).is_none()
818 && s.trim() == s
819 && !s.is_empty() =>
820 {
821 s.clone()
822 }
823 other => other.to_string(),
824 }
825}
826
827pub fn highlight(text: &str) -> Vec<Line<'static>> {
829 let parsed = parse(text);
830 text.split('\n')
831 .zip(parsed.tags.iter())
832 .map(|(line, tag)| {
833 let style = match tag {
834 t if t.is_head() => Style::new().fg(t.color()).add_modifier(Modifier::BOLD),
835 Tag::State => Style::new(),
836 Tag::Rule | Tag::Comment | Tag::Blank => Style::new().fg(DIM),
837 t => Style::new().fg(t.color()),
838 };
839 Line::from(vec![Span::raw(" "), Span::styled(line.to_owned(), style)])
840 })
841 .collect()
842}