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 if levels.len() > MAX_SCORE_LEVELS {
588 problem(
589 b.line,
590 format!(
591 "a score takes at most {MAX_SCORE_LEVELS} levels, this one has {}",
592 levels.len()
593 ),
594 );
595 return;
596 }
597 out.questions
598 .push((b.name, Score::new(instructions, levels).into()));
599 return;
600 }
601
602 out.tags[b.line] = Tag::Choice;
603 let mut q = Choice::new(instructions);
604 let mut count = 0;
605 let mut bad = false;
606 for (i, part) in &parts {
607 out.tags[*i] = Tag::Option;
608 let (label, desc) = match split_top(part, '=').as_slice() {
609 [l, d, ..] => (l.trim(), d.trim()),
610 _ => (part.as_str(), ""),
611 };
612 if label.is_empty() {
613 bad = true;
614 out.tags[*i] = Tag::Stray;
615 problem(*i, "an option needs a label before the `=`".into());
616 continue;
617 }
618 q = if desc.is_empty() {
619 q.label(label)
620 } else {
621 q.option(label, value(desc))
622 };
623 count += 1;
624 }
625 if bad {
626 return;
627 }
628 if count < 2 {
629 problem(
630 b.line,
631 "one option is not a choice — add another, or write ordered levels as `a < b < c`"
632 .into(),
633 );
634 return;
635 }
636 if count > MAX_CHOICE_OPTIONS {
637 problem(
638 b.line,
639 format!("a choice takes at most {MAX_CHOICE_OPTIONS} options, this one has {count}"),
640 );
641 return;
642 }
643 out.questions.push((b.name, q.into()));
644}
645
646pub const MAX_SCORE_LEVELS: usize = 10;
648pub const MAX_CHOICE_OPTIONS: usize = 255;
649
650fn split_top(text: &str, sep: char) -> Vec<&str> {
653 let mut parts = Vec::new();
654 let mut start = 0;
655 let mut quoted = false;
656 let mut escaped = false;
657 for (i, c) in text.char_indices() {
658 if escaped {
659 escaped = false;
660 continue;
661 }
662 match c {
663 '\\' if quoted => escaped = true,
664 '"' => quoted = !quoted,
665 c if c == sep && !quoted && (sep != '=' || parts.is_empty()) => {
666 parts.push(&text[start..i]);
667 start = i + c.len_utf8();
668 }
669 _ => {}
670 }
671 }
672 parts.push(&text[start..]);
673 parts
674}
675
676pub fn value(text: &str) -> Value {
679 let t = text.trim();
680 if (t.starts_with('{') || t.starts_with('[') || t.starts_with('"'))
681 && let Ok(v) = serde_json::from_str::<Value>(t)
682 {
683 return v;
684 }
685 Value::String(t.to_owned())
686}
687
688fn state_value(text: &str) -> Value {
689 let t = text.trim();
690 if (t.starts_with('{') || t.starts_with('['))
691 && let Ok(v) = serde_json::from_str::<Value>(t)
692 {
693 return v;
694 }
695 Value::String(t.to_owned())
696}
697
698pub fn render(session: &Session) -> String {
703 let mut out = String::new();
704 match &session.state {
705 Value::String(s) => out.push_str(s.trim_end()),
706 Value::Null => {}
707 other => out.push_str(&serde_json::to_string_pretty(other).unwrap_or_default()),
708 }
709 out.push_str("\n---\n");
710 if let Some(model) = &session.model {
711 out.push_str(&format!("@model {model}\n"));
712 }
713 for (i, (name, question)) in session.questions.iter().enumerate() {
714 if i > 0 || session.model.is_some() {
715 out.push('\n');
716 }
717 out.push_str(&render_question(name, question));
718 if let (Some(bar), Some(directive)) = (session.bar(name), bar_directive(question)) {
719 out.push_str(&format!(" {directive} {bar}\n"));
720 }
721 }
722 out
723}
724
725fn bar_directive(question: &Question) -> Option<&'static str> {
727 match kind_of(question) {
728 "noul" => Some("@threshold"),
729 "choice" | "score" => Some("@confidence"),
730 _ => None,
731 }
732}
733
734pub fn set_bars(text: &str, bars: &[(String, f64)]) -> String {
741 let parsed = parse(text);
742 let mut lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
743 let cr = if text.contains("\r\n") { "\r" } else { "" };
744 let leading = |line: &str| -> String {
745 line.chars()
746 .take_while(|c| *c == ' ' || *c == '\t')
747 .collect()
748 };
749 let mut inserts: Vec<(usize, String)> = Vec::new();
750 for (name, value) in bars {
751 let directive = parsed
752 .questions
753 .iter()
754 .find(|(n, _)| n == name)
755 .and_then(|(_, q)| bar_directive(q));
756 let (Some(block), Some(directive)) = (parsed.block(name), directive) else {
757 continue;
758 };
759 if let Some(at) = block.bar {
760 let old = &lines[at];
761 let end = if old.ends_with('\r') { "\r" } else { "" };
762 lines[at] = format!("{}{directive} {value}{end}", leading(old));
763 continue;
764 }
765 let indent = (block.head + 1..=block.last)
766 .find(|i| !matches!(parsed.tags.get(*i), None | Some(Tag::Blank | Tag::Comment)))
767 .map_or_else(|| " ".to_owned(), |i| leading(&lines[i]));
768 inserts.push((block.last, format!("{indent}{directive} {value}{cr}")));
769 }
770 inserts.sort_by_key(|x| std::cmp::Reverse(x.0));
771 for (after, line) in inserts {
772 lines.insert(after + 1, line);
773 }
774 lines.join("\n")
775}
776
777fn render_question(name: &str, question: &Question) -> String {
778 let v = serde_json::to_value(question).unwrap_or(Value::Null);
779 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
780 let instructions = v.get("instructions").map(part).unwrap_or_default();
781 let criteria = v.get("criteria");
782 let mut s = String::new();
783 match (question, kind) {
784 (Question::Raw(raw), _) => {
785 s.push_str(&format!("{name}! {raw}\n"));
786 }
787 (_, "noul") => {
788 s.push_str(&format!("{name}? {instructions}\n"));
789 if let Some(yes) = criteria.and_then(|c| c.get("true")) {
790 s.push_str(&format!(" yes: {}\n", part(yes)));
791 }
792 if let Some(no) = criteria.and_then(|c| c.get("false")) {
793 s.push_str(&format!(" no: {}\n", part(no)));
794 }
795 }
796 (_, "choice") => {
797 s.push_str(&format!("{name}: {instructions}\n"));
798 if let Some(map) = criteria.and_then(Value::as_object) {
799 for (label, desc) in map {
800 match desc {
801 Value::Null => s.push_str(&format!(" {label}\n")),
802 d => s.push_str(&format!(" {label} = {}\n", part(d))),
803 }
804 }
805 }
806 }
807 (_, "score") => {
808 s.push_str(&format!("{name}: {instructions}\n"));
809 let levels: Vec<String> = criteria
810 .and_then(Value::as_array)
811 .map(|a| a.iter().map(part).collect())
812 .unwrap_or_default();
813 let one_line = levels.join(" < ");
814 if one_line.chars().count() <= 60 {
815 s.push_str(&format!(" {one_line}\n"));
816 } else {
817 for (i, level) in levels.iter().enumerate() {
818 if i == 0 {
819 s.push_str(&format!(" {level}\n"));
820 } else {
821 s.push_str(&format!(" < {level}\n"));
822 }
823 }
824 }
825 }
826 _ => s.push_str(&format!("{name}! {v}\n")),
827 }
828 s
829}
830
831fn part(v: &Value) -> String {
834 match v {
835 Value::String(s)
836 if !s.contains(['|', '<', '=', '\n', '\r'])
837 && !s.starts_with(['{', '[', '"', '#', '@', '-', '*', '•'])
838 && head(s).is_none()
839 && s.trim() == s
840 && !s.is_empty() =>
841 {
842 s.clone()
843 }
844 other => other.to_string(),
845 }
846}
847
848pub fn highlight(text: &str) -> Vec<Line<'static>> {
850 let parsed = parse(text);
851 text.split('\n')
852 .zip(parsed.tags.iter())
853 .map(|(line, tag)| {
854 let style = match tag {
855 t if t.is_head() => Style::new().fg(t.color()).add_modifier(Modifier::BOLD),
856 Tag::State => Style::new(),
857 Tag::Rule | Tag::Comment | Tag::Blank => Style::new().fg(DIM),
858 t => Style::new().fg(t.color()),
859 };
860 Line::from(vec![Span::raw(" "), Span::styled(line.to_owned(), style)])
861 })
862 .collect()
863}