1use alloc::format;
40use alloc::string::{String, ToString};
41use alloc::vec::Vec;
42use core::fmt::Write as _;
43
44use crate::debug::DebugValue;
45use crate::debug_control::{BreakpointSet, DEFAULT_DEBUG_BUDGET, DebugStopReason, StepMode};
46use crate::{FastRng, Program, Story};
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Command {
51 Break {
53 file: String,
54 line: u32,
55 },
56 Run,
59 StepInstruction(StepMode),
61 StepLine(StepMode),
63 Locals,
66 Stack,
67 ExpectLine(u32),
69 ExpectLocal {
71 name: String,
72 value: String,
73 },
74 ExpectStack(Vec<String>),
76 ExpectTerminal,
78}
79
80#[derive(Debug)]
83pub struct ScriptError {
84 pub line: usize,
85 pub message: String,
86}
87
88impl std::fmt::Display for ScriptError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 write!(f, "script line {}: {}", self.line, self.message)
91 }
92}
93
94pub fn parse_script(text: &str) -> Result<Vec<Command>, ScriptError> {
103 let mut out = Vec::new();
104 for (i, raw) in text.lines().enumerate() {
105 let lineno = i + 1;
106 let line = raw.split('#').next().unwrap_or("").trim();
107 if line.is_empty() {
108 continue;
109 }
110 let err = |message: String| ScriptError {
111 line: lineno,
112 message,
113 };
114 let (verb, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
115 let rest = rest.trim();
116 let cmd = match verb {
117 "break" => {
118 let (file, line_s) = rest
119 .rsplit_once(':')
120 .ok_or_else(|| err(format!("expected `break <file>:<line>`, got {rest:?}")))?;
121 let n: u32 = line_s.trim().parse().map_err(|_| {
122 err(format!(
123 "line number must be a positive integer: {line_s:?}"
124 ))
125 })?;
126 if n == 0 {
127 return Err(err(
128 "line numbers in scripts are 1-based; 0 is not a line".into()
129 ));
130 }
131 Command::Break {
132 file: file.trim().to_string(),
133 line: n,
134 }
135 }
136 "run" | "continue" => Command::Run,
137 "stepi" => match rest {
138 "into" => Command::StepInstruction(StepMode::Into),
139 "over" => Command::StepInstruction(StepMode::Over),
140 "out" => Command::StepInstruction(StepMode::Out),
141 other => return Err(err(format!("stepi takes into|over|out, got {other:?}"))),
142 },
143 "step" => match rest {
147 "into" => Command::StepLine(StepMode::Into),
148 "over" => Command::StepLine(StepMode::Over),
149 "out" => Command::StepLine(StepMode::Out),
150 other => return Err(err(format!("step takes into|over|out, got {other:?}"))),
151 },
152 "next" => Command::StepLine(StepMode::Over),
153 "locals" => Command::Locals,
154 "stack" => Command::Stack,
155 "expect-line" => Command::ExpectLine(
156 rest.parse()
157 .map_err(|_| err(format!("expect-line takes a 1-based line, got {rest:?}")))?,
158 ),
159 "expect-local" => {
160 let (name, value) = rest.split_once('=').ok_or_else(|| {
161 err(format!(
162 "expected `expect-local <name> = <value>`, got {rest:?}"
163 ))
164 })?;
165 Command::ExpectLocal {
166 name: name.trim().to_string(),
167 value: value.trim().to_string(),
168 }
169 }
170 "expect-stack" => Command::ExpectStack(
171 rest.split('>')
172 .map(|s| s.trim().to_string())
173 .filter(|s| !s.is_empty())
174 .collect(),
175 ),
176 "expect-terminal" => Command::ExpectTerminal,
177 other => return Err(err(format!("unknown verb {other:?}"))),
178 };
179 out.push(cmd);
180 }
181 Ok(out)
182}
183
184fn render(value: &DebugValue) -> String {
188 match value {
189 DebugValue::Int(i) => i.to_string(),
190 DebugValue::Float(f) => format!("{f}"),
191 DebugValue::Bool(b) => b.to_string(),
192 DebugValue::Str(s) => format!("{s:?}"),
193 DebugValue::Null => "null".to_string(),
194 DebugValue::List(items) => format!("[{}]", items.join(", ")),
195 DebugValue::DivertTarget(t) => format!("-> {}", t.as_deref().unwrap_or("?")),
196 DebugValue::Struct { name, fields } => {
197 let inner: Vec<String> = fields
198 .iter()
199 .map(|(k, v)| format!("{k}: {}", render(v)))
200 .collect();
201 format!(
202 "{} {{ {} }}",
203 name.as_deref().unwrap_or("?"),
204 inner.join(", ")
205 )
206 }
207 DebugValue::Handle { kind, id } => format!("<{kind} #{id}>"),
208 DebugValue::Other(s) => s.clone(),
209 }
210}
211
212pub struct Session {
215 story: Story<FastRng>,
216 program: alloc::sync::Arc<Program>,
217 breakpoints: BreakpointSet,
218 transcript: String,
219 last_reason: Option<DebugStopReason>,
220}
221
222impl Session {
223 #[must_use]
224 pub fn new(
225 program: alloc::sync::Arc<Program>,
226 line_tables: Vec<Vec<brink_format::LineEntry>>,
227 ) -> Self {
228 let story = Story::<FastRng>::new(alloc::sync::Arc::clone(&program), line_tables);
229 Self {
230 story,
231 program,
232 breakpoints: BreakpointSet::new(),
233 transcript: String::new(),
234 last_reason: None,
235 }
236 }
237
238 #[must_use]
240 pub fn transcript(&self) -> &str {
241 &self.transcript
242 }
243
244 #[must_use]
249 pub fn current_position(&self) -> Option<(String, u32)> {
250 self.current_line()
251 }
252
253 fn current_line(&self) -> Option<(String, u32)> {
261 let pos = self.story.debug_snapshot().position?;
262 let loc = self.program.resolve_debug_position(pos)?;
263 let file = loc.file?;
264 let line0 = self.program.line_at(&file, loc.range_start)?;
265 Some((file, line0 + 1))
266 }
267
268 fn frame_names(&self) -> Vec<String> {
269 self.story
270 .debug_snapshot()
271 .call_stack
272 .iter()
273 .rev()
274 .filter_map(|f| f.location.clone())
275 .map(|name| {
280 if name.is_empty() {
281 "<root>".to_owned()
282 } else {
283 name
284 }
285 })
286 .collect()
287 }
288
289 fn note_position(&mut self) {
290 match self.current_line() {
291 Some((file, line)) => {
292 let _ = writeln!(self.transcript, " at {file}:{line}");
293 }
294 None => {
295 let _ = writeln!(self.transcript, " at <no source position>");
296 }
297 }
298 }
299}
300
301pub fn run_script(session: &mut Session, script: &[Command]) -> Result<String, String> {
308 for cmd in script {
309 match cmd {
310 Command::Break { .. }
311 | Command::Run
312 | Command::StepInstruction(_)
313 | Command::StepLine(_)
314 | Command::Locals
315 | Command::Stack => apply_action(session, cmd)?,
316 Command::ExpectLine(_)
317 | Command::ExpectLocal { .. }
318 | Command::ExpectStack(_)
319 | Command::ExpectTerminal => apply_expectation(session, cmd)?,
320 }
321 }
322 Ok(session.transcript.clone())
323}
324
325fn arm_breakpoint(session: &mut Session, file: &str, line: u32) -> Result<(), String> {
333 let position = session
335 .program
336 .resolve_source_line(file, line.saturating_sub(1))
337 .ok_or_else(|| {
338 let why = if session.program.has_debug_info() {
339 "that line has no executable code (a comment, a blank, or code that \
340 folded away)"
341 } else {
342 "this story carries no debug info — recompile the source, or build the \
343 artifact with `--debug-info`"
344 };
345 format!(
346 "{}\nbreak {file}:{line} bound to nothing — {why}. A breakpoint that can \
347 never hit is worse than none, so this is an error rather than a silent \
348 no-op.",
349 session.transcript
350 )
351 })?;
352 session.breakpoints.insert(
353 position.container_idx,
354 position.offset,
355 format!("{file}:{line}"),
356 );
357 let _ = writeln!(session.transcript, "break {file}:{line}");
358 Ok(())
359}
360
361fn apply_action(session: &mut Session, cmd: &Command) -> Result<(), String> {
362 match cmd {
363 Command::Break { file, line } => arm_breakpoint(session, file, *line)?,
364 Command::Run => {
365 let outcome = session
366 .story
367 .debug_run(&session.breakpoints, DEFAULT_DEBUG_BUDGET)
368 .map_err(|e| format!("{}\nrun failed: {e:?}", session.transcript))?;
369 let _ = writeln!(session.transcript, "run -> {}", describe(&outcome.reason));
370 session.last_reason = Some(outcome.reason);
371 session.note_position();
372 }
373 Command::StepInstruction(mode) => {
374 let outcome = session
375 .story
376 .debug_step(*mode, &session.breakpoints, DEFAULT_DEBUG_BUDGET)
377 .map_err(|e| format!("{}\nstepi failed: {e:?}", session.transcript))?;
378 let _ = writeln!(
379 session.transcript,
380 "stepi {} -> {}",
381 match mode {
382 StepMode::Into => "into",
383 StepMode::Over => "over",
384 StepMode::Out => "out",
385 },
386 describe(&outcome.reason)
387 );
388 session.last_reason = Some(outcome.reason);
389 session.note_position();
390 }
391 Command::StepLine(mode) => {
392 let outcome = session
393 .story
394 .debug_step_line(*mode, &session.breakpoints, DEFAULT_DEBUG_BUDGET)
395 .map_err(|e| format!("{}\nstep failed: {e:?}", session.transcript))?;
396 let _ = writeln!(
397 session.transcript,
398 "step {} -> {}",
399 match mode {
400 StepMode::Into => "into",
401 StepMode::Over => "over",
402 StepMode::Out => "out",
403 },
404 describe(&outcome.reason)
405 );
406 session.last_reason = Some(outcome.reason);
407 session.note_position();
408 }
409 Command::Locals => {
410 let snap = session.story.debug_snapshot();
411 let locals = snap.call_stack.first().and_then(|f| f.locals.as_ref());
412 match locals {
413 Some(ls) if !ls.is_empty() => {
414 let _ = writeln!(session.transcript, "locals");
415 for l in ls {
416 let _ = writeln!(session.transcript, " {} = {}", l.name, render(&l.value));
417 }
418 }
419 Some(_) => {
420 let _ = writeln!(session.transcript, "locals (none in scope)");
421 }
422 None => {
423 let _ = writeln!(
424 session.transcript,
425 "locals <unavailable: compiled without debug info>"
426 );
427 }
428 }
429 }
430 Command::Stack => {
431 let _ = writeln!(session.transcript, "stack");
432 for name in session.frame_names() {
433 let _ = writeln!(session.transcript, " {name}");
434 }
435 }
436 _ => unreachable!("apply_action only handles action verbs"),
437 }
438 Ok(())
439}
440
441fn apply_expectation(session: &mut Session, cmd: &Command) -> Result<(), String> {
445 match cmd {
446 Command::ExpectLine(want) => {
447 let got = session.current_line();
448 let _ = writeln!(session.transcript, "expect-line {want}");
449 match got {
450 Some((_, line)) if line == *want => {}
451 Some((file, line)) => {
452 return Err(format!(
453 "{}\nexpected to be stopped on line {want}, but the flow is at \
454 {file}:{line}",
455 session.transcript
456 ));
457 }
458 None => {
459 return Err(format!(
460 "{}\nexpected to be stopped on line {want}, but the flow has no source \
461 position (terminal, or parked)",
462 session.transcript
463 ));
464 }
465 }
466 }
467 Command::ExpectLocal { name, value } => {
468 let _ = writeln!(session.transcript, "expect-local {name} = {value}");
469 let snap = session.story.debug_snapshot();
470 let locals = snap
471 .call_stack
472 .first()
473 .and_then(|f| f.locals.as_ref())
474 .ok_or_else(|| {
475 format!(
476 "{}\nexpect-local {name}: this frame reports no locals at all (compiled \
477 without debug info?)",
478 session.transcript
479 )
480 })?;
481 let found = locals.iter().find(|l| &l.name == name).ok_or_else(|| {
482 let have: Vec<&str> = locals.iter().map(|l| l.name.as_str()).collect();
483 format!(
484 "{}\nexpect-local {name}: no such local in scope. In scope: {have:?}",
485 session.transcript
486 )
487 })?;
488 let got = render(&found.value);
489 if &got != value {
490 return Err(format!(
491 "{}\nexpect-local {name}: expected {value}, got {got}",
492 session.transcript
493 ));
494 }
495 }
496 Command::ExpectStack(want) => {
497 let _ = writeln!(session.transcript, "expect-stack {}", want.join(" > "));
498 let got = session.frame_names();
499 if &got != want {
500 return Err(format!(
501 "{}\nexpect-stack: expected {want:?}, got {got:?}",
502 session.transcript
503 ));
504 }
505 }
506 Command::ExpectTerminal => {
507 let _ = writeln!(session.transcript, "expect-terminal");
508 match &session.last_reason {
509 Some(DebugStopReason::Terminal) => {}
510 other => {
511 return Err(format!(
512 "{}\nexpect-terminal: the last action stopped for {other:?}, not a \
513 terminal outcome",
514 session.transcript
515 ));
516 }
517 }
518 }
519 _ => unreachable!("apply_expectation only handles expect verbs"),
520 }
521 Ok(())
522}
523
524fn describe(reason: &DebugStopReason) -> String {
527 match reason {
528 DebugStopReason::Breakpoint { name, .. } => format!("breakpoint {name}"),
529 DebugStopReason::Watchpoint { global_idx } => format!("watchpoint on global {global_idx}"),
530 DebugStopReason::Choices => "choice point".to_string(),
531 DebugStopReason::Step => "step".to_string(),
532 other => format!("{other:?}").to_lowercase(),
533 }
534}