Skip to main content

jev_repl/
sketch.rs

1//! Sketch notation: the whole request written as one page of plain text, the way you would
2//! scribble a rubric on paper. The shape of each question is read off its punctuation, so there
3//! is nothing to select — you write what you mean and the gutter tells you what it became.
4//!
5//! ```text
6//! The payout failed again, third time this month. I'm done waiting.
7//! ---
8//! is_urgent? The message conveys urgency or time-sensitivity
9//!   yes: A deadline, a threat to leave, or "ASAP"
10//!   no: Routine, no time pressure
11//!
12//! department: Which team should handle this
13//!   billing = Payment or subscription issues
14//!   technical = Bugs or integration problems
15//!   sales
16//!
17//! frustration: How frustrated the customer appears
18//!   Calm < Frustrated but civil < Very angry
19//! ```
20//!
21//! - Everything above the first `---` line is the state (JSON if it parses as JSON).
22//! - `name? instructions` is a yes/no question (noul); `yes:` / `no:` lines describe the outcomes.
23//! - `name: instructions` followed by `label = description` lines (or bare labels) is a choice.
24//! - `name: instructions` followed by levels joined with `<` is a score, lowest first.
25//! - `name! {json}` sends a hand-built question object, like [`Question::Raw`].
26//! - The parts of a question can also go on its first line, separated by `|`.
27//! - `@model jev-2` pins the model; `#` starts a comment. Indentation is only for reading.
28//! - `@threshold 0.6` under a noul, or `@confidence 0.7` under a choice or a score, writes down the
29//!   bar its answer is acted on at. It stays on the page and is never sent.
30
31use 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/// What one line of a sketch turned out to be; shown in the editor gutter.
40#[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    /// `@threshold` or `@confidence`: the bar the question above is acted on at.
57    Bar,
58    /// A line that could not be placed; it always carries a problem.
59    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    /// Body lines carry their question's colour; heads are bold.
96    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    /// Zero-based line.
104    pub line: usize,
105    pub message: String,
106}
107
108/// Where a question sits on the page, so a bar can be written back without redrawing the page.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct BlockSpan {
111    /// Zero-based line of the question's head.
112    pub head: usize,
113    /// Zero-based line of the last line that belongs to it: a part, a criterion, its bar.
114    pub last: usize,
115    /// Zero-based line of its `@threshold` or `@confidence`, when it has one.
116    pub bar: Option<usize>,
117}
118
119/// A parsed sketch. Questions with problems are left out of `questions` but keep their tags, so
120/// the page still reads sensibly while it is being fixed.
121#[derive(Debug, Default)]
122pub struct Parsed {
123    pub state: Value,
124    pub model: Option<String>,
125    pub questions: Vec<(String, Question)>,
126    /// Each question's bar, from its `@threshold` or `@confidence` line.
127    pub bars: Vec<(String, f64)>,
128    /// Every question that parsed, by name: where it is on the page.
129    pub blocks: Vec<(String, BlockSpan)>,
130    /// One per line of the input.
131    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    /// Where a question that parsed sits on the page.
150    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    /// The bar a question's line gave it.
158    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    /// The first problem on or after a line — what the status line shows for the cursor.
166    pub fn problem_at(&self, line: usize) -> Option<&Problem> {
167        self.problems.iter().find(|p| p.line == line)
168    }
169}
170
171/// A question block under construction: its head, and the parts collected from the head line
172/// (after `|`) and the lines below it.
173struct Block {
174    line: usize,
175    name: String,
176    marker: char,
177    /// The head line after the marker, unsplit — raw questions need it whole.
178    rest: String,
179    parts: Vec<(usize, String)>,
180    /// `@threshold` / `@confidence` lines: the line, which of the two, and the number.
181    bars: Vec<(usize, Directive, f64)>,
182}
183
184/// The two directives that set a question's bar.
185#[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
320/// `name?` / `name:` / `name!` at the start of a line, with the name a plain identifier.
321/// `yes:` and `no:` are a noul's body, never a head.
322fn 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
352/// A bar as the page writes it: a plain decimal from 0 to 1. Plain, so `1e-1` and `0x1` are
353/// refused the same way in every port rather than read however a runtime happens to read them.
354pub 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
368/// The wire `type` of a question that parsed, with a hand-built object as `raw`.
369fn 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
378/// Turn a finished block into a question and its bar, or into problems.
379fn 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        // A question that did not parse has problems enough; its bar waits until it does.
394        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    // `name: instructions | part | part` — inline parts join the ones below.
465    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    // A `<` only means "level" in a part that is not an option; descriptions may contain one.
546    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
629/// Split on `sep`, except inside a double-quoted JSON string — so a quoted value can carry the
630/// notation's own punctuation. Splits at most once for `=`, since a description may contain it.
631fn 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
655/// Text becomes a JSON value when it is written as one (an object, an array or a quoted
656/// string); anything else is sent as the plain string it is.
657pub 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
677// ---- rendering ----------------------------------------------------------------------------
678
679/// The session as a sketch — the inverse of [`parse`], so a page can be opened, edited and
680/// applied without losing anything.
681pub 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
704/// Which directive holds a question's bar, by its kind; a raw question has none.
705fn 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
713/// Write bars into a page without redrawing it: each named question's bar line is replaced, or a
714/// new one is added at the end of its block, and every other line — comments, blank lines, the way
715/// someone chose to lay out their options — stays exactly as it was.
716///
717/// Questions the page does not have, raw ones and pages that do not parse are left alone; the
718/// caller has already parsed the page and knows which it is.
719pub 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
810/// A value as it goes on a sketch line: plain text when it can be read back as itself, a JSON
811/// literal when it could not (structured values, or text the notation would otherwise split).
812fn 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
827/// A sketch, highlighted for the transcript: heads bold in their kind's colour, bodies in it.
828pub 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}