Skip to main content

blockworx/script/
debugger.rs

1//! The script debugger: author a script as text, one step per line, and
2//! execute it like code under a debugger — step forward, step backward,
3//! reset — against the real editing tools.
4//!
5//! The core invariant: the state at position `p` is *the initial document
6//! with the step lines `0..p` replayed as one continuous script*, exactly
7//! the way the tutorial player and the golden tests replay a level. Every
8//! position change re-derives the state by prefix replay (milliseconds at
9//! tutorial scale), so stepping, rewinding, and editing share one code path
10//! and can never disagree with what a shipped level would do. The emitted
11//! level's `solution` is the full replay's end state — such a level passes
12//! the golden-replay test by construction.
13//!
14//! Execution halts at the first invalid line: a skipped step would make
15//! every later line replay against the wrong state.
16
17use crate::document_ng::Document;
18use crate::schema::model as schema;
19
20use super::headless::Headless;
21use super::parse::{Line, ParseError, parse_line};
22use super::step::{Script, Step};
23
24/// One line of the script buffer, as last parsed.
25pub struct LineEntry {
26    pub source: String,
27    pub kind: LineKind,
28}
29
30pub enum LineKind {
31    /// Empty, whitespace, or a comment — traversed silently.
32    Blank,
33    /// A `step key=… en=…` narration marker — starts a group, executes as a
34    /// no-op.
35    Marker {
36        key: String,
37        narration: String,
38    },
39    Step(Step),
40    /// Doesn't parse; execution refuses to pass it.
41    Invalid(ParseError),
42}
43
44/// What a transport action did.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum StepOutcome {
47    Advanced,
48    /// Parked at `line`: it is invalid (or failed to resolve at runtime) and
49    /// execution refuses to pass it.
50    Halted(usize),
51    AtEnd,
52    AtStart,
53}
54
55pub struct Debugger {
56    /// The captured starting document, in document KDL — replays parse it
57    /// exactly like a level file's `initial`.
58    initial_kdl: String,
59    lines: Vec<LineEntry>,
60    /// Lines `0..position` are applied; `position` is the next line to run.
61    position: usize,
62    harness: Headless,
63    /// A step line whose doc-anchored target named a missing object — a
64    /// *runtime* halt (the line parses fine), reported against its line.
65    runtime_halt: Option<(usize, String)>,
66}
67
68const NAME: &str = "script-debugger";
69
70impl Debugger {
71    /// A debugger over `doc` as the starting state ("Init" in the window).
72    pub fn new(doc: &Document) -> Self {
73        let initial_kdl = schema::Document::from(doc).to_kdl();
74        let harness = Headless::new(Some(&initial_kdl), NAME);
75        Self {
76            initial_kdl,
77            lines: Vec::new(),
78            position: 0,
79            harness,
80            runtime_halt: None,
81        }
82    }
83
84    /// Re-capture the starting document ("Init" again): the script text
85    /// stays, the position rewinds to the top of the new baseline.
86    pub fn init(&mut self, doc: &Document) {
87        self.initial_kdl = schema::Document::from(doc).to_kdl();
88        // Replaying zero lines can't fail.
89        let _ = self.replay(0);
90    }
91
92    /// Replace the script text (the editor's buffer, verbatim). The position
93    /// clamps to the first changed line — everything at or past an edit no
94    /// longer describes the state that was replayed through it.
95    pub fn set_text(&mut self, text: &str) {
96        let new_lines: Vec<LineEntry> = text
97            .split('\n')
98            .map(|source| LineEntry {
99                kind: match parse_line(source) {
100                    Ok(Line::Blank) => LineKind::Blank,
101                    Ok(Line::Marker { key, narration }) => LineKind::Marker { key, narration },
102                    Ok(Line::Step(step)) => LineKind::Step(step),
103                    Err(e) => LineKind::Invalid(e),
104                },
105                source: source.to_owned(),
106            })
107            .collect();
108        let first_change = self
109            .lines
110            .iter()
111            .zip(&new_lines)
112            .position(|(old, new)| old.source != new.source)
113            .unwrap_or_else(|| self.lines.len().min(new_lines.len()));
114        self.lines = new_lines;
115        if first_change < self.position {
116            // A failure parks the position at the failing line, which is all
117            // an edit-triggered rewind needs.
118            let _ = self.replay(first_change);
119        }
120    }
121
122    pub fn text(&self) -> String {
123        let sources: Vec<&str> = self.lines.iter().map(|l| l.source.as_str()).collect();
124        sources.join("\n")
125    }
126
127    pub fn lines(&self) -> &[LineEntry] {
128        &self.lines
129    }
130
131    /// The next line to execute — where the editor's marker sits.
132    pub fn position(&self) -> usize {
133        self.position
134    }
135
136    /// The line execution is parked on for a *runtime* failure, with the
137    /// message (parse failures live on the lines themselves).
138    pub fn runtime_halt(&self) -> Option<&(usize, String)> {
139        self.runtime_halt.as_ref()
140    }
141
142    /// Execute the next step line (traversing blanks and markers), halting
143    /// at an invalid line.
144    pub fn step_forward(&mut self) -> StepOutcome {
145        loop {
146            match self.lines.get(self.position).map(|l| &l.kind) {
147                None => return StepOutcome::AtEnd,
148                Some(LineKind::Blank | LineKind::Marker { .. }) => self.position += 1,
149                Some(LineKind::Invalid(_)) => return StepOutcome::Halted(self.position),
150                Some(LineKind::Step(_)) => {
151                    let target = self.position + 1;
152                    return match self.replay(target) {
153                        Ok(()) => StepOutcome::Advanced,
154                        Err(line) => StepOutcome::Halted(line),
155                    };
156                }
157            }
158        }
159    }
160
161    /// Rewind past the previous step line (to the state just before it).
162    pub fn step_back(&mut self) -> StepOutcome {
163        let previous = self.lines[..self.position]
164            .iter()
165            .rposition(|l| matches!(l.kind, LineKind::Step(_)));
166        if let Some(line) = previous {
167            let _ = self.replay(line);
168            StepOutcome::Advanced
169        } else {
170            let _ = self.replay(0);
171            StepOutcome::AtStart
172        }
173    }
174
175    /// Back to the initial state, position 0.
176    pub fn reset(&mut self) {
177        let _ = self.replay(0);
178    }
179
180    /// The state at the current position, for mirroring into the app editor.
181    pub fn document(&self) -> &Document {
182        self.harness.document()
183    }
184
185    pub fn projected(&self) -> schema::Document {
186        self.harness.projected()
187    }
188
189    /// Derive the state at `target` from scratch: reset to the initial
190    /// document and replay the step lines `0..target` as one continuous
191    /// script. On a runtime resolution failure, parks at the failing line
192    /// (the frames before it have run — the same state a full replay up to
193    /// that line produces) and records the halt.
194    fn replay(&mut self, target: usize) -> Result<(), usize> {
195        self.runtime_halt = None;
196        self.harness.reset(Some(&self.initial_kdl), NAME);
197        let step_lines: Vec<usize> = self.lines[..target]
198            .iter()
199            .enumerate()
200            .filter_map(|(i, l)| matches!(l.kind, LineKind::Step(_)).then_some(i))
201            .collect();
202        let script = Script::new(
203            step_lines
204                .iter()
205                .filter_map(|&i| match self.lines[i].kind {
206                    LineKind::Step(step) => Some(step),
207                    _ => None,
208                })
209                .collect(),
210        );
211        match self.harness.run_script(&script) {
212            Ok(()) => {
213                self.position = target;
214                Ok(())
215            }
216            Err(e) => {
217                // `e.step` indexes the prefix script; map back to its line.
218                let line = step_lines.get(e.step).copied().unwrap_or(target);
219                self.position = line;
220                self.runtime_halt = Some((line, format!("names a missing object: {:?}", e.target)));
221                Err(line)
222            }
223        }
224    }
225
226    /// The first invalid line, if any — emission refuses while one exists.
227    pub fn first_invalid(&self) -> Option<usize> {
228        self.lines
229            .iter()
230            .position(|l| matches!(l.kind, LineKind::Invalid(_)))
231    }
232
233    /// Assemble the complete level file: metadata, the captured initial, the
234    /// editor text wrapped into `step {}` groups at the marker lines —
235    /// verbatim — and the full replay's end state as the solution.
236    pub fn emit_level(&self, meta: &LevelMeta) -> Result<String, String> {
237        if let Some(line) = self.first_invalid() {
238            return Err(format!("line {} is invalid", line + 1));
239        }
240        let has_steps = self
241            .lines
242            .iter()
243            .any(|l| matches!(l.kind, LineKind::Step(_)));
244        if !has_steps {
245            return Err("the script has no steps".into());
246        }
247        // The solution: a scratch full replay, leaving the current position
248        // alone.
249        let mut scratch = Headless::new(Some(&self.initial_kdl), NAME);
250        let script = Script::new(
251            self.lines
252                .iter()
253                .filter_map(|l| match l.kind {
254                    LineKind::Step(step) => Some(step),
255                    _ => None,
256                })
257                .collect(),
258        );
259        scratch
260            .run_script(&script)
261            .map_err(|e| format!("step {} names a missing object", e.step))?;
262        let solution = scratch.projected();
263        Ok(emit(meta, &self.initial_kdl, &solution, &self.lines))
264    }
265}
266
267/// Metadata for [`Debugger::emit_level`]. `camera` is grid cells
268/// `(x, y, w, h)`; `None` derives it from the solution's top (sheet) block.
269pub struct LevelMeta {
270    pub id: String,
271    pub title: String,
272    pub instructions: String,
273    pub camera: Option<(i32, i32, i32, i32)>,
274}
275
276fn emit(
277    meta: &LevelMeta,
278    initial: &str,
279    solution: &schema::Document,
280    lines: &[LineEntry],
281) -> String {
282    use std::fmt::Write;
283    let mut out = String::new();
284    let title = if meta.title.trim().is_empty() {
285        meta.id.clone()
286    } else {
287        meta.title.trim().to_owned()
288    };
289    let _ = writeln!(
290        out,
291        "level {} title={} {{",
292        kdl_quote(meta.id.trim()),
293        kdl_quote(&title)
294    );
295    let _ = writeln!(
296        out,
297        "    instructions {}",
298        kdl_quote(meta.instructions.trim())
299    );
300    let (cx, cy, cw, ch) = meta.camera.unwrap_or_else(|| solution_camera(solution));
301    let _ = writeln!(out, "    camera x={cx} y={cy} w={cw} h={ch}");
302    let _ = writeln!(
303        out,
304        "    initial {{\n{}\n    }}",
305        indent(initial.trim_end(), "        ")
306    );
307    let _ = writeln!(
308        out,
309        "    solution {{\n{}\n    }}",
310        indent(solution.to_kdl().trim_end(), "        ")
311    );
312    let mut group_open = false;
313    for entry in lines {
314        match &entry.kind {
315            LineKind::Blank | LineKind::Invalid(_) => {}
316            LineKind::Marker { key, narration } => {
317                if group_open {
318                    let _ = writeln!(out, "    }}");
319                }
320                let _ = writeln!(
321                    out,
322                    "    step key={} en={} {{",
323                    kdl_quote(key),
324                    kdl_quote(narration)
325                );
326                group_open = true;
327            }
328            LineKind::Step(_) => {
329                if !group_open {
330                    // Steps before the first marker: an implicit opening group
331                    // the author renames in the file.
332                    let _ = writeln!(out, "    step key=\"step-1\" en=\"step-1\" {{");
333                    group_open = true;
334                }
335                let _ = writeln!(out, "        {}", entry.source.trim());
336            }
337        }
338    }
339    if group_open {
340        let _ = writeln!(out, "    }}");
341    }
342    out.push_str("}\n");
343    out
344}
345
346fn kdl_quote(s: &str) -> String {
347    let escaped = s
348        .replace('\\', "\\\\")
349        .replace('"', "\\\"")
350        .replace('\n', "\\n");
351    format!("\"{escaped}\"")
352}
353
354/// Indent a document snippet to sit inside a `{}` block.
355fn indent(kdl: &str, by: &str) -> String {
356    kdl.lines()
357        .map(|l| {
358            if l.is_empty() {
359                String::new()
360            } else {
361                format!("{by}{l}")
362            }
363        })
364        .collect::<Vec<_>>()
365        .join("\n")
366}
367
368/// The camera fallback: the solution's top (sheet) block, which the document
369/// pipeline keeps sized around the content; else a bounding box.
370fn solution_camera(solution: &schema::Document) -> (i32, i32, i32, i32) {
371    if let Some(top) = solution.blocks.iter().find(|b| b.id == solution.top) {
372        let (w, h) = (
373            i32::try_from(top.w).unwrap_or(i32::MAX),
374            i32::try_from(top.h).unwrap_or(i32::MAX),
375        );
376        return (top.x, top.y, w, h);
377    }
378    let xs = || solution.blocks.iter();
379    let x0 = xs().map(|b| b.x).min().unwrap_or(0);
380    let y0 = xs().map(|b| b.y).min().unwrap_or(0);
381    let far = |b: &schema::Block| b.x.saturating_add(i32::try_from(b.w).unwrap_or(i32::MAX));
382    let low = |b: &schema::Block| b.y.saturating_add(i32::try_from(b.h).unwrap_or(i32::MAX));
383    let x1 = xs().map(far).max().unwrap_or(24);
384    let y1 = xs().map(low).max().unwrap_or(20);
385    (x0 - 2, y0 - 2, (x1 - x0) + 4, (y1 - y0) + 4)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::widget::drawing::finalize_load;
392
393    const INITIAL: &str = r#"
394top "b0"
395
396block "b0" x=0 y=0 w=24 h=20 {
397    title "sheet"
398}
399"#;
400
401    const SCRIPT: &str = r#"step key="pick" en="Pick the New Block tool"
402highlight "tool:new-block" secs=0.3
403click "tool:new-block"
404step key="place" en="Click two corners"
405move-to "8,6" secs=0.3
406click "8,6"
407move-to "16,13" secs=0.3
408click "16,13"
409type "12,14" text="CPU" secs=0.5"#;
410
411    fn initial_doc() -> Document {
412        let mut doc = crate::document_ng::schema_convert::from_kdl(INITIAL, "test").unwrap();
413        finalize_load(&mut doc);
414        doc
415    }
416
417    fn debugger_with_script() -> Debugger {
418        let mut debugger = Debugger::new(&initial_doc());
419        debugger.set_text(SCRIPT);
420        debugger
421    }
422
423    fn titles(doc: &schema::Document) -> Vec<String> {
424        doc.blocks
425            .iter()
426            .filter_map(|b| b.title.as_ref().map(|t| t.name.clone()))
427            .collect()
428    }
429
430    fn run_to_end(debugger: &mut Debugger) {
431        while debugger.step_forward() == StepOutcome::Advanced {}
432    }
433
434    #[test]
435    fn stepping_to_the_end_builds_the_block() {
436        let mut debugger = debugger_with_script();
437        run_to_end(&mut debugger);
438        assert_eq!(debugger.step_forward(), StepOutcome::AtEnd);
439        let projected = debugger.projected();
440        assert_eq!(projected.blocks.len(), 2);
441        assert!(titles(&projected).contains(&"CPU".to_owned()));
442    }
443
444    /// Stepping back un-does the last step line; stepping forward again
445    /// re-derives the identical state (prefix replay is the single truth).
446    #[test]
447    fn stepping_back_and_forward_is_deterministic() {
448        let mut debugger = debugger_with_script();
449        run_to_end(&mut debugger);
450        let at_end = debugger.projected();
451
452        assert_eq!(debugger.step_back(), StepOutcome::Advanced);
453        assert!(
454            !titles(&debugger.projected()).contains(&"CPU".to_owned()),
455            "the type line should be un-done"
456        );
457        assert_eq!(debugger.step_forward(), StepOutcome::Advanced);
458        assert_eq!(debugger.projected(), at_end);
459    }
460
461    #[test]
462    fn reset_returns_to_the_initial_state() {
463        let mut debugger = debugger_with_script();
464        let initial = debugger.projected();
465        run_to_end(&mut debugger);
466        assert_ne!(debugger.projected(), initial);
467        debugger.reset();
468        assert_eq!(debugger.position(), 0);
469        assert_eq!(debugger.projected(), initial);
470    }
471
472    /// An invalid line is a wall: execution parks on it, repeated stepping
473    /// stays parked, and the state is the prefix before it.
474    #[test]
475    fn execution_halts_at_the_first_invalid_line() {
476        let mut debugger = Debugger::new(&initial_doc());
477        debugger.set_text("click \"tool:new-block\"\nwiggle \"1,1\"\nmove-to \"8,6\" secs=0.3");
478        assert_eq!(debugger.step_forward(), StepOutcome::Advanced);
479        let before = debugger.projected();
480        assert_eq!(debugger.step_forward(), StepOutcome::Halted(1));
481        assert_eq!(debugger.step_forward(), StepOutcome::Halted(1));
482        assert_eq!(debugger.position(), 1);
483        assert_eq!(debugger.projected(), before);
484    }
485
486    /// A line that parses but names a missing object halts at runtime.
487    #[test]
488    fn a_missing_target_halts_at_runtime() {
489        let mut debugger = Debugger::new(&initial_doc());
490        debugger.set_text("drag \"block:ghost\" \"4,4\" secs=0.3");
491        assert_eq!(debugger.step_forward(), StepOutcome::Halted(0));
492        assert!(debugger.runtime_halt().is_some());
493    }
494
495    /// Editing a line clamps the position back to it — the state past an
496    /// edit no longer describes what was replayed.
497    #[test]
498    fn editing_an_executed_line_rewinds_to_it() {
499        let mut debugger = debugger_with_script();
500        run_to_end(&mut debugger);
501        let mut text = debugger.text();
502        text = text.replace("click \"8,6\"", "click \"9,6\"");
503        debugger.set_text(&text);
504        assert!(debugger.position() <= 5);
505        assert!(!titles(&debugger.projected()).contains(&"CPU".to_owned()));
506    }
507
508    /// The emitted level round-trips through the real level parser and — the
509    /// by-construction property — replays to exactly its stored solution.
510    #[test]
511    fn emitted_level_replays_to_its_solution_by_construction() {
512        let mut debugger = debugger_with_script();
513        run_to_end(&mut debugger);
514        let text = debugger
515            .emit_level(&LevelMeta {
516                id: "authored".into(),
517                title: "Authored".into(),
518                instructions: "Do the thing.".into(),
519                camera: None,
520            })
521            .unwrap();
522        let level = crate::tutorial::level::Level::parse(Box::leak(text.into_boxed_str()))
523            .unwrap_or_else(|e| panic!("{e}"));
524        assert_eq!(level.steps.len(), 2);
525        assert_eq!(level.steps[0].key, "pick");
526        assert_eq!(level.steps[1].key, "place");
527
528        let mut harness = Headless::new(level.initial_kdl, &level.id);
529        harness.run_script(&level.script).unwrap();
530        let mut solution =
531            crate::document_ng::schema_convert::from_kdl(level.solution_kdl, &level.id).unwrap();
532        finalize_load(&mut solution);
533        assert_eq!(harness.projected(), schema::Document::from(&solution));
534    }
535
536    #[test]
537    fn emission_refuses_invalid_lines_and_empty_scripts() {
538        let meta = LevelMeta {
539            id: "x".into(),
540            title: String::new(),
541            instructions: String::new(),
542            camera: None,
543        };
544        let mut debugger = Debugger::new(&initial_doc());
545        assert!(debugger.emit_level(&meta).is_err());
546        debugger.set_text("wiggle \"1,1\"");
547        assert!(debugger.emit_level(&meta).unwrap_err().contains("line 1"));
548    }
549}