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
29use 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/// What one line of a sketch turned out to be; shown in the editor gutter.
38#[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    /// A line that could not be placed; it always carries a problem.
55    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    /// Body lines carry their question's colour; heads are bold.
90    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    /// Zero-based line.
98    pub line: usize,
99    pub message: String,
100}
101
102/// A parsed sketch. Questions with problems are left out of `questions` but keep their tags, so
103/// the page still reads sensibly while it is being fixed.
104#[derive(Debug, Default)]
105pub struct Parsed {
106    pub state: Value,
107    pub model: Option<String>,
108    pub questions: Vec<(String, Question)>,
109    /// One per line of the input.
110    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    /// The first problem on or after a line — what the status line shows for the cursor.
128    pub fn problem_at(&self, line: usize) -> Option<&Problem> {
129        self.problems.iter().find(|p| p.line == line)
130    }
131}
132
133/// A question block under construction: its head, and the parts collected from the head line
134/// (after `|`) and the lines below it.
135struct Block {
136    line: usize,
137    name: String,
138    marker: char,
139    /// The head line after the marker, unsplit — raw questions need it whole.
140    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
240/// `name?` / `name:` / `name!` at the start of a line, with the name a plain identifier.
241/// `yes:` and `no:` are a noul's body, never a head.
242fn 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
272/// Turn a finished block into a question, or into problems.
273fn 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    // `name: instructions | part | part` — inline parts join the ones below.
309    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    // A `<` only means "level" in a part that is not an option; descriptions may contain one.
390    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
473/// Split on `sep`, except inside a double-quoted JSON string — so a quoted value can carry the
474/// notation's own punctuation. Splits at most once for `=`, since a description may contain it.
475fn 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
499/// Text becomes a JSON value when it is written as one (an object, an array or a quoted
500/// string); anything else is sent as the plain string it is.
501pub 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
521// ---- rendering ----------------------------------------------------------------------------
522
523/// The session as a sketch — the inverse of [`parse`], so a page can be opened, edited and
524/// applied without losing anything.
525pub 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
599/// A value as it goes on a sketch line: plain text when it can be read back as itself, a JSON
600/// literal when it could not (structured values, or text the notation would otherwise split).
601fn 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
616/// A sketch, highlighted for the transcript: heads bold in their kind's colour, bodies in it.
617pub 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}