Implement and archive Phase 5 debugger

This commit is contained in:
2026-09-06 21:24:38 +02:00
parent ce97700a98
commit e236306657
25 changed files with 3102 additions and 52 deletions

View File

@@ -134,7 +134,7 @@ struct VarInfo {
by_ref: bool, by_ref: bool,
} }
#[derive(Default)] #[derive(Default, Clone)]
struct Scope { struct Scope {
vars: HashMap<String, VarInfo>, vars: HashMap<String, VarInfo>,
labels: HashMap<String, LabelId>, labels: HashMap<String, LabelId>,
@@ -421,6 +421,55 @@ pub fn lower_with_forms(
(hir, s.diags) (hir, s.diags)
} }
/// Kompiliert Debugcode mit denselben Symbolen, DEFtype-Regeln und Slots wie der Originalrumpf.
/// Der neue Rumpf ist getrennt; der Projektquelltext wird nicht verändert.
#[derive(Default, Clone)]
pub struct DebugSymbols {
pub globals: Vec<hir::HVar>,
pub udts: Vec<hir::HUdt>,
pub original_globals: usize,
}
pub fn lower_debug(
module: &Module,
catalog: &FormCatalog,
procedure: &str,
text: &str,
expression: bool,
symbols: DebugSymbols,
) -> Result<(hir::HirModule, hir::HProc), Vec<Diagnostic>> {
let text = if expression {
format!("PRINT {text}\n")
} else {
format!("{text}\n")
};
let lexed = crate::lexer::lex(&text);
let parsed = crate::parser::parse("<Immediate>", &lexed.tokens);
let mut errors = lexed.diagnostics;
errors.extend(parsed.diagnostics);
if !parsed.module.procs.is_empty() {
errors.push(Diagnostic {
file: None,
pos: SourcePos::default(),
message: "Keine Prozedurdefinition im Direktfenster".into(),
});
}
if !errors.is_empty() {
return Err(errors);
}
let mut sema = new_sema(module, catalog);
sema.debug_symbols = Some(symbols);
sema.debug_request = Some((procedure.into(), parsed.module.body, expression));
let hir = sema.run(module);
if sema.debug_proc.is_none() {
sema.err(SourcePos::default(), "Kein erreichbarer Debug-Kontext");
}
if !sema.diags.is_empty() {
return Err(sema.diags);
}
Ok((hir.unwrap(), sema.debug_proc.unwrap()))
}
/// Semantisch gebundene Objekt-/Ereignisnamen für transaktionale IDE-Umbenennungen. /// Semantisch gebundene Objekt-/Ereignisnamen für transaktionale IDE-Umbenennungen.
/// Aufrufer validieren die gesamte Übersetzungseinheit einschließlich ihrer Imports. /// Aufrufer validieren die gesamte Übersetzungseinheit einschließlich ihrer Imports.
#[derive(Default)] #[derive(Default)]
@@ -464,6 +513,9 @@ fn new_sema(module: &Module, catalog: &FormCatalog) -> Sema {
module_name: module.name.clone(), module_name: module.name.clone(),
event_procs: Vec::new(), event_procs: Vec::new(),
references: None, references: None,
debug_request: None,
debug_proc: None,
debug_symbols: None,
} }
} }
@@ -569,6 +621,9 @@ struct Sema {
module_name: String, module_name: String,
event_procs: Vec<hir::HEventProc>, event_procs: Vec<hir::HEventProc>,
references: Option<BoundFormReferences>, references: Option<BoundFormReferences>,
debug_request: Option<(String, Vec<Stmt>, bool)>,
debug_proc: Option<hir::HProc>,
debug_symbols: Option<DebugSymbols>,
} }
impl Sema { impl Sema {
@@ -636,6 +691,7 @@ impl Sema {
}; };
self.prescan(&module.body, &mut scope, true); self.prescan(&module.body, &mut scope, true);
let body = self.lower_body(&module.body, &mut scope); let body = self.lower_body(&module.body, &mut scope);
self.lower_debug_scope(&module.name, &scope);
let main = hir::HProc { let main = hir::HProc {
name: module.name.clone(), name: module.name.clone(),
kind: hir::HProcKind::Main, kind: hir::HProcKind::Main,
@@ -795,6 +851,7 @@ impl Sema {
ret_ty = Some(self.h_ty(&ret)); ret_ty = Some(self.h_ty(&ret));
} }
let body = self.lower_body(&proc.body, &mut scope); let body = self.lower_body(&proc.body, &mut scope);
self.lower_debug_scope(&proc.sig.name, &scope);
let hproc = hir::HProc { let hproc = hir::HProc {
name: proc.sig.name.clone(), name: proc.sig.name.clone(),
kind: match proc.sig.kind { kind: match proc.sig.kind {
@@ -814,6 +871,156 @@ impl Sema {
} }
} }
fn lower_debug_scope(&mut self, name: &str, original: &Scope) {
let Some((target, statements, expression)) = self.debug_request.clone() else {
return;
};
if !name.eq_ignore_ascii_case(&target) {
return;
}
if original.locals.len() >= u16::MAX as usize {
self.err(SourcePos::default(), "Kein freier temporärer Debug-Slot");
return;
}
let mut scope = original.clone();
if let Some(symbols) = self.debug_symbols.take() {
let offset = self.udt_defs.len() as u16;
let mut udts = symbols.udts;
for udt in &mut udts {
for (_, ty) in &mut udt.fields {
if let HTy::Udt(id) = ty {
*id += offset;
}
}
}
for (i, udt) in udts.iter().enumerate() {
self.udt_ids.insert(udt.name.clone(), offset + i as u16);
}
self.udt_defs.extend(udts);
self.globals
.resize_with(symbols.original_globals, || hir::HVar {
name: "<DebugPad>".into(),
ty: HTy::Num(NumTy::Int),
array: false,
});
for mut var in symbols.globals {
if let HTy::Udt(id) = &mut var.ty {
*id += offset;
}
let ty = match &var.ty {
HTy::Num(n) => ty_of_num(*n),
HTy::Str => Ty::Str,
HTy::FixedStr(n) => Ty::FixedStr(*n),
HTy::Udt(id) => Ty::Udt(self.udt_defs[*id as usize].name.clone()),
HTy::Form => Ty::Form,
HTy::Control => Ty::Control,
};
let key = if var.name.ends_with(['%', '&', '!', '#', '$', '@']) {
var.name.clone()
} else {
format!("{}\u{1}AS", var.name)
};
scope.vars.insert(
key,
VarInfo {
ty,
array: var.array,
explicit: true,
slot: self.globals.len() as u16,
global: true,
by_ref: false,
},
);
self.globals.push(var);
}
}
let explicit = self.explicit;
self.explicit = true;
let globals = self.globals.len();
let locals = scope.locals.len();
let mut proc = hir::HProc {
name: "<Immediate>".into(),
kind: hir::HProcKind::Sub,
params: vec![],
locals: vec![],
ret_slot: None,
ret_ty: None,
body: vec![],
label_count: 0,
};
if expression {
if let [Stmt::Print { items, .. }] = statements.as_slice() {
if let [PrintItem::Expr(expr)] = items.as_slice() {
let (value, ty) = self.lower_expr(expr, &mut scope);
let ty = self.h_ty(&ty);
let slot = VarSlot::Local(scope.locals.len() as u16);
proc.body.push(HStmt {
pos: SourcePos::default(),
line: 0,
kind: HStmtKind::Assign {
place: HPlace {
base: slot,
base_is_ref: false,
indices: vec![],
fields: vec![],
ty: ty.clone(),
array_elem: None,
},
value,
},
});
proc.ret_ty = Some(ty.clone());
proc.ret_slot = Some(slot);
proc.kind = hir::HProcKind::Function;
proc.locals = scope.locals.clone();
proc.locals.push(hir::HVar {
name: "<Watch>".into(),
ty,
array: false,
});
} else {
self.err(
SourcePos::default(),
"Genau ein Watch-Ausdruck erforderlich",
);
}
} else {
self.err(
SourcePos::default(),
"Genau ein Watch-Ausdruck erforderlich",
);
}
} else {
if statements.iter().any(|s| {
!matches!(
s,
Stmt::Assign { .. }
| Stmt::Print { .. }
| Stmt::Call { .. }
| Stmt::ErrorStmt { .. }
)
}) {
self.err(
SourcePos::default(),
"Direktfenster erlaubt PRINT, Zuweisungen, Prozeduraufrufe und ERROR",
);
} else {
proc.body = self.lower_body(&statements, &mut scope);
}
proc.locals = scope.locals.clone();
}
if self.globals.len() != globals || scope.locals.len() != locals {
self.err(
SourcePos::default(),
"Direktcode darf keine neuen Variablen deklarieren",
);
}
proc.label_count = scope.next_label;
self.explicit = explicit;
self.debug_proc = Some(proc);
self.debug_request = None;
}
/// Prescan eines Rumpfs: Labels/Zeilennummern erhalten `LabelId`s; /// Prescan eines Rumpfs: Labels/Zeilennummern erhalten `LabelId`s;
/// im Modulrumpf werden zusätzlich DATA-Konstanten (statisch, in /// im Modulrumpf werden zusätzlich DATA-Konstanten (statisch, in
/// Quellreihenfolge) und RESTORE-Marken eingesammelt. /// Quellreihenfolge) und RESTORE-Marken eingesammelt.
@@ -1159,6 +1366,19 @@ impl Sema {
Some((id, object, member.to_string())) Some((id, object, member.to_string()))
} }
fn is_debug_path(&self, scope: &Scope, path: &str) -> bool {
self.debug_request.is_some()
&& path.contains('!')
&& scope.vars.keys().any(|k| {
k.trim_end_matches("\u{1}AS")
.trim_end_matches(['%', '&', '!', '#', '$', '@'])
== path
.split('.')
.next()
.unwrap_or(path)
.trim_end_matches(['%', '&', '!', '#', '$', '@'])
})
}
fn is_udt_path(&self, scope: &Scope, path: &str) -> bool { fn is_udt_path(&self, scope: &Scope, path: &str) -> bool {
let base = path.split('.').next().unwrap_or(path); let base = path.split('.').next().unwrap_or(path);
let as_key = format!("{base}\u{1}AS"); let as_key = format!("{base}\u{1}AS");
@@ -1669,7 +1889,9 @@ impl Sema {
return; return;
} }
} }
if (name.contains('.') || name.contains('!')) && !self.is_udt_path(scope, name) if (name.contains('.') || name.contains('!'))
&& !self.is_udt_path(scope, name)
&& !self.is_debug_path(scope, name)
{ {
if name.contains('!') && !name.contains('.') { if name.contains('!') && !name.contains('.') {
let object_name = name.split_once('!').unwrap().1; let object_name = name.split_once('!').unwrap().1;
@@ -3725,7 +3947,9 @@ impl Sema {
return false; return false;
} }
if (suffix.is_none() && self.find_object(name).is_some()) if (suffix.is_none() && self.find_object(name).is_some())
|| (name.contains('!') && !self.is_udt_path(scope, name)) || (name.contains('!')
&& !self.is_udt_path(scope, name)
&& !self.is_debug_path(scope, name))
{ {
return false; return false;
} }
@@ -4720,7 +4944,10 @@ impl Sema {
); );
} }
} }
if (name.contains('.') || name.contains('!')) && !self.is_udt_path(scope, name) { if (name.contains('.') || name.contains('!'))
&& !self.is_udt_path(scope, name)
&& !self.is_debug_path(scope, name)
{
if name.contains('!') && !name.contains('.') { if name.contains('!') && !name.contains('.') {
return match self.object_member_target(name, pos) { return match self.object_member_target(name, pos) {
Some((object, info, _)) => { Some((object, info, _)) => {

View File

@@ -192,6 +192,8 @@ pub enum AfterSave {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum DialogKind { pub enum DialogKind {
Watch(Command),
DeleteWatch,
DesignProperty, DesignProperty,
DesignPalette, DesignPalette,
DesignMenu((String, Option<i32>)), DesignMenu((String, Option<i32>)),
@@ -259,6 +261,7 @@ impl Dialog {
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum Hit { pub enum Hit {
DebugFrame(usize),
DesignTool(usize), DesignTool(usize),
DesignPaint(&'static str, u8), DesignPaint(&'static str, u8),
DesignObject(u64), DesignObject(u64),
@@ -275,6 +278,7 @@ pub enum Hit {
} }
pub struct App { pub struct App {
pub debugger: crate::debugger::Debugger,
pub designer: crate::designer::Designer, pub designer: crate::designer::Designer,
pub session: crate::execution::Session, pub session: crate::execution::Session,
pub editor: crate::editor::Editor, pub editor: crate::editor::Editor,
@@ -314,6 +318,7 @@ impl App {
let (options, errors, config_disk) = Options::load(&config_path); let (options, errors, config_disk) = Options::load(&config_path);
project.include_paths = options.include_paths.clone(); project.include_paths = options.include_paths.clone();
let mut app = Self { let mut app = Self {
debugger: Default::default(),
designer: Default::default(), designer: Default::default(),
session: Default::default(), session: Default::default(),
editor: Default::default(), editor: Default::default(),
@@ -570,6 +575,9 @@ impl App {
} }
} }
fn action(&mut self, command: Command) -> Result<()> { fn action(&mut self, command: Command) -> Result<()> {
if self.debug_command(command)? {
return Ok(());
}
if self.designer_command(command)? { if self.designer_command(command)? {
return Ok(()); return Ok(());
} }
@@ -1056,6 +1064,7 @@ impl App {
self.project.open_project(&path, Decision::Discard)?; self.project.open_project(&path, Decision::Discard)?;
} }
} }
self.debugger = Default::default();
self.session = Default::default(); self.session = Default::default();
self.designer = Default::default(); self.designer = Default::default();
self.basic_events.clear(); self.basic_events.clear();
@@ -1133,6 +1142,10 @@ impl App {
} }
fn submit(&mut self, d: &mut Dialog) -> Result<bool> { fn submit(&mut self, d: &mut Dialog) -> Result<bool> {
match d.kind.clone() { match d.kind.clone() {
DialogKind::Watch(_) | DialogKind::DeleteWatch => {
self.debug_submit(d)?;
return Ok(false);
}
DialogKind::DesignProperty DialogKind::DesignProperty
| DialogKind::DesignPalette | DialogKind::DesignPalette
| DialogKind::DesignMenu(_) | DialogKind::DesignMenu(_)
@@ -1491,6 +1504,15 @@ impl App {
} else if self.program_focus() { } else if self.program_focus() {
self.basic_events.push(event); self.basic_events.push(event);
} else if let Event::Paste(text) = event { } else if let Event::Paste(text) = event {
if self.dialog.is_none()
&& self.menu.is_none()
&& self
.active_window()
.is_some_and(|w| w.kind == WindowKind::Immediate)
{
self.debugger.immediate.push_str(&text);
return;
}
if self.dialog.is_none() && self.menu.is_none() && self.mode == Mode::Environment { if self.dialog.is_none() && self.menu.is_none() && self.mode == Mode::Environment {
if let Err(e) = self.editor_insert(&text) { if let Err(e) = self.editor_insert(&text) {
self.message = e.to_string(); self.message = e.to_string();
@@ -1535,6 +1557,11 @@ impl App {
return; return;
} }
match hit { match hit {
Hit::DebugFrame(index) => {
if let Err(e) = self.select_debug_frame(index) {
self.message = e.to_string();
}
}
Hit::DesignTool(_) => {} Hit::DesignTool(_) => {}
Hit::DesignObject(id) => { Hit::DesignObject(id) => {
if let Err(e) = self.design_select(id, false) { if let Err(e) = self.design_select(id, false) {
@@ -1924,6 +1951,9 @@ impl App {
)) ))
} }
fn code_key(&mut self, key: KeyEvent) -> Result<()> { fn code_key(&mut self, key: KeyEvent) -> Result<()> {
if self.debug_key(key)? {
return Ok(());
}
if self.mode == Mode::Designer || self.editor_view().is_err() { if self.mode == Mode::Designer || self.editor_view().is_err() {
return Ok(()); return Ok(());
} }

View File

@@ -101,9 +101,6 @@ impl Command {
pub fn feature_phase(self) -> Option<u8> { pub fn feature_phase(self) -> Option<u8> {
use Command::*; use Command::*;
match self { match self {
NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches
| Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement
| RunToCursor | Step | ProcedureStep | HistoryBack | HistoryForward => Some(6),
HelpIndex | HelpContents | Keyboard | Topic | UsingHelp | Tutorial => Some(7), HelpIndex | HelpContents | Keyboard | Topic | UsingHelp | Tutorial => Some(7),
_ => None, _ => None,
} }

View File

@@ -0,0 +1,687 @@
//! IDE debugger state and commands on the session's existing VM.
use crate::{
app::{App, Dialog, DialogKind, Execution, Field, Hit, WindowKind},
commands::Command,
documents::{BreakpointMark, Document, DocumentId},
editor::line_start,
};
use anyhow::{anyhow, ensure, Result};
use crossterm::event::{KeyCode as K, KeyEvent, KeyModifiers as M};
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::Paragraph,
Frame,
};
use tb_frontend::{Diagnostic, SourcePos};
use tb_vm::interp::{DebugLocation, DebugWatch, Vm};
#[derive(Default)]
pub struct Debugger {
pub source_modules: std::collections::BTreeMap<DocumentId, DocumentId>,
pub watches: Vec<DebugWatch>,
pub frame: Option<u64>,
pub trace: bool,
pub history: bool,
pub break_errors: bool,
pub historical: Option<usize>,
pub immediate: String,
pub motion: Option<Command>,
pub instant: Option<DebugWatch>,
pub last_location: Option<DebugLocation>,
}
fn module_name(doc: &Document) -> String {
match doc.content() {
tb_vm::project_io::Content::Form(form) => form.root.name.clone(),
_ => doc
.source_path()
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
}
}
pub fn physical_line(doc: &Document, at: usize) -> u32 {
let first = if let tb_vm::project_io::Content::Form(form) = doc.content() {
tb_ui::frm::read_text(
&doc.source_path().display().to_string(),
&tb_ui::frm::write_text(form),
)
.map_or(1, |f| f.code_line())
} else {
1
};
first
+ doc.code()[..at.min(doc.code().len())]
.bytes()
.filter(|c| *c == b'\n')
.count() as u32
}
impl App {
pub(crate) fn debug_setup(&mut self, vm: &mut Vm) {
vm.debug.enabled = true;
vm.debug.compiler = Some(self.editor.compiler.debug_compiler());
vm.debug.trace = self.debugger.trace;
vm.debug.history_on = self.debugger.history;
vm.debug.break_errors = self.debugger.break_errors;
for w in &mut self.debugger.watches {
w.value = Err("Noch kein aktueller Halt".into());
w.frame = None;
}
vm.debug.watches = self.debugger.watches.clone();
self.debugger.frame = None;
self.debugger.historical = None;
self.debugger.last_location = None;
self.debugger.instant = None;
self.bind_debug_breakpoints(vm);
}
fn bind_debug_breakpoints(&mut self, vm: &mut Vm) {
vm.clear_source_breakpoints();
let ids = self
.project
.documents()
.map(|(id, _)| id)
.collect::<Vec<_>>();
for id in ids {
let doc = self.project.document(id).unwrap();
let marks = doc
.breakpoints
.iter()
.map(|m| {
(
m.valid,
self.project
.document(m.module)
.map(module_name)
.unwrap_or_default(),
physical_line(doc, m.at),
)
})
.collect::<Vec<_>>();
let path = doc.source_path().display().to_string();
let bound = marks
.into_iter()
.map(|(valid, module, line)| {
let source = vm
.source_files()
.iter()
.enumerate()
.find(|(_, s)| {
s.path == path && vm.module_name(s.module).eq_ignore_ascii_case(&module)
})
.map(|(id, s)| (s.module, id as u32));
valid
&& source.is_some_and(|(module, source)| {
vm.add_source_breakpoint(module, source, line)
})
})
.collect::<Vec<_>>();
for (mark, bound) in self
.project
.breakpoint_marks_mut(id)
.unwrap()
.iter_mut()
.zip(bound)
{
mark.bound = bound;
}
}
}
fn debug_cursor(&self) -> Result<(String, u32)> {
let view = self.project.view(self.editor_view()?)?;
ensure!(
!self.editor.expansions.contains_key(&self.editor_view()?),
"Included File für einen physischen Quellort öffnen"
);
let doc = self.project.document(view.document())?;
Ok((
doc.source_path().display().to_string(),
physical_line(doc, view.cursor),
))
}
fn debug_target(&self) -> Result<(u16, u32, u32)> {
ensure!(self.current_compilation().is_some() && self.editor.revision == self.session.source_revision, "Cursorziele benötigen den unveränderten Quellstand der pausierten Sitzung; zuerst neu starten");
let (path, line) = self.debug_cursor()?;
let vm = self
.session
.vm
.as_ref()
.ok_or_else(|| anyhow!("Keine pausierte Sitzung"))?;
let module = vm.current_module();
let candidates = vm
.source_files()
.iter()
.enumerate()
.filter(|(_, s)| s.path == path)
.collect::<Vec<_>>();
let (source, file) = candidates
.iter()
.find(|(_, s)| s.module == module)
.copied()
.or_else(|| (candidates.len() == 1).then(|| candidates[0]))
.ok_or_else(|| {
anyhow!("Quelldatei gehört nicht eindeutig zur pausierten Kompilatrevision")
})?;
Ok((file.module, source as u32, line))
}
pub fn toggle_debug_breakpoint(&mut self) -> Result<()> {
let view = self.project.view(self.editor_view()?)?;
let id = view.document();
let at = line_start(self.project.document(id)?.code(), view.cursor);
let doc = self.project.document(id)?;
let line = physical_line(doc, at);
let module = if self.project.members().contains(&id) {
Some(id)
} else {
self.debugger
.source_modules
.get(&id)
.copied()
.or_else(|| self.project.members().get(self.selected_member).copied())
}
.ok_or_else(|| anyhow!("Kein Ursprungsmodul ausgewählt"))?;
let existing = doc
.breakpoints
.iter()
.position(|m| physical_line(doc, m.at) == line && m.module == module);
if let Some(index) = existing {
self.project.breakpoint_marks_mut(id)?.remove(index);
} else {
self.project.breakpoint_marks_mut(id)?.push(BreakpointMark {
at,
module,
valid: true,
bound: false,
});
}
if self.current_compilation().is_some()
&& self.editor.revision == self.session.source_revision
{
if let Some(mut vm) = self.session.vm.take() {
self.bind_debug_breakpoints(&mut vm);
self.session.vm = Some(vm);
}
}
self.message =
"Breakpoint aktualisiert · ● gebunden, ○ inaktiv bis gültiger Übersetzung".into();
Ok(())
}
pub(crate) fn debug_command(&mut self, c: Command) -> Result<bool> {
use Command::*;
match c {
Breakpoint => self.toggle_debug_breakpoint()?,
ClearBreakpoints => {
let ids = self
.project
.documents()
.map(|(id, _)| id)
.collect::<Vec<_>>();
for id in ids {
self.project.breakpoint_marks_mut(id)?.clear();
}
if let Some(vm) = &mut self.session.vm {
vm.clear_source_breakpoints();
}
}
Step | ProcedureStep | RunToCursor => {
ensure!(
self.execution != Execution::Paused || self.session.vm.is_some(),
"Keine pausierte VM"
);
if !matches!(
self.execution,
Execution::Paused | Execution::Running | Execution::Waiting
) {
self.start_execution()?;
}
self.debugger.motion = Some(c);
if self.execution == Execution::Paused {
self.continue_execution()?;
} else {
self.debug_resume_motion()?;
}
}
NextStatement => {
self.debugger.historical = None;
self.debug_navigate(None)?;
}
SetStatement => {
ensure!(
self.execution == Execution::Paused,
"Set Next Statement benötigt Break-Modus"
);
let (_, source, line) = self.debug_target()?;
let vm = self.session.vm.as_mut().unwrap();
let frame = vm
.debug_location()
.ok_or_else(|| anyhow!("Kein Frame"))?
.frame;
ensure!(
self.debugger.frame.is_none_or(|id| id == frame),
"Inspektionsrahmen ist nicht der aktive Frame"
);
vm.set_next_statement(frame, source, line)
.map_err(|e| anyhow!(e))?;
self.debug_navigate(None)?;
}
AddWatch | InstantWatch | Watchpoint => self.open_dialog(
if c == Watchpoint {
"Watchpoint"
} else {
"Watch"
},
DialogKind::Watch(c),
vec![Field::text("BASIC-Ausdruck", "")],
),
DeleteWatch => self.open_dialog(
"Delete Watch",
DialogKind::DeleteWatch,
vec![Field::choice(
"Watch",
self.debugger
.watches
.iter()
.map(|w| w.expression.clone())
.collect(),
0,
)],
),
DeleteWatches => {
self.debugger.watches.clear();
self.sync_watchpoints();
}
Trace | History | BreakErrors => {
match c {
Trace => self.debugger.trace = !self.debugger.trace,
History => self.debugger.history = !self.debugger.history,
_ => self.debugger.break_errors = !self.debugger.break_errors,
}
if let Some(vm) = &mut self.session.vm {
vm.debug.trace = self.debugger.trace;
vm.debug.history_on = self.debugger.history;
vm.debug.break_errors = self.debugger.break_errors;
}
self.message = format!(
"Trace {} · History {} · Break on Errors {}",
self.debugger.trace, self.debugger.history, self.debugger.break_errors
);
}
HistoryBack | HistoryForward => {
let vm = self
.session
.vm
.as_ref()
.ok_or_else(|| anyhow!("Keine History"))?;
ensure!(!vm.debug.history.is_empty(), "History ist leer");
let last = vm.debug.history.len();
let at = self.debugger.historical.unwrap_or(last);
let at = if c == HistoryBack {
at.saturating_sub(1)
} else {
(at + 1).min(last)
};
let location = vm.debug.history.get(at).cloned();
self.debugger.historical = (at < last).then_some(at);
self.debug_navigate(location)?;
}
Calls | Debug => {
self.refresh_debug_watches();
self.show_tool(if c == Calls {
WindowKind::Calls
} else {
WindowKind::Debug
});
}
Immediate => self.show_tool(WindowKind::Immediate),
_ => return Ok(false),
}
Ok(true)
}
pub(crate) fn debug_resume_motion(&mut self) -> Result<()> {
let motion = self.debugger.motion.take();
let target = if motion == Some(Command::RunToCursor) {
Some(self.debug_target()?)
} else {
None
};
if let Some(vm) = &mut self.session.vm {
vm.set_step(false);
vm.debug.cancel_motion();
vm.debug.error = None;
match motion {
Some(Command::Step) => vm.set_step(true),
Some(Command::ProcedureStep) => vm.step_over(),
Some(Command::RunToCursor) => {
let (m, s, l) = target.unwrap();
vm.run_to_cursor(m, s, l).map_err(|e| anyhow!(e))?;
}
_ => {}
}
}
self.debugger.historical = None;
Ok(())
}
fn sync_watchpoints(&mut self) {
if let Some(vm) = &mut self.session.vm {
vm.debug.watches = self.debugger.watches.clone();
}
}
pub fn refresh_debug_watches(&mut self) {
let Some(vm) = &mut self.session.vm else {
return;
};
let Some(active) = vm.debug_location() else {
return;
};
let frame = self
.debugger
.frame
.filter(|id| vm.debug_frames().iter().any(|f| f.id == *id))
.unwrap_or(active.frame);
self.debugger.frame = Some(frame);
for watch in &mut self.debugger.watches {
watch.frame = Some(frame);
watch.value = if watch.condition {
vm.evaluate_condition(frame, &watch.expression)
} else {
vm.evaluate_watch(frame, &watch.expression)
};
}
self.sync_watchpoints();
}
pub fn select_debug_frame(&mut self, index: usize) -> Result<()> {
ensure!(
self.execution == Execution::Paused,
"Calls-Inspektion benötigt Break-Modus"
);
let frame = self
.session
.vm
.as_ref()
.and_then(|vm| vm.debug_frames().get(index).cloned())
.ok_or_else(|| anyhow!("Frame nicht erreichbar"))?;
self.debugger.frame = Some(frame.id);
self.refresh_debug_watches();
self.debug_navigate(Some(frame.location))
}
pub(crate) fn debug_navigate(&mut self, location: Option<DebugLocation>) -> Result<()> {
let vm = self
.session
.vm
.as_ref()
.ok_or_else(|| anyhow!("Keine laufende Sitzung"))?;
let location = location
.or_else(|| vm.execution_location())
.ok_or_else(|| anyhow!("Kein Quellort"))?;
let file = vm.source_files()[location.source as usize].path.clone();
if file == "<Immediate>" {
self.show_tool(WindowKind::Immediate);
self.message = format!("Direktkommando pausiert · Zeile {}", location.line);
return Ok(());
}
self.debugger.last_location = Some(location.clone());
let owner_name = vm.module_name(location.module).to_string();
let owner = self.project.members().iter().copied().find(|id| {
self.project
.document(*id)
.is_ok_and(|doc| module_name(doc).eq_ignore_ascii_case(&owner_name))
});
self.session.fullscreen = false;
self.goto_diagnostic(&Diagnostic {
file: Some(file.clone()),
pos: SourcePos {
source: location.source,
line: location.line,
column: location.column,
},
message: String::new(),
})?;
if let (Some(id), Some(owner)) = (self.active_document(), owner) {
self.debugger.source_modules.insert(id, owner);
}
self.message = format!(
"{} · {}:{} · Werte vom aktuellen Halt{}",
if self.debugger.historical.is_some() {
"HISTORIE"
} else if self
.session
.vm
.as_ref()
.and_then(|vm| vm.debug_location())
.is_some_and(|at| at.frame != location.frame)
{
"Inspektionsrahmen"
} else {
"Ausführungsstelle"
},
file,
location.line,
if self.session.old_revision {
" · ALTES KOMPILAT"
} else {
""
}
);
if let Some(error) = self
.session
.vm
.as_ref()
.and_then(|vm| vm.debug.error.as_ref())
{
self.message.push_str(&format!(
" · Behandelter Fehler {} · Handler {:?}",
error.code, error.handler
));
}
if let Some(error) = self
.session
.vm
.as_ref()
.and_then(|vm| vm.debug.immediate_error.as_ref())
{
self.message.push_str(&format!(" · Direktfehler: {error}"));
}
Ok(())
}
pub fn submit_immediate(&mut self, text: &str) -> Result<()> {
ensure!(
self.execution == Execution::Paused,
"Direktkommandos benötigen Break-Modus"
);
let vm = self
.session
.vm
.as_mut()
.ok_or_else(|| anyhow!("Keine Sitzung"))?;
let frame = self
.debugger
.frame
.or_else(|| vm.debug_location().map(|l| l.frame))
.ok_or_else(|| anyhow!("Kein Frame"))?;
vm.start_immediate(frame, text).map_err(|e| anyhow!(e))?;
self.execution = Execution::Running;
self.message = "Direktkommando läuft · Unterbrechen über Run/Break".into();
Ok(())
}
pub(crate) fn debug_submit(&mut self, d: &Dialog) -> Result<()> {
match d.kind {
DialogKind::Watch(command) => {
let text = d.fields[0].string();
ensure!(!text.trim().is_empty(), "Ausdruck fehlt");
let mut watch = DebugWatch {
expression: text,
condition: command == Command::Watchpoint,
frame: None,
value: Err("Noch kein Halt".into()),
};
if self.execution == Execution::Paused {
if let Some(vm) = &mut self.session.vm {
if let Some(frame) = self
.debugger
.frame
.or_else(|| vm.debug_location().map(|p| p.frame))
{
watch.frame = Some(frame);
watch.value = if watch.condition {
vm.evaluate_condition(frame, &watch.expression)
} else {
vm.evaluate_watch(frame, &watch.expression)
};
}
}
}
if command == Command::InstantWatch {
self.debugger.instant = Some(watch);
} else {
self.debugger.watches.push(watch);
self.sync_watchpoints();
}
self.show_tool(WindowKind::Debug);
}
DialogKind::DeleteWatch => {
let i = d.fields[0].index();
if i < self.debugger.watches.len() {
self.debugger.watches.remove(i);
self.sync_watchpoints();
}
}
_ => {}
}
Ok(())
}
pub(crate) fn debug_key(&mut self, key: KeyEvent) -> Result<bool> {
match self.active_window().map(|w| w.kind) {
Some(WindowKind::Immediate) => {
match key.code {
K::Enter => {
let text = self.debugger.immediate.clone();
self.submit_immediate(&text)?;
self.debugger.immediate.clear();
}
K::Backspace => {
self.debugger.immediate.pop();
}
K::Char(c) if !key.modifiers.intersects(M::CONTROL | M::ALT) => {
self.debugger.immediate.push(c)
}
_ => {}
}
Ok(true)
}
Some(WindowKind::Calls) => {
let frames = self
.session
.vm
.as_ref()
.map(|vm| vm.debug_frames())
.unwrap_or_default();
let at = frames
.iter()
.position(|f| Some(f.id) == self.debugger.frame)
.unwrap_or(0);
let next = match key.code {
K::Down => (at + 1).min(frames.len().saturating_sub(1)),
K::Up => at.saturating_sub(1),
K::Enter => at,
_ => return Ok(true),
};
self.select_debug_frame(next)?;
self.show_tool(WindowKind::Calls);
Ok(true)
}
Some(WindowKind::Debug) => Ok(true),
_ => Ok(false),
}
}
pub(crate) fn debug_render(&mut self, f: &mut Frame, kind: WindowKind, r: Rect) {
let mut lines = Vec::new();
match kind {
WindowKind::Immediate => {
lines.push(format!("> {}", self.debugger.immediate));
lines.push("Enter: ausführen · Run/Break: unterbrechen · F5: fortsetzen".into());
lines.push(self.message.clone());
if let Some(vm) = &self.session.vm {
if let Some(error) = &vm.debug.immediate_error {
lines.push(error.clone());
}
}
}
WindowKind::Calls => {
if let Some(vm) = &self.session.vm {
for (row, frame) in vm.debug_frames().iter().enumerate() {
lines.push(format!(
"{} {} · {}:{}",
if Some(frame.id) == self.debugger.frame {
""
} else {
" "
},
frame.procedure,
frame.file,
frame.location.line
));
if row < r.height as usize {
self.hits.push((
Rect::new(r.x, r.y + row as u16, r.width, 1),
Hit::DebugFrame(row),
));
}
}
}
}
_ => {
lines.push(if self.debugger.historical.is_some() {
"HISTORIE · Werte vom aktuellen Halt".into()
} else {
"Watches · Werte vom aktuellen Halt (read-only)".into()
});
for watch in self
.debugger
.watches
.iter()
.chain(self.debugger.instant.iter())
{
lines.push(format!(
"{}{} = {}",
if watch.condition { "[Watchpoint] " } else { "" },
watch.expression,
match &watch.value {
Ok(v) => format!("{v:?}"),
Err(e) => format!("FEHLER: {e}"),
}
));
}
if let Some(vm) = &self.session.vm {
if let Some(error) = &vm.debug.error {
lines.push(format!(
"Behandelter Fehler {} · Ursache {:?} · Handler {:?}",
error.code, error.origin, error.handler
));
}
}
for (_, doc) in self.project.documents() {
for mark in &doc.breakpoints {
lines.push(format!(
"{} {}:{}",
if mark.bound { "" } else { "○ inaktiv" },
doc.source_path().display(),
physical_line(doc, mark.at)
));
}
}
}
}
f.render_widget(
Paragraph::new(lines.join("\n"))
.style(Style::default().fg(Color::White).bg(Color::Blue)),
r,
);
if kind == WindowKind::Immediate && r.height > 4 {
f.render_widget(
tb_ui::screen::ScreenWidget(self.session.screen()),
Rect::new(r.x, r.y + 4, r.width, r.height - 4),
);
}
}
}

View File

@@ -50,13 +50,23 @@ impl View {
#[derive(Debug)] #[derive(Debug)]
struct Edit { struct Edit {
breakpoints: Vec<BreakpointMark>,
content: Content, content: Content,
design_ids: BTreeMap<(String, Option<i32>), u64>, design_ids: BTreeMap<(String, Option<i32>), u64>,
group: Option<(u64, Vec<DocumentId>)>, group: Option<(u64, Vec<DocumentId>)>,
views: Vec<(ViewId, View)>, views: Vec<(ViewId, View)>,
} }
#[derive(Debug, Clone)]
pub struct BreakpointMark {
pub at: usize,
pub module: DocumentId,
pub valid: bool,
pub bound: bool,
}
#[derive(Debug)] #[derive(Debug)]
pub struct Document { pub struct Document {
pub breakpoints: Vec<BreakpointMark>,
source_path: PathBuf, source_path: PathBuf,
path: Option<PathBuf>, path: Option<PathBuf>,
content: Content, content: Content,
@@ -68,6 +78,29 @@ pub struct Document {
undo: Vec<Edit>, undo: Vec<Edit>,
design_ids: BTreeMap<(String, Option<i32>), u64>, design_ids: BTreeMap<(String, Option<i32>), u64>,
} }
fn remap_breakpoints(
marks: &[BreakpointMark],
edits: &[(Range<usize>, String)],
) -> Vec<BreakpointMark> {
marks
.iter()
.cloned()
.map(|mut mark| {
for (range, text) in edits.iter().rev() {
if range.contains(&mark.at) {
mark.valid = false;
}
if mark.at >= range.end {
mark.at = mark.at - range.len() + text.len();
} else if mark.at > range.start {
mark.at = range.start;
}
}
mark.bound = false;
mark
})
.collect()
}
impl Document { impl Document {
pub fn path(&self) -> Option<&Path> { pub fn path(&self) -> Option<&Path> {
self.path.as_deref() self.path.as_deref()
@@ -251,6 +284,7 @@ impl Project {
self.documents.insert( self.documents.insert(
id, id,
Document { Document {
breakpoints: Vec::new(),
source_path: path.clone(), source_path: path.clone(),
path: Some(path.clone()), path: Some(path.clone()),
saved: Some(read.content.clone()), saved: Some(read.content.clone()),
@@ -303,6 +337,7 @@ impl Project {
self.documents.insert( self.documents.insert(
id, id,
Document { Document {
breakpoints: Vec::new(),
source_path: path.clone(), source_path: path.clone(),
path: None, path: None,
content, content,
@@ -542,7 +577,15 @@ impl Project {
at at
} }
}; };
let marks = remap_breakpoints(
&old.breakpoints,
&[(
removed.clone(),
after[removed.start..removed.start + inserted].to_owned(),
)],
);
let undo = Edit { let undo = Edit {
breakpoints: old.breakpoints.clone(),
content: old.content.clone(), content: old.content.clone(),
design_ids: old.design_ids.clone(), design_ids: old.design_ids.clone(),
group: None, group: None,
@@ -554,6 +597,7 @@ impl Project {
.collect(), .collect(),
}; };
let doc = self.documents.get_mut(&id).unwrap(); let doc = self.documents.get_mut(&id).unwrap();
doc.breakpoints = marks;
doc.undo.push(undo); doc.undo.push(undo);
doc.content = content; doc.content = content;
doc.revision += 1; doc.revision += 1;
@@ -567,6 +611,13 @@ impl Project {
} }
Ok(()) Ok(())
} }
pub fn breakpoint_marks_mut(&mut self, id: DocumentId) -> Result<&mut Vec<BreakpointMark>> {
Ok(&mut self
.documents
.get_mut(&id)
.ok_or_else(|| anyhow!("Unbekanntes Dokument"))?
.breakpoints)
}
pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> { pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> {
self.replace_ranges(id, &[(range, text.to_owned())]) self.replace_ranges(id, &[(range, text.to_owned())])
} }
@@ -576,6 +627,7 @@ impl Project {
id: DocumentId, id: DocumentId,
edits: &[(Range<usize>, String)], edits: &[(Range<usize>, String)],
) -> Result<()> { ) -> Result<()> {
let marks = remap_breakpoints(&self.document(id)?.breakpoints, edits);
let mut content = self.document(id)?.content.clone(); let mut content = self.document(id)?.content.clone();
let mut end = 0; let mut end = 0;
for (range, _) in edits { for (range, _) in edits {
@@ -597,6 +649,7 @@ impl Project {
content.code_mut().replace_range(range.clone(), text); content.code_mut().replace_range(range.clone(), text);
} }
self.commit_edit(id, content)?; self.commit_edit(id, content)?;
self.documents.get_mut(&id).unwrap().breakpoints = marks;
for (view_id, old) in views { for (view_id, old) in views {
let map = |mut at: usize| { let map = |mut at: usize| {
for (range, text) in edits.iter().rev() { for (range, text) in edits.iter().rev() {
@@ -659,6 +712,7 @@ impl Project {
let Some(edit) = doc.undo.pop() else { let Some(edit) = doc.undo.pop() else {
return Ok(false); return Ok(false);
}; };
doc.breakpoints = edit.breakpoints;
doc.content = edit.content; doc.content = edit.content;
doc.design_ids = edit.design_ids; doc.design_ids = edit.design_ids;
doc.revision += 1; doc.revision += 1;

View File

@@ -501,7 +501,15 @@ impl App {
.resolve(doc.source_path().parent().unwrap(), &path) .resolve(doc.source_path().parent().unwrap(), &path)
.map_err(|e| anyhow!(e))?; .map_err(|e| anyhow!(e))?;
self.editor.history.push((id, view.cursor)); self.editor.history.push((id, view.cursor));
let owner = if self.project.members().contains(&id) {
Some(id)
} else {
self.debugger.source_modules.get(&id).copied()
};
let id = self.project.open_document(&path)?; let id = self.project.open_document(&path)?;
if let Some(owner) = owner {
self.debugger.source_modules.insert(id, owner);
}
self.show_document(id, false)?; self.show_document(id, false)?;
} }
IncludedLines => { IncludedLines => {
@@ -809,6 +817,7 @@ impl App {
} }
/// Immer aktuelle Quellen einschließlich externer Includes prüfen; alte Erfolge werden nie herausgegeben. /// Immer aktuelle Quellen einschließlich externer Includes prüfen; alte Erfolge werden nie herausgegeben.
pub fn compile_current(&mut self) -> Result<&CompiledModule> { pub fn compile_current(&mut self) -> Result<&CompiledModule> {
self.editor.compiler.debug_symbols = true;
self.editor.compiled = None; self.editor.compiled = None;
self.editor.revision = None; self.editor.revision = None;
self.editor.diagnostics.clear(); self.editor.diagnostics.clear();

View File

@@ -51,7 +51,7 @@ pub struct Session {
pub forms_phase: bool, pub forms_phase: bool,
pub file_shell: bool, pub file_shell: bool,
revision: Vec<u8>, revision: Vec<u8>,
source_revision: Option<crate::editor::Revision>, pub(crate) source_revision: Option<crate::editor::Revision>,
output: TextScreen, output: TextScreen,
} }
impl Default for Session { impl Default for Session {
@@ -109,7 +109,7 @@ impl App {
.map_err(|e| anyhow!(e))? .map_err(|e| anyhow!(e))?
}; };
let revision = module.to_tbc(); let revision = module.to_tbc();
let vm = new_execution( let mut vm = new_execution(
module, module,
&self.session.command, &self.session.command,
Some((self.size.0 as usize, self.size.1 as usize)), Some((self.size.0 as usize, self.size.1 as usize)),
@@ -126,6 +126,7 @@ impl App {
} else { } else {
target target
}; };
self.debug_setup(&mut vm);
self.session.finish(); self.session.finish();
self.basic_events.clear(); self.basic_events.clear();
self.session.vm = Some(vm); self.session.vm = Some(vm);
@@ -165,6 +166,11 @@ impl App {
) )
}, },
); );
if let Some(vm) = &mut self.session.vm {
vm.debug.cancel_motion();
}
self.debugger.frame = None;
self.refresh_debug_watches();
} }
} }
pub fn continue_execution(&mut self) -> Result<()> { pub fn continue_execution(&mut self) -> Result<()> {
@@ -208,6 +214,10 @@ impl App {
Ok(()) Ok(())
} }
pub fn resume_execution(&mut self, old: bool) { pub fn resume_execution(&mut self, old: bool) {
if let Err(e) = self.debug_resume_motion() {
self.message = e.to_string();
return;
}
if let Some(vm) = self.session.vm.as_mut() { if let Some(vm) = self.session.vm.as_mut() {
vm.rt.abbruch = false; vm.rt.abbruch = false;
self.session.old_revision = old; self.session.old_revision = old;
@@ -272,6 +282,11 @@ impl App {
} else { } else {
vm.poll(&mut self.session.host, 4096) vm.poll(&mut self.session.host, 4096)
}; };
for (watch, live) in self.debugger.watches.iter_mut().zip(&vm.debug.watches) {
if watch.condition {
watch.value = live.value.clone();
}
}
match result { match result {
PollResult::Yield => self.execution = Execution::Running, PollResult::Yield => self.execution = Execution::Running,
PollResult::Waiting { .. } => self.execution = Execution::Waiting, PollResult::Waiting { .. } => self.execution = Execution::Waiting,
@@ -289,10 +304,11 @@ impl App {
self.execution = Execution::Ended; self.execution = Execution::Ended;
self.message = "Ended".into(); self.message = "Ended".into();
} }
RunEvent::Stopped { .. } RunEvent::Stopped { .. } | RunEvent::Interrupted { .. } => self.pause_execution(),
| RunEvent::Interrupted { .. } RunEvent::Breakpoint { .. } | RunEvent::Stepped { .. } => {
| RunEvent::Breakpoint { .. } self.pause_execution();
| RunEvent::Stepped { .. } => self.pause_execution(), let _ = self.debug_navigate(None);
}
RunEvent::Error { RunEvent::Error {
code, code,
line, line,
@@ -323,5 +339,12 @@ impl App {
} }
}, },
} }
if self.debugger.trace && matches!(self.execution, Execution::Running | Execution::Waiting)
{
let location = self.session.vm.as_ref().and_then(|vm| vm.debug_location());
if location != self.debugger.last_location {
let _ = self.debug_navigate(location);
}
}
} }
} }

View File

@@ -12,3 +12,5 @@ pub mod editor;
pub mod execution; pub mod execution;
pub mod designer; pub mod designer;
pub mod debugger;

View File

@@ -222,6 +222,81 @@ impl App {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
f.render_widget(Paragraph::new(text).style(content_style), inner); f.render_widget(Paragraph::new(text).style(content_style), inner);
if expansion.is_none() {
for mark in &doc.breakpoints {
let row = doc.code()[..mark.at.min(doc.code().len())]
.bytes()
.filter(|c| *c == b'\n')
.count();
if row >= v.scroll_line
&& row < v.scroll_line + inner.height as usize
{
put(
f,
Rect::new(
rect.x,
inner.y + (row - v.scroll_line) as u16,
1,
1,
),
if mark.bound { "" } else { "" },
Style::default().fg(Color::Red),
);
}
}
}
if expansion.is_none() {
if let Some(vm) = &self.session.vm {
let actual = vm.execution_location();
let selected = self.debugger.last_location.as_ref();
for (location, mark) in
actual.as_ref().map(|p| (p, "")).into_iter().chain(
selected.map(|p| {
(
p,
if self.debugger.historical.is_some() {
"H"
} else if actual
.as_ref()
.is_some_and(|a| a.frame != p.frame)
{
"I"
} else {
""
},
)
}),
)
{
if vm.source_files().get(location.source as usize).is_some_and(
|source| {
std::path::Path::new(&source.path) == doc.source_path()
},
) {
let row = location
.line
.saturating_sub(crate::debugger::physical_line(doc, 0))
as usize;
if row >= v.scroll_line
&& row < v.scroll_line + inner.height as usize
{
put(
f,
Rect::new(
rect.x,
inner.y + (row - v.scroll_line) as u16,
1,
1,
),
mark,
Style::default().fg(Color::Yellow),
);
}
}
}
}
}
if count > inner.height as usize { if count > inner.height as usize {
f.render_stateful_widget( f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight) Scrollbar::new(ScrollbarOrientation::VerticalRight)
@@ -335,6 +410,9 @@ impl App {
WindowKind::Output => { WindowKind::Output => {
f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner) f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner)
} }
kind @ (WindowKind::Calls | WindowKind::Debug | WindowKind::Immediate) => {
self.debug_render(f, kind, inner)
}
kind => put( kind => put(
f, f,
inner, inner,

View File

@@ -252,7 +252,7 @@ fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() {
app.execution = Execution::Paused; app.execution = Execution::Paused;
plain(&mut app, K::F(10)); plain(&mut app, K::F(10));
assert_eq!(app.last_command, Some(Command::ProcedureStep)); assert_eq!(app.last_command, Some(Command::ProcedureStep));
assert!(app.message.contains("06")); assert_eq!(app.availability(Command::ProcedureStep), None);
menu(&mut app, Command::Output); menu(&mut app, Command::Output);
app.execution = Execution::Running; app.execution = Execution::Running;
plain(&mut app, K::Char('a')); plain(&mut app, K::Char('a'));

View File

@@ -0,0 +1,339 @@
use crossterm::event::{Event, KeyCode as K, KeyEvent, KeyModifiers as M};
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, Execution, Field, WindowKind},
commands::Command,
};
use tb_runtime::value::Value;
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let p = std::env::temp_dir().join(format!(
"tb-debug-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
fn app(&self, source: &str) -> App {
let p = self.0.join("main.bas");
fs::write(&p, source).unwrap();
let mut a = App::new(&self.0, self.0.join("config"), (100, 30)).unwrap();
a.load_initial_project(p).unwrap();
a.options.syntax_checking = false;
a
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn key(a: &mut App, k: K, m: M) {
a.handle(Event::Key(KeyEvent::new(k, m)));
}
fn tick(a: &mut App) {
for _ in 0..30 {
a.tick_execution(0);
if !matches!(a.execution, Execution::Running | Execution::Waiting) {
break;
}
}
}
fn cursor(a: &mut App, line: usize) {
let (id, v) = a
.windows
.iter()
.find_map(|w| {
if let WindowKind::Code(v) = w.kind {
Some((w.id, v))
} else {
None
}
})
.unwrap();
a.execute(Command::FocusWindow(id));
let doc = a.project.view(v).unwrap().document();
let code = a.project.document(doc).unwrap().code();
let at = code
.split_inclusive('\n')
.take(line - 1)
.map(str::len)
.sum();
a.project.view_mut(v).unwrap().cursor = at;
}
fn watch(a: &mut App, c: Command, text: &str) {
a.execute(c);
a.dialog.as_mut().unwrap().fields[0] = Field::text("BASIC-Ausdruck", text);
key(a, K::Enter, M::NONE);
assert!(a.dialog.is_none(), "{:?}", a.dialog);
}
#[test]
fn dispatcher_marks_edits_rebind_and_readonly_watches() {
let t = Temp::new();
let mut a = t.app("x%=1\nx%=2\nPRINT x%\nEND\n");
cursor(&mut a, 2);
key(&mut a, K::F(9), M::NONE);
let doc = a.active_document().unwrap();
a.project.replace_text(doc, 0..0, "' inserted\n").unwrap();
assert_eq!(
a.project.document(doc).unwrap().breakpoints[0].at,
"' inserted\nx%=1\n".len()
);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused, "{}", a.message);
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 3);
assert!(a.project.document(doc).unwrap().breakpoints[0].bound);
watch(&mut a, Command::AddWatch, "x% + 1");
assert!(matches!(a.debugger.watches[0].value, Ok(Value::Int(2))));
let before = a.project.document(doc).unwrap().code().to_string();
key(&mut a, K::Char('z'), M::NONE);
assert_eq!(a.project.document(doc).unwrap().code(), before);
watch(&mut a, Command::InstantWatch, "RND");
assert!(a.debugger.instant.as_ref().unwrap().value.is_err());
a.execute(Command::Immediate);
for c in "x%=7".chars() {
key(&mut a, K::Char(c), M::NONE);
}
key(&mut a, K::Enter, M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused, "{}", a.message);
assert!(matches!(
a.session.vm.as_ref().unwrap().inspect("x%"),
Some(Value::Int(7))
));
a.execute(Command::DeleteWatches);
assert!(a.debugger.watches.is_empty());
key(&mut a, K::F(8), M::NONE);
tick(&mut a);
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 4);
assert!(matches!(
a.session.vm.as_ref().unwrap().inspect("x%"),
Some(Value::Int(2))
));
a.execute(Command::ClearBreakpoints);
key(&mut a, K::F(5), M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Ended);
a.project.replace_text(doc, 0..0, "' again\n").unwrap();
cursor(&mut a, 1);
key(&mut a, K::F(9), M::NONE);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
assert_eq!(a.execution, Execution::Ended);
assert!(!a.project.document(doc).unwrap().breakpoints[0].bound);
}
#[test]
fn calls_procedure_step_cursor_history_and_restart() {
let t = Temp::new();
let mut a=t.app("CALL rec(2)\nx%=1\nx%=2\nEND\nSUB rec(n AS INTEGER)\nIF n>0 THEN CALL rec(n-1)\nPRINT n\nEND SUB\n");
a.execute(Command::History);
cursor(&mut a, 6);
key(&mut a, K::F(9), M::NONE);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
a.execute(Command::ClearBreakpoints);
let frame = a
.session
.vm
.as_ref()
.unwrap()
.debug_location()
.unwrap()
.frame;
key(&mut a, K::F(10), M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused);
assert_eq!(
a.session
.vm
.as_ref()
.unwrap()
.debug_location()
.unwrap()
.frame,
frame
);
cursor(&mut a, 3);
key(&mut a, K::F(7), M::NONE);
tick(&mut a);
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 3);
watch(&mut a, Command::AddWatch, "x%");
let current = a.session.vm.as_ref().unwrap().debug_location();
key(&mut a, K::F(8), M::SHIFT);
key(&mut a, K::F(8), M::SHIFT);
assert!(a.debugger.historical.is_some());
assert!(a.message.contains("HISTORIE"));
assert_eq!(a.session.vm.as_ref().unwrap().debug_location(), current);
assert!(matches!(a.debugger.watches[0].value, Ok(Value::Int(1))));
key(&mut a, K::F(10), M::SHIFT);
a.execute(Command::NextStatement);
assert!(a.debugger.historical.is_none());
a.execute(Command::Restart);
assert!(a.debugger.frame.is_none());
assert!(a.debugger.watches[0].value.is_err());
assert!(a.session.vm.as_ref().unwrap().debug.history.is_empty());
assert_eq!(a.debugger.watches.len(), 1);
}
#[test]
fn includes_and_deleted_marks_do_not_bind_to_foreign_statements() {
let t = Temp::new();
fs::write(t.0.join("a.bi"), "'a\nx%=1\n").unwrap();
fs::write(t.0.join("b.bi"), "'b\nx%=2\n").unwrap();
let mut a = t.app("'$INCLUDE: 'a.bi'\n'$INCLUDE: 'b.bi'\nEND\n");
cursor(&mut a, 2);
a.execute(Command::IncludedFile);
let doc = a.active_document().unwrap();
let v = match a.active_window().unwrap().kind {
WindowKind::Code(v) => v,
_ => panic!(),
};
a.project.view_mut(v).unwrap().cursor = 3;
key(&mut a, K::F(9), M::NONE);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused, "{}", a.message);
let vm = a.session.vm.as_ref().unwrap();
assert!(vm.current_file().ends_with("b.bi"));
assert!(matches!(vm.inspect("x%"), Some(Value::Int(1))));
a.project.replace_text(doc, 3..8, "").unwrap();
assert!(!a.project.document(doc).unwrap().breakpoints[0].valid);
a.execute(Command::Restart);
tick(&mut a);
assert_eq!(a.execution, Execution::Ended);
assert!(!a.project.document(doc).unwrap().breakpoints[0].bound);
a.project.undo(doc).unwrap();
assert!(a.project.document(doc).unwrap().breakpoints[0].valid);
}
#[test]
fn calls_mouse_inspection_instant_key_delete_and_error_window() {
let t = Temp::new();
let mut a = t.app(
"CALL rec(2)\nEND\nSUB rec(n AS INTEGER)\nIF n>0 THEN CALL rec(n-1)\nPRINT n\nEND SUB\n",
);
cursor(&mut a, 5);
key(&mut a, K::F(9), M::NONE);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
let current = a.session.vm.as_ref().unwrap().debug_location();
a.execute(Command::Calls);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
terminal.draw(|f| a.render(f)).unwrap();
let rect = a
.hits
.iter()
.find_map(|(r, h)| matches!(h, tb_ide::app::Hit::DebugFrame(1)).then_some(*r))
.unwrap();
a.handle(Event::Mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: rect.x,
row: rect.y,
modifiers: M::NONE,
}));
assert_eq!(a.session.vm.as_ref().unwrap().debug_location(), current);
key(&mut a, K::F(9), M::SHIFT);
a.dialog.as_mut().unwrap().fields[0] = Field::text("Ausdruck", "n");
key(&mut a, K::Enter, M::NONE);
assert!(matches!(
a.debugger.instant.as_ref().unwrap().value,
Ok(Value::Int(1))
));
watch(&mut a, Command::AddWatch, "n+2");
watch(&mut a, Command::AddWatch, "1/0");
assert!(a.debugger.watches[1].value.is_err());
a.execute(Command::DeleteWatch);
key(&mut a, K::Enter, M::NONE);
assert_eq!(a.debugger.watches.len(), 1);
terminal.draw(|f| a.render(f)).unwrap();
let display = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(display.contains("FEHLER"));
a.execute(Command::Immediate);
for c in "ERROR 5".chars() {
key(&mut a, K::Char(c), M::NONE);
}
key(&mut a, K::Enter, M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused);
assert_eq!(a.session.vm.as_ref().unwrap().debug_location(), current);
terminal.draw(|f| a.render(f)).unwrap();
let display = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(display.contains("Error"));
}
#[test]
fn form_handler_keys_and_old_revision_cursor_guard() {
let t = Temp::new();
let p = t.0.join("window.frm");
fs::write(&p,"VERSION 1.00\nBEGIN Form Form1\nEND\nSUB Form_Load()\nCALL work\nPRINT 2\nEND SUB\nSUB work()\nPRINT 1\nEND SUB\n").unwrap();
let mut a = App::new(&t.0, t.0.join("config"), (100, 30)).unwrap();
a.load_initial_project(p).unwrap();
a.options.syntax_checking = false;
cursor(&mut a, 2);
key(&mut a, K::F(9), M::NONE);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused, "{}", a.message);
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 5);
key(&mut a, K::F(10), M::NONE);
tick(&mut a);
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 6);
let before = a.session.vm.as_ref().unwrap().debug_location();
let doc = a.active_document().unwrap();
a.project.replace_text(doc, 0..0, "' edit\n").unwrap();
a.execute(Command::SetStatement);
assert!(a.message.contains("Quellstand"));
assert_eq!(a.session.vm.as_ref().unwrap().debug_location(), before);
}
#[test]
fn immediate_wait_run_transition_watchpoint_and_trace_are_session_owned() {
let t = Temp::new();
let mut a=t.app("x%=1\nx%=2\nx%=3\nEND\nSUB ask(n AS INTEGER)\nINPUT n\nEND SUB\nSUB restart()\nRUN\nEND SUB\n");
watch(&mut a, Command::Watchpoint, "x%=2");
a.execute(Command::Trace);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused, "{}", a.message);
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 3);
let before = a.session.vm.as_ref().unwrap().debug_location();
a.execute(Command::DeleteWatches);
a.execute(Command::Immediate);
a.submit_immediate("CALL ask(x%)").unwrap();
tick(&mut a);
assert_eq!(a.execution, Execution::Waiting);
a.execute(Command::Pause);
assert_eq!(a.execution, Execution::Paused);
assert!(a.session.vm.as_ref().unwrap().immediate_active());
key(&mut a, K::F(5), M::NONE);
a.execute(Command::Output);
key(&mut a, K::Char('9'), M::NONE);
key(&mut a, K::Enter, M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused);
assert_eq!(a.session.vm.as_ref().unwrap().debug_location(), before);
assert!(matches!(
a.session.vm.as_ref().unwrap().inspect("x%"),
Some(Value::Int(9))
));
a.submit_immediate("CALL restart()").unwrap();
tick(&mut a);
assert_eq!(a.execution, Execution::Ended);
assert!(a.session.vm.is_none());
}

View File

@@ -0,0 +1,612 @@
//! Opt-in debugger control. All execution remains in the ordinary VM.
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebugLocation {
pub frame: u64,
pub procedure: usize,
pub module: u16,
pub source: u32,
pub line: u32,
pub column: u32,
pub pc: usize,
}
#[derive(Debug, Clone)]
pub struct DebugFrame {
pub id: u64,
pub procedure: String,
pub file: String,
pub location: DebugLocation,
}
#[derive(Debug, Clone)]
pub struct DebugError {
pub code: u16,
pub origin: Option<DebugLocation>,
pub handler: Option<DebugLocation>,
pub resume_pc: usize,
}
#[derive(Default)]
pub struct Debugger {
pub enabled: bool,
pub trace: bool,
pub history_on: bool,
pub history: VecDeque<DebugLocation>,
history_pending: Option<DebugLocation>,
pub break_errors: bool,
pub error: Option<DebugError>,
pub(super) error_pending: bool,
pub immediate_error: Option<String>,
pub compiler: Option<crate::project::DebugCompiler>,
pub watches: Vec<DebugWatch>,
pub(super) next_frame: u64,
pub(super) breakpoints: HashSet<(u16, u32, u32)>,
over: Option<(u64, usize, usize)>,
cursor: Option<(u16, u32, u32)>,
pub(super) immediate: Option<DebugEntry>,
}
#[derive(Debug, Clone)]
pub struct DebugWatch {
pub expression: String,
pub condition: bool,
pub frame: Option<u64>,
pub value: Result<Value, String>,
}
impl Debugger {
pub fn cancel_motion(&mut self) {
self.over = None;
self.cursor = None;
}
}
impl Vm {
pub fn debug_location(&self) -> Option<DebugLocation> {
let f = self.frames.last()?;
Some(self.frame_location(f, f.last_stmt_pc))
}
fn frame_location(&self, f: &Frame, pc: usize) -> DebugLocation {
let code = &self.module.procs[f.proc].code;
let (source, column) = match pc.checked_sub(1).and_then(|p| code.get(p)) {
Some(Instr::Source(s, c)) => (*s, *c),
_ => (f.source, f.column),
};
let line = match code.get(pc) {
Some(Instr::Stmt(l) | Instr::InitStmt(l)) => *l,
_ => f.line,
};
DebugLocation {
frame: f.id,
procedure: f.proc,
module: self.module.sources[source as usize].module,
source,
line,
column,
pc,
}
}
pub fn execution_location(&self) -> Option<DebugLocation> {
let frame = self.frames.last()?;
let code = &self.module.procs[frame.proc].code;
let pc = match code.get(frame.pc) {
Some(Instr::Stmt(_)) => frame.pc,
Some(Instr::Source(..)) if matches!(code.get(frame.pc + 1), Some(Instr::Stmt(_))) => {
frame.pc + 1
}
_ => frame.last_stmt_pc,
};
Some(self.frame_location(frame, pc))
}
pub fn next_debug_location(&self) -> Option<DebugLocation> {
let f = self.frames.last()?;
let pc = self.module.procs[f.proc]
.code
.iter()
.enumerate()
.skip(f.pc)
.find(|(_, i)| matches!(i, Instr::Stmt(l) if *l > 0))
.map(|(i, _)| i)?;
Some(self.frame_location(f, pc))
}
pub fn debug_frames(&self) -> Vec<DebugFrame> {
self.frames
.iter()
.rev()
.map(|f| {
let location = self.frame_location(f, f.last_stmt_pc);
DebugFrame {
id: f.id,
procedure: self.module.procs[f.proc].name.clone(),
file: self.module.sources[location.source as usize].path.clone(),
location,
}
})
.collect()
}
pub fn module_name(&self, module: u16) -> &str {
&self.module.modules[module as usize].0
}
pub fn source_files(&self) -> &[tb_frontend::source::SourceFile] {
&self.module.sources
}
pub fn executable_line(&self, module: u16, source: u32, line: u32) -> bool {
self.statement_locations()
.iter()
.any(|p| p.module == module && p.source == source && p.line == line)
}
pub fn statement_locations(&self) -> Vec<DebugLocation> {
self.module
.procs
.iter()
.enumerate()
.flat_map(|(procedure, proc)| {
proc.code
.iter()
.enumerate()
.filter_map(move |(pc, instruction)| {
let Instr::Stmt(line) = instruction else {
return None;
};
if *line == 0 {
return None;
}
let (source, column) =
match pc.checked_sub(1).and_then(|p| proc.code.get(p)) {
Some(Instr::Source(s, c)) => (*s, *c),
_ => (0, 1),
};
Some(DebugLocation {
frame: 0,
procedure,
module: self.module.sources[source as usize].module,
source,
line: *line,
column,
pc,
})
})
})
.collect()
}
pub fn add_source_breakpoint(&mut self, module: u16, source: u32, line: u32) -> bool {
self.debug.enabled = true;
if !self.executable_line(module, source, line) {
return false;
}
self.debug.breakpoints.insert((module, source, line));
true
}
pub fn clear_source_breakpoints(&mut self) {
self.debug.breakpoints.clear();
}
pub fn step_over(&mut self) {
self.debug.enabled = true;
self.set_step(false);
if let Some(f) = self.frames.last() {
self.debug.over = Some((f.id, self.frames.len(), f.gosub.len()));
}
}
pub fn run_to_cursor(&mut self, module: u16, source: u32, line: u32) -> Result<(), String> {
if !self.executable_line(module, source, line) {
return Err("Cursorzeile ist nicht ausführbar".into());
}
self.debug.enabled = true;
self.set_step(false);
self.debug.cursor = Some((module, source, line));
Ok(())
}
pub(super) fn debug_boundary(&mut self, pc: usize, initializing: bool) -> Option<RunEvent> {
let error_pending = std::mem::take(&mut self.debug.error_pending);
if self
.debug
.immediate
.as_ref()
.is_some_and(|e| self.frames.last().is_some_and(|f| f.proc == e.proc))
{
return error_pending.then_some(RunEvent::Breakpoint {
line: self.current_line(),
});
}
if initializing || self.current_line() == 0 {
return None;
}
let loc = self.debug_location()?;
let f = self.frames.last_mut().unwrap();
let line_entry = f.debug_line.is_none_or(|(source, line, prior)| {
source != loc.source || line != loc.line || pc <= prior
});
f.debug_line = Some((loc.source, loc.line, pc));
if self.debug.history_on {
self.debug.history_pending = Some(loc.clone());
}
if error_pending {
return Some(RunEvent::Breakpoint { line: loc.line });
}
let f = self.frames.last().unwrap();
let key = (loc.module, loc.source, loc.line);
let breakpoint = line_entry && self.debug.breakpoints.contains(&key);
let cursor = self.debug.cursor == Some(key);
let over = self.debug.over.is_some_and(|(id, depth, gosub)| {
(f.id == id && f.gosub.len() <= gosub) || self.frames.len() < depth
});
let conditional = self.check_watchpoints();
if breakpoint || conditional || cursor || over {
self.debug.cancel_motion();
return Some(if breakpoint || conditional || cursor {
RunEvent::Breakpoint { line: loc.line }
} else {
RunEvent::Stepped { line: loc.line }
});
}
None
}
pub(super) fn record_debug_execution(&mut self) {
if let Some(location) = self.debug.history_pending.take() {
if self.debug.history_on
&& self
.frames
.last()
.is_some_and(|f| f.id == location.frame && f.pc == location.pc + 1)
{
if self.debug.history.len() == 1024 {
self.debug.history.pop_front();
}
self.debug.history.push_back(location);
}
}
}
pub fn set_next_statement(&mut self, frame: u64, source: u32, line: u32) -> Result<(), String> {
let f = self.frames.last().ok_or("Kein aktiver Frame")?;
if f.id != frame {
return Err("Nur der aktive Ausführungsrahmen darf verändert werden".into());
}
let target = self
.statement_locations()
.into_iter()
.find(|p| p.procedure == f.proc && p.source == source && p.line == line)
.ok_or("Ziel gehört nicht zum aktiven Prozedurrahmen")?;
if f.pc != f.last_stmt_pc + 1 || self.immediate_active() {
return Err(
"Kein freier Anweisungsbeginn; zuerst bis zur nächsten Grenze fortsetzen".into(),
);
}
if self.in_handler
|| !f.gosub.is_empty()
|| f.eingabe.is_some()
|| f.sleep.is_some()
|| f.waiting_form.is_some()
{
return Err("Aktiver Fehler-, GOSUB- oder Wartekontext erlaubt keinen Sprung".into());
}
let current = self.debug_location().ok_or("Kein Quellkontext")?;
if current.module != target.module {
return Err("Ziel liegt in einem anderen Modulkontext".into());
}
if let Some(compiler) = &self.debug.compiler {
let position = |p: &DebugLocation| tb_frontend::SourcePos {
source: p.source,
line: p.line,
column: p.column,
};
let proc = &self.module.procs[f.proc];
if !compiler.same_control_context(
current.module,
if proc.kind == tb_frontend::hir::HProcKind::Main {
"<main>"
} else {
&proc.name
},
position(&current),
position(&target),
) {
return Err("Ziel hat einen anderen strukturierten Kontrollkontext".into());
}
}
let code = &self.module.procs[f.proc].code;
let context = |at: usize| -> Vec<usize> {
code.iter()
.enumerate()
.filter_map(|(pc, i)| {
let t = match i {
Instr::Jump(t) | Instr::JumpIfFalse(t) | Instr::JumpIfTrue(t) => {
*t as usize
}
_ => return None,
};
((t <= at && at <= pc)
|| (pc < at
&& at < t
&& (!matches!(i, Instr::Jump(_)) || self.debug.compiler.is_none())))
.then_some(pc)
})
.collect()
};
let special = code.iter().any(|i| match i {
Instr::Gosub(t) | Instr::OnErrorLocal(t) | Instr::OnErrorGoto(t) => {
let start = *t as usize;
let end = code
.iter()
.enumerate()
.skip(start)
.find(|(_, i)| {
matches!(
i,
Instr::RetGosub
| Instr::Resume0
| Instr::ResumeNext
| Instr::ResumeLabel(_)
)
})
.map_or(code.len(), |(p, _)| p);
start <= target.pc && target.pc <= end
}
_ => false,
});
if special || context(f.last_stmt_pc) != context(target.pc) {
return Err("Ziel hat einen anderen Kontroll-, Schleifen- oder Handlerkontext".into());
}
let f = self.frames.last_mut().unwrap();
f.pc = target.pc + 1;
f.last_stmt_pc = target.pc;
f.source = target.source;
f.line = target.line;
f.column = target.column;
self.debug.history_pending = self
.debug
.history_on
.then(|| self.debug_location())
.flatten();
self.debug.cancel_motion();
self.frames.last_mut().unwrap().debug_line = Some((target.source, target.line, target.pc));
Ok(())
}
}
pub(super) struct DebugEntry {
pub depth: usize,
pub proc: usize,
pub base: usize,
pub locals_len: usize,
pub strings_len: usize,
pub sources_len: usize,
pub stack: Vec<Value>,
pub flags: u32,
pub history_pending: Option<DebugLocation>,
pub handler_return: Option<(usize, usize)>,
pub error_state: (u16, u32, u32, bool, usize),
}
impl Vm {
fn compile_debug(
&self,
frame: u64,
text: &str,
expression: bool,
) -> Result<crate::project::DebugCode, String> {
let f = self
.frames
.iter()
.find(|f| f.id == frame)
.ok_or("Inspektionsrahmen nicht mehr erreichbar")?;
let module = self.module.sources[f.source as usize].module;
let proc = &self.module.procs[f.proc];
self.debug
.compiler
.as_ref()
.ok_or("Debug-Symbole fehlen")?
.compile(
module,
if proc.kind == tb_frontend::hir::HProcKind::Main {
"<main>"
} else {
&proc.name
},
text,
expression,
)
}
fn install_debug_code(&mut self, mut code: crate::project::DebugCode) -> Result<usize, String> {
let offset = self.module.strings.len();
if offset + code.strings.len() >= u16::MAX as usize {
return Err("Zu viele Debug-Stringkonstanten".into());
}
for i in &mut code.procedure.code {
match i {
Instr::PushStr(id)
| Instr::Unsupported(id)
| Instr::LoadDynamicObjectProperty(id)
| Instr::StoreDynamicObjectProperty(id) => *id += offset as u16,
_ => {}
}
}
self.module.strings.extend(code.strings);
let id = self.module.procs.len();
self.module.procs.push(code.procedure);
Ok(id)
}
/// Side-effect-free bytecode only, executed by exec (no independent evaluator).
pub fn evaluate_watch(&mut self, frame: u64, text: &str) -> Result<Value, String> {
let code = self.compile_debug(frame, text, true)?;
let result_slot = code.procedure.locals_init.len().saturating_sub(1) as u16;
for i in &code.procedure.code {
let mut bytes = Vec::new();
i.encode(&mut bytes);
let pure = match i {
Instr::CallBuiltin(id, _) => *id <= ids::ATN || *id == ids::FORMAT_S,
Instr::StoreLocal(slot) => *slot == result_slot,
Instr::Source(_, _)
| Instr::Stmt(_)
| Instr::LoadGlobal(_)
| Instr::LoadLocal(_)
| Instr::LoadRef(_)
| Instr::LoadArr(..)
| Instr::LoadElem(_)
| Instr::LoadField(_)
| Instr::ArrBound(_)
| Instr::FixStr(_)
| Instr::LoadErr
| Instr::LoadErl
| Instr::RetFn => true,
_ => matches!(bytes[0], 0x10..=0x17 | 0x40..=0x95),
};
if !pure {
return Err(format!("Watch ist nicht nachweislich nebenwirkungsfrei: {i:?}; Aufrufe im Direktfenster verwenden"));
}
}
let f = self
.frames
.iter()
.find(|f| f.id == frame)
.ok_or("Frame nicht erreichbar")?;
let base = f.locals_base;
let count = self.module.procs[f.proc].locals_init.len();
let values = self.locals[base..base + count].to_vec();
let strings_len = self.module.strings.len();
let proc = self.install_debug_code(code)?;
let stack = std::mem::take(&mut self.stack);
self.push_frame(proc, 0);
let new_base = self.frames.last().unwrap().locals_base;
self.locals[new_base..new_base + count].clone_from_slice(&values);
let mut host = tb_runtime::host::CaptureHost::default();
let result = (|| {
for (pc, i) in self.module.procs[proc].code.clone().into_iter().enumerate() {
if matches!(i, Instr::Stmt(_) | Instr::Source(..)) {
continue;
}
if matches!(i, Instr::RetFn) {
return self.pop().map_err(|e| e.to_string());
}
if let Instr::LoadArr(global, slot, ..) = i {
let value = if global {
&self.globals[slot as usize]
} else {
&self.locals[new_base + slot as usize]
};
if matches!(value, Value::Empty) {
return Err(
"Array noch nicht initialisiert; Watch führt kein Auto-DIM aus".into(),
);
}
}
self.exec(i, pc, &mut host).map_err(|e| e.to_string())?;
}
Err("Watch ohne Ergebnis".into())
})();
self.pop_frame();
self.stack = stack;
self.module.procs.truncate(proc);
self.module.strings.truncate(strings_len);
result
}
pub fn evaluate_condition(&mut self, frame: u64, text: &str) -> Result<Value, String> {
let value = self.evaluate_watch(frame, text)?;
if !matches!(
value,
Value::Int(_) | Value::Lng(_) | Value::Sng(_) | Value::Dbl(_) | Value::Cur(_)
) {
return Err("Watchpoint benötigt eine numerische BASIC-Bedingung".into());
}
Ok(value)
}
fn check_watchpoints(&mut self) -> bool {
let Some(frame) = self.frames.last().map(|f| f.id) else {
return false;
};
let mut watches = std::mem::take(&mut self.debug.watches);
let mut stop = false;
for watch in watches.iter_mut().filter(|w| w.condition) {
watch.value = self.evaluate_condition(watch.frame.unwrap_or(frame), &watch.expression);
stop |= watch.value.as_ref().is_ok_and(Self::truthy);
}
self.debug.watches = watches;
stop
}
pub fn immediate_active(&self) -> bool {
self.debug.immediate.is_some()
}
pub fn start_immediate(&mut self, frame: u64, text: &str) -> Result<(), String> {
if self.immediate_active() {
return Err("Direktkommando läuft bereits".into());
}
// Compile all input before changing a stack or a variable.
let code = self.compile_debug(frame, text, false)?;
let f = self
.frames
.iter()
.find(|f| f.id == frame)
.ok_or("Frame nicht erreichbar")?;
let base = f.locals_base;
let source = self.module.sources.len() as u32;
let module = self.module.sources[f.source as usize].module;
let entry = DebugEntry {
depth: self.frames.len(),
proc: self.module.procs.len(),
base,
locals_len: self.locals.len(),
strings_len: self.module.strings.len(),
sources_len: self.module.sources.len(),
stack: self.stack.clone(),
flags: self.flags,
history_pending: self.debug.history_pending.take(),
handler_return: None,
error_state: (
self.err,
self.erl,
self.zeile_nr,
self.in_handler,
self.resume_pc,
),
};
let proc = self.install_debug_code(code)?;
self.module.sources.push(tb_frontend::source::SourceFile {
module,
path: "<Immediate>".into(),
});
for i in &mut self.module.procs[proc].code {
if let Instr::Source(s, _) = i {
*s = source;
}
}
self.debug.immediate_error = None;
self.stack.clear();
self.push_frame(proc, 0);
self.locals.truncate(entry.locals_len);
let f = self.frames.last_mut().unwrap();
f.locals_base = base;
f.source = source;
self.flags &= !F_STEP;
self.debug.cancel_motion();
self.debug.immediate = Some(entry);
Ok(())
}
pub(super) fn restore_debug_handler(&mut self) {
if let Some(entry) = &mut self.debug.immediate {
if self.frames.len() == entry.depth + 1 {
if let Some((stmt, _)) = entry.handler_return.take() {
let f = self.frames.last_mut().unwrap();
f.proc = entry.proc;
f.locals_base = entry.base;
f.last_stmt_pc = stmt;
self.resume_pc = stmt;
}
}
}
}
pub(super) fn finish_immediate(&mut self) {
let Some(entry) = self.debug.immediate.take() else {
return;
};
self.frames.truncate(entry.depth);
self.locals.truncate(entry.locals_len);
self.stack = entry.stack;
self.flags = entry.flags;
(
self.err,
self.erl,
self.zeile_nr,
self.in_handler,
self.resume_pc,
) = entry.error_state;
self.module.procs.truncate(entry.proc);
self.module.strings.truncate(entry.strings_len);
self.module.sources.truncate(entry.sources_len);
self.debug.error = None;
self.debug.history_pending = entry.history_pending;
}
}

View File

@@ -19,6 +19,10 @@ use tb_runtime::traps::Quelle;
use tb_runtime::value::{self, default_value, ArrayObj, RecordObj, TypeInit, Value, VarRef}; use tb_runtime::value::{self, default_value, ArrayObj, RecordObj, TypeInit, Value, VarRef};
use tb_ui::forms::{FormEvent, FormsModel, PropertyValue, ShowResult}; use tb_ui::forms::{FormEvent, FormsModel, PropertyValue, ShowResult};
#[path = "debugger.rs"]
mod debugger;
pub use debugger::*;
/// Warum die VM die Kontrolle abgibt. /// Warum die VM die Kontrolle abgibt.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum RunEvent { pub enum RunEvent {
@@ -69,6 +73,8 @@ enum Handler {
} }
struct Frame { struct Frame {
id: u64,
debug_line: Option<(u32, u32, usize)>,
proc: usize, proc: usize,
pc: usize, pc: usize,
locals_base: usize, locals_base: usize,
@@ -115,6 +121,7 @@ enum FormEventReturn {
pub struct Vm { pub struct Vm {
module: CompiledModule, module: CompiledModule,
pub debug: Debugger,
globals: Vec<Value>, globals: Vec<Value>,
locals: Vec<Value>, locals: Vec<Value>,
stack: Vec<Value>, stack: Vec<Value>,
@@ -194,6 +201,7 @@ impl Vm {
forms.show(form, false).expect("validiertes Startformular"); forms.show(form, false).expect("validiertes Startformular");
} }
let mut vm = Vm { let mut vm = Vm {
debug: Debugger::default(),
globals, globals,
locals: Vec::new(), locals: Vec::new(),
stack: Vec::new(), stack: Vec::new(),
@@ -258,7 +266,10 @@ impl Vm {
self.locals[locals_base + i] = v; self.locals[locals_base + i] = v;
} }
let stack_base = self.stack.len(); let stack_base = self.stack.len();
self.debug.next_frame += 1;
self.frames.push(Frame { self.frames.push(Frame {
id: self.debug.next_frame,
debug_line: None,
source: 0, source: 0,
column: 0, column: 0,
proc, proc,
@@ -785,7 +796,10 @@ impl Vm {
fn trap_frame_aufsetzen(&mut self, q: Quelle, ziel: u32) { fn trap_frame_aufsetzen(&mut self, q: Quelle, ziel: u32) {
let locals_base = self.frames[0].locals_base; let locals_base = self.frames[0].locals_base;
let stack_base = self.stack.len(); let stack_base = self.stack.len();
self.debug.next_frame += 1;
self.frames.push(Frame { self.frames.push(Frame {
id: self.debug.next_frame,
debug_line: None,
source: 0, source: 0,
column: 0, column: 0,
proc: 0, proc: 0,
@@ -1114,6 +1128,9 @@ impl Vm {
continue; continue;
} }
} }
if self.debug.enabled {
self.record_debug_execution();
}
let Some(frame) = self.frames.last_mut() else { let Some(frame) = self.frames.last_mut() else {
return PollResult::Event(RunEvent::Ended); return PollResult::Event(RunEvent::Ended);
}; };
@@ -1121,7 +1138,19 @@ impl Vm {
let Some(instr) = self.module.procs[frame.proc].code.get(pc).cloned() else { let Some(instr) = self.module.procs[frame.proc].code.get(pc).cloned() else {
return PollResult::Event(RunEvent::Ended); return PollResult::Event(RunEvent::Ended);
}; };
frame.pc = pc + 1; if matches!(instr, Instr::RetProc | Instr::RetFn)
&& self
.debug
.immediate
.as_ref()
.is_some_and(|e| self.frames.len() == e.depth + 1)
{
self.finish_immediate();
return PollResult::Event(RunEvent::Stopped {
line: self.current_line(),
});
}
self.frames.last_mut().unwrap().pc = pc + 1;
match self.exec(instr, pc, host) { match self.exec(instr, pc, host) {
Ok(Flow::Normal) => {} Ok(Flow::Normal) => {}
Ok(Flow::Event(ev)) => return PollResult::Event(ev), Ok(Flow::Event(ev)) => return PollResult::Event(ev),
@@ -1132,6 +1161,13 @@ impl Vm {
f.sleep = None; f.sleep = None;
} }
if let Some(ev) = self.handle_error(e.0, host) { if let Some(ev) = self.handle_error(e.0, host) {
if matches!(ev, RunEvent::Error { .. }) && self.immediate_active() {
self.debug.immediate_error = Some(format!("{ev:?}"));
self.finish_immediate();
return PollResult::Event(RunEvent::Stopped {
line: self.current_line(),
});
}
return PollResult::Event(ev); return PollResult::Event(ev);
} }
} }
@@ -1151,6 +1187,7 @@ impl Vm {
/// `Some(event)` = unbehandelt (Programmabbruch). /// `Some(event)` = unbehandelt (Programmabbruch).
fn handle_error(&mut self, code: u16, _host: &mut dyn Host) -> Option<RunEvent> { fn handle_error(&mut self, code: u16, _host: &mut dyn Host) -> Option<RunEvent> {
let line = self.current_line(); let line = self.current_line();
let error_location = self.debug_location();
if self.in_handler { if self.in_handler {
// Fehler im Handler: fatal, keine Kaskade. // Fehler im Handler: fatal, keine Kaskade.
return Some(self.error_event(code, line)); return Some(self.error_event(code, line));
@@ -1175,9 +1212,24 @@ impl Vm {
} }
} }
} }
let Some((depth, handler)) = target else { let Some((mut depth, handler)) = target else {
return Some(self.error_event(code, line)); return Some(self.error_event(code, line));
}; };
if let Some(entry) = &mut self.debug.immediate {
if depth < entry.depth {
let handler_proc = self.frames[depth].proc;
let handler_base = self.frames[depth].locals_base;
let f = &mut self.frames[entry.depth];
if handler == Handler::ResumeNext {
depth = entry.depth;
} else {
entry.handler_return = Some((f.last_stmt_pc, f.pc));
f.proc = handler_proc;
f.locals_base = handler_base;
depth = entry.depth;
}
}
}
// Unwinding bis zum Handler-Frame. // Unwinding bis zum Handler-Frame.
while self.frames.len() > depth + 1 { while self.frames.len() > depth + 1 {
self.pop_frame(); self.pop_frame();
@@ -1203,6 +1255,16 @@ impl Vm {
} }
Handler::None => unreachable!(), Handler::None => unreachable!(),
} }
if self.debug.break_errors {
self.debug.error = Some(DebugError {
code,
origin: error_location,
handler: self.next_debug_location(),
resume_pc: self.resume_pc,
});
self.debug.cancel_motion();
self.debug.error_pending = true;
}
None None
} }
@@ -1435,21 +1497,32 @@ impl Vm {
} }
let erster_eintritt = let erster_eintritt =
std::mem::take(&mut self.frames.last_mut().unwrap().handler_start); std::mem::take(&mut self.frames.last_mut().unwrap().handler_start);
let caller = self.frames.len() - 1;
if !initializing if !initializing
&& !erster_eintritt && !erster_eintritt
&& !self.debug.error_pending
&& self.zustellen(host, Zustellpunkt::Anweisung) && self.zustellen(host, Zustellpunkt::Anweisung)
{ {
if self.debug.enabled {
self.frames[caller].pc = pc;
}
return Ok(Flow::Normal); return Ok(Flow::Normal);
} }
if self.flags != 0 { if self.debug.enabled {
if self.flags & F_STEP != 0 { if let Some(event) = self.debug_boundary(pc, initializing) {
return Ok(Flow::Event(RunEvent::Stepped { line })); return Ok(Flow::Event(event));
} }
}
if self.flags != 0 {
if self.flags & F_BREAK != 0 if self.flags & F_BREAK != 0
&& self.breakpoints.contains(&(self.current_module(), line)) && self.breakpoints.contains(&(self.current_module(), line))
{ {
self.debug.cancel_motion();
return Ok(Flow::Event(RunEvent::Breakpoint { line })); return Ok(Flow::Event(RunEvent::Breakpoint { line }));
} }
if self.flags & F_STEP != 0 && line > 0 {
return Ok(Flow::Event(RunEvent::Stepped { line }));
}
if self.flags & F_POLL != 0 && self.rt.abbruch { if self.flags & F_POLL != 0 && self.rt.abbruch {
return Ok(Flow::Event(RunEvent::Interrupted { line })); return Ok(Flow::Event(RunEvent::Interrupted { line }));
} }
@@ -2577,11 +2650,17 @@ impl Vm {
} }
Ok(Flow::Normal) Ok(Flow::Normal)
} }
I::Resume0 => self.do_resume(|vm| vm.resume_pc), I::Resume0 => {
I::ResumeNext => self.do_resume(|vm| { self.restore_debug_handler();
self.do_resume(|vm| vm.resume_pc)
}
I::ResumeNext => {
self.restore_debug_handler();
self.do_resume(|vm| {
let f = vm.frames.last().unwrap(); let f = vm.frames.last().unwrap();
vm.next_stmt_pc(f.proc, vm.resume_pc) vm.next_stmt_pc(f.proc, vm.resume_pc)
}), })
}
I::ResumeLabel(t) => self.do_resume(move |_| t as usize), I::ResumeLabel(t) => self.do_resume(move |_| t as usize),
I::RaiseError => { I::RaiseError => {
let code = self.pop_i16()?; let code = self.pop_i16()?;

View File

@@ -28,6 +28,9 @@ pub struct ProjectCompiler {
parsed: Vec<Parsed>, parsed: Vec<Parsed>,
products: Vec<Product>, products: Vec<Product>,
pub stats: CompileStats, pub stats: CompileStats,
/// Vollständige Imports im Cache halten, damit spätere Debugkommandos sie binden können.
pub debug_symbols: bool,
debug_maps: Vec<DebugMap>,
} }
#[derive(Debug, Default, Clone, Copy)] #[derive(Debug, Default, Clone, Copy)]
pub struct CompileStats { pub struct CompileStats {
@@ -45,6 +48,7 @@ struct Parsed {
} }
struct Product { struct Product {
module: Module, module: Module,
debug_module: Module,
catalog: FormCatalog, catalog: FormCatalog,
code: CompiledModule, code: CompiledModule,
commons: Vec<tb_frontend::hir::HCommon>, commons: Vec<tb_frontend::hir::HCommon>,
@@ -211,6 +215,9 @@ impl ProjectCompiler {
import_declarations(&mut key, &parsed, &exports, Some(&self.parsed[index].names)); import_declarations(&mut key, &parsed, &exports, Some(&self.parsed[index].names));
let mut module = module.clone(); let mut module = module.clone();
import_declarations(&mut module, &parsed, &exports, None); import_declarations(&mut module, &parsed, &exports, None);
if self.debug_symbols {
key = module.clone();
}
if let Some(product) = self if let Some(product) = self
.products .products
.iter() .iter()
@@ -230,6 +237,7 @@ impl ProjectCompiler {
commons.push(hir.commons.clone()); commons.push(hir.commons.clone());
products.push(Product { products.push(Product {
module: key, module: key,
debug_module: module,
catalog: catalog.clone(), catalog: catalog.clone(),
code, code,
commons: hir.commons, commons: hir.commons,
@@ -246,11 +254,12 @@ impl ProjectCompiler {
&& !products.iter().any(|n| n.module.name == p.module.name) && !products.iter().any(|n| n.module.name == p.module.name)
}); });
self.products.extend(products); self.products.extend(products);
let mut result = link(name, parts, &parsed, &commons).map_err(|error| { let (mut result, maps) = link(name, parts, &parsed, &commons).map_err(|error| {
let mut errors = vec![error]; let mut errors = vec![error];
locate_diagnostics(&mut errors, &sources); locate_diagnostics(&mut errors, &sources);
errors errors
})?; })?;
self.debug_maps = maps;
result.sources = sources; result.sources = sources;
let objects = FormCatalog { let objects = FormCatalog {
objects: result.objects.clone(), objects: result.objects.clone(),
@@ -546,7 +555,7 @@ fn link(
mut parts: Vec<CompiledModule>, mut parts: Vec<CompiledModule>,
ast: &[Module], ast: &[Module],
module_commons: &[Vec<tb_frontend::hir::HCommon>], module_commons: &[Vec<tb_frontend::hir::HCommon>],
) -> Result<CompiledModule, Diagnostic> { ) -> Result<(CompiledModule, Vec<DebugMap>), Diagnostic> {
let at = |pos, message| Diagnostic { let at = |pos, message| Diagnostic {
file: None, file: None,
pos, pos,
@@ -625,6 +634,7 @@ fn link(
main.name = name.into(); main.name = name.into();
let mut initializers = Vec::new(); let mut initializers = Vec::new();
let mut bodies = Vec::new(); let mut bodies = Vec::new();
let mut debug_maps = Vec::new();
let mut common: HashMap<_, (u16, tb_frontend::hir::HCommon)> = HashMap::new(); let mut common: HashMap<_, (u16, tb_frontend::hir::HCommon)> = HashMap::new();
for (module_id, part) in parts.iter_mut().enumerate() { for (module_id, part) in parts.iter_mut().enumerate() {
let mut types = Vec::new(); let mut types = Vec::new();
@@ -715,6 +725,11 @@ fn link(
}; };
globals.push(id); globals.push(id);
} }
debug_maps.push(DebugMap {
globals: globals.clone(),
types: types.clone(),
procs: proc_maps[module_id].clone(),
});
let string_offset = result.strings.len(); let string_offset = result.strings.len();
if string_offset + part.strings.len() >= u16::MAX as usize { if string_offset + part.strings.len() >= u16::MAX as usize {
return Err(at( return Err(at(
@@ -831,5 +846,232 @@ fn link(
} }
} }
} }
Ok(result) Ok((result, debug_maps))
}
/// Ephemeral symbol/link context, deliberately not part of TBC serialization.
#[derive(Clone)]
pub struct DebugCompiler {
modules: Vec<(Module, FormCatalog, DebugMap)>,
symbols: tb_frontend::sema::DebugSymbols,
slots: Vec<u16>,
error: Option<String>,
}
#[derive(Clone)]
struct DebugMap {
globals: Vec<u16>,
types: Vec<u16>,
procs: Vec<u16>,
}
#[derive(Clone)]
pub struct DebugCode {
pub procedure: crate::bytecode::ProcCode,
pub strings: Vec<std::rc::Rc<str>>,
pub expression: bool,
}
impl ProjectCompiler {
pub fn debug_compiler(&self) -> DebugCompiler {
let modules: Vec<_> = self
.parsed
.iter()
.zip(&self.debug_maps)
.filter_map(|(parsed, map)| {
self.products
.iter()
.find(|p| p.module.name == parsed.module.name)
.map(|p| (p.debug_module.clone(), p.catalog.clone(), map.clone()))
})
.collect();
let mut symbols = tb_frontend::sema::DebugSymbols::default();
let mut slots = Vec::new();
let mut error = None;
for (ast, catalog, map) in &modules {
if let Some(hir) = tb_frontend::sema::lower_with_forms(ast, catalog).0 {
if symbols.udts.len() + hir.udts.len() >= u16::MAX as usize
|| symbols.globals.len() + hir.globals.len() >= u16::MAX as usize
{
error = Some("Debug-Symboltabelle überschreitet die 16-Bit-Slotgrenze".into());
break;
}
let offset = symbols.udts.len() as u16;
for mut udt in hir.udts {
udt.name = format!("<Debug:{}!{}>", ast.name, udt.name);
for (_, ty) in &mut udt.fields {
if let HTy::Udt(id) = ty {
*id += offset;
}
}
symbols.udts.push(udt);
}
for (mut var, slot) in hir.globals.into_iter().zip(&map.globals) {
var.name = format!("{}!{}", ast.name, var.name);
if let HTy::Udt(id) = &mut var.ty {
*id += offset;
}
symbols.globals.push(var);
slots.push(*slot);
}
}
}
DebugCompiler {
modules,
symbols,
slots,
error,
}
}
}
impl DebugCompiler {
pub fn compile(
&self,
module: u16,
procedure: &str,
text: &str,
expression: bool,
) -> Result<DebugCode, String> {
if let Some(error) = &self.error {
return Err(error.clone());
}
let (ast, catalog, map) = self
.modules
.get(module as usize)
.ok_or("Kein Debug-Quellkontext für dieses Kompilat")?;
let name = if procedure == "<main>" {
&ast.name
} else {
procedure.rsplit('!').next().unwrap_or(procedure)
};
if map.globals.len() + self.symbols.globals.len() >= u16::MAX as usize
|| map.types.len() + self.symbols.udts.len() >= u16::MAX as usize
{
return Err("Debug-Kontext überschreitet die 16-Bit-Slotgrenze".into());
}
let mut symbols = self.symbols.clone();
symbols.original_globals = map.globals.len();
let (mut hir, debug) = tb_frontend::sema::lower_debug(
ast, catalog, name, text, expression, symbols,
)
.map_err(|e| {
e.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
})?;
hir.procs.push(debug);
let mut globals = map.globals.clone();
globals.extend(&self.slots);
let mut types = map.types.clone();
for (_, _, mapping) in &self.modules {
types.extend(&mapping.types);
}
let mut compiled = codegen::compile(&hir);
let mut proc = compiled.procs.pop().unwrap();
proc.module = module;
for ty in &mut proc.locals_init {
remap_type(ty, &types);
}
if let Some(ty) = &mut proc.ret_ty {
remap_signature(ty, &types);
}
for i in &mut proc.code {
use Instr::*;
match i {
LoadGlobal(id) | StoreGlobal(id) | MakeRefGlobal(id) => {
*id = *globals
.get(*id as usize)
.ok_or("Unbekannter globaler Slot")?
}
LoadArr(global, id, _, ty) => {
if *global {
*id = globals[*id as usize];
}
remap_type(ty, &types);
}
Call(id, _) => *id = map.procs[*id as usize],
PushUdtId(id) => *id = types[*id as usize],
_ => {}
}
}
Ok(DebugCode {
procedure: proc,
strings: compiled.strings,
expression,
})
}
}
impl DebugCompiler {
pub(crate) fn same_control_context(
&self,
module: u16,
procedure: &str,
from: SourcePos,
to: SourcePos,
) -> bool {
let Some((ast, _, _)) = self.modules.get(module as usize) else {
return false;
};
let body = if procedure == "<main>" {
&ast.body
} else {
let Some(proc) = ast.procs.iter().find(|p| {
p.sig
.name
.eq_ignore_ascii_case(procedure.rsplit('!').next().unwrap_or(procedure))
}) else {
return false;
};
&proc.body
};
let a = control_path(body, from);
let b = control_path(body, to);
a.is_some() && a == b
}
}
/// Structured arms supplement bytecode loop ranges: ELSE and CASE are distinct
/// even when their forward jump would otherwise look like a top-level GOTO.
fn control_path(body: &[Stmt], target: SourcePos) -> Option<Vec<(SourcePos, usize)>> {
for stmt in body {
let pos = tb_frontend::sema::stmt_pos(stmt);
if pos == target {
return Some(vec![]);
}
let mut children: Vec<(&[Stmt], Option<SourcePos>)> = Vec::new();
match stmt {
Stmt::If {
then_body,
elseifs,
else_body,
..
} => {
children.push((then_body, None));
for (_, body, pos) in elseifs {
children.push((body, Some(*pos)));
}
if let Some(body) = else_body {
children.push((body, None));
}
}
Stmt::Select { arms, .. } => {
for arm in arms {
children.push((&arm.body, Some(arm.pos)));
}
}
Stmt::For { body, end_pos, .. }
| Stmt::DoLoop { body, end_pos, .. }
| Stmt::While { body, end_pos, .. } => children.push((body, Some(*end_pos))),
_ => {}
}
for (index, (body, entry)) in children.into_iter().enumerate() {
if let Some(mut path) = if entry == Some(target) {
Some(vec![])
} else {
control_path(body, target)
} {
path.insert(0, (pos, index));
return Some(path);
}
}
}
None
} }

View File

@@ -0,0 +1,420 @@
use tb_frontend::{forms::FormCatalog, source::SourceUnit};
use tb_runtime::{host::CaptureHost, value::Value};
use tb_vm::{
interp::{RunEvent, Vm},
project::ProjectCompiler,
};
fn vm(source: &str) -> Vm {
let mut compiler = ProjectCompiler::default();
let code = compiler
.compile(
"TEST",
&[SourceUnit::new("TEST", "test.bas", source)],
&FormCatalog::default(),
&[],
)
.unwrap();
let mut vm = Vm::new(code);
vm.debug.enabled = true;
vm.debug.compiler = Some(compiler.debug_compiler());
vm
}
#[test]
fn break_continue_loop_and_statement_steps() {
let mut vm = vm("FOR i% = 1 TO 2\nx% = x% + 1: y% = y% + 1\nNEXT\nEND\n");
let mut host = CaptureHost::default();
assert!(vm.add_source_breakpoint(0, 0, 2));
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 2 });
assert!(matches!(vm.inspect("x%"), Some(Value::Int(0))));
vm.set_step(true);
assert_eq!(vm.run(&mut host), RunEvent::Stepped { line: 2 });
assert!(matches!(vm.inspect("x%"), Some(Value::Int(1))));
vm.set_step(false);
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 2 });
assert!(matches!(vm.inspect("y%"), Some(Value::Int(1))));
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("x%"), Some(Value::Int(2))));
}
#[test]
fn watches_immediate_byref_and_errors_preserve_continuation() {
let mut vm=vm("DIM SHARED x AS INTEGER\nx = 3\nCALL work(x)\nEND\nSUB work(n AS INTEGER)\nDIM a(1 TO 2) AS INTEGER\na(1)=n\nn=n+1\nEND SUB\nSUB bump(n AS INTEGER)\nn=n+5\nEND SUB\n");
let mut host = CaptureHost::default();
assert!(vm.add_source_breakpoint(0, 0, 8));
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 8 });
let location = vm.debug_location().unwrap();
let frame = location.frame;
for _ in 0..3 {
assert!(matches!(
vm.evaluate_watch(frame, "a(1) + CINT(2.5)").unwrap(),
Value::Int(5)
));
assert!(vm.evaluate_watch(frame, "RND").is_err());
assert_eq!(vm.debug_location().unwrap(), location);
}
assert!(vm.start_immediate(frame, "n =").is_err());
vm.start_immediate(frame, "n = 10: CALL bump(n): PRINT n")
.unwrap();
assert_eq!(vm.run(&mut host), RunEvent::Stopped { line: 8 });
assert_eq!(vm.debug_location().unwrap(), location);
assert!(matches!(vm.inspect("n"), Some(Value::Int(15))));
vm.start_immediate(frame, "ERROR 5").unwrap();
assert_eq!(vm.run(&mut host), RunEvent::Stopped { line: 8 });
assert!(vm.debug.immediate_error.is_some());
assert_eq!(vm.debug_location().unwrap(), location);
vm.clear_source_breakpoints();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("x"), Some(Value::Int(16))));
}
#[test]
fn immediate_handler_resume_and_break_on_errors() {
let mut vm = vm("ON ERROR GOTO handler\nx%=1\nx%=x%+1\nEND\nhandler:\ny%=ERR\nRESUME NEXT\n");
let mut host = CaptureHost::default();
vm.add_source_breakpoint(0, 0, 3);
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 3 });
let location = vm.debug_location().unwrap();
vm.debug.break_errors = true;
vm.start_immediate(location.frame, "ERROR 5: x%=10")
.unwrap();
assert!(matches!(vm.run(&mut host), RunEvent::Breakpoint { .. }));
assert_eq!(vm.debug.error.as_ref().unwrap().code, 5);
assert_eq!(vm.run(&mut host), RunEvent::Stopped { line: 3 });
assert_eq!(vm.debug_location().unwrap(), location);
assert!(matches!(vm.inspect("y%"), Some(Value::Int(5))));
assert!(matches!(vm.inspect("x%"), Some(Value::Int(10))));
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("x%"), Some(Value::Int(11))));
}
#[test]
fn step_over_recursive_frames_history_and_safe_jump() {
let mut vm=vm("CALL rec(2)\nx%=1\nx%=2\nEND\nSUB rec(n AS INTEGER)\nIF n>0 THEN\nCALL rec(n-1)\nEND IF\nPRINT n\nEND SUB\n");
let mut host = CaptureHost::default();
vm.debug.history_on = true;
vm.add_source_breakpoint(0, 0, 7);
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 7 });
let first = vm.debug_location().unwrap();
vm.clear_source_breakpoints();
vm.step_over();
assert!(matches!(vm.run(&mut host), RunEvent::Stepped { .. }));
assert_eq!(vm.debug_location().unwrap().frame, first.frame);
vm.set_step(false);
vm.run_to_cursor(0, 0, 2).unwrap();
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 2 });
let at = vm.debug_location().unwrap();
vm.set_next_statement(at.frame, 0, 3).unwrap();
let moved = vm.debug_location().unwrap();
assert!(vm.set_next_statement(at.frame, 0, 7).is_err());
assert_eq!(vm.debug_location().unwrap(), moved);
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("x%"), Some(Value::Int(2))));
assert!(!vm.debug.history.is_empty());
}
#[test]
fn include_identity_qualified_globals_udts_and_older_frames() {
use tb_frontend::source::SourceSegment;
let mut compiler = ProjectCompiler::default();
let first=SourceUnit{name:"FIRST".into(),segments:vec![SourceSegment{file:"one.bi".into(),first_line:1,text:"'one\nx%=1\n".into()},SourceSegment{file:"two.bi".into(),first_line:1,text:"'two\nx%=2\nCALL rec(2)\nEND\nSUB rec(n AS INTEGER)\nDIM a(1 TO 2) AS INTEGER\na(1)=n\nIF n>0 THEN CALL rec(n-1)\nPRINT n\nEND SUB\n".into()}]};
let second=SourceUnit::new("SECOND","second.bas","TYPE pair\nvalue AS INTEGER\nEND TYPE\nDIM SHARED r AS pair\nDIM SHARED a(1 TO 2) AS INTEGER\n");
let code = compiler
.compile("P", &[first, second], &FormCatalog::default(), &[])
.unwrap();
let mut vm = Vm::new(code);
vm.debug.compiler = Some(compiler.debug_compiler());
vm.add_source_breakpoint(0, 1, 2);
let mut host = CaptureHost::default();
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 2 });
assert_eq!(vm.current_file(), "two.bi");
assert!(matches!(vm.inspect("x%"), Some(Value::Int(1))));
vm.clear_source_breakpoints();
vm.add_source_breakpoint(0, 1, 9);
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 9 });
let frames = vm.debug_frames();
assert!(frames.len() >= 4);
let top = vm.debug_location();
assert!(matches!(
vm.evaluate_watch(frames[0].id, "a(1)").unwrap(),
Value::Int(0)
));
assert!(matches!(
vm.evaluate_watch(frames[1].id, "a(1)").unwrap(),
Value::Int(1)
));
assert!(matches!(
vm.evaluate_watch(frames[1].id, "SECOND!r.value + SECOND!a(1)")
.unwrap(),
Value::Int(0)
));
assert_eq!(vm.debug_location(), top);
vm.start_immediate(frames[1].id, "SECOND!r.value=7: SECOND!a(1)=8")
.unwrap();
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
assert!(matches!(
vm.evaluate_watch(frames[1].id, "SECOND!r.value + SECOND!a(1)")
.unwrap(),
Value::Int(15)
));
}
#[test]
fn watchpoint_history_ring_and_error_differential() {
use tb_vm::interp::DebugWatch;
let mut vm = vm("FOR i%=1 TO 1500\nx%=i%\nNEXT\nEND\n");
vm.debug.enabled = true;
vm.debug.history_on = true;
vm.debug.watches.push(DebugWatch {
expression: "x%=2".into(),
condition: true,
frame: None,
value: Err("pending".into()),
});
let mut host = CaptureHost::default();
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 3 });
assert!(matches!(vm.inspect("x%"), Some(Value::Int(2))));
vm.debug.watches.clear();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert_eq!(vm.debug.history.len(), 1024);
let source="10 ON ERROR GOTO handler\n20 ERROR 6\n30 x%=x%+1\n40 END\nhandler:\ne%=ERR: l&=ERL\nRESUME NEXT\n";
let run = |break_errors| {
let mut vm = self::vm(source);
vm.debug.break_errors = break_errors;
let mut host = CaptureHost::default();
if break_errors {
assert!(matches!(vm.run(&mut host), RunEvent::Breakpoint { .. }));
assert_eq!(vm.debug.error.as_ref().unwrap().code, 6);
assert!(matches!(vm.inspect("e%"), Some(Value::Int(0))));
}
assert_eq!(vm.run(&mut host), RunEvent::Ended);
format!(
"{:?}{:?}{:?}",
vm.inspect("e%"),
vm.inspect("l&"),
vm.inspect("x%")
)
};
assert_eq!(run(true), run(false));
}
#[test]
fn gosub_step_over_breakpoint_priority_and_temporary_target_cleanup() {
let mut vm = vm("GOSUB work\nx%=4\nEND\nwork:\nx%=1\nx%=2\nRETURN\n");
let mut host = CaptureHost::default();
vm.set_step(true);
assert_eq!(vm.run(&mut host), RunEvent::Stepped { line: 1 });
vm.step_over();
vm.add_source_breakpoint(0, 0, 6);
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 6 });
vm.clear_source_breakpoints();
vm.set_step(false);
assert_eq!(vm.run(&mut host), RunEvent::Ended);
let mut vm = self::vm("FOR i%=1 TO 2\nx%=x%+1\ny%=y%+1\nNEXT\nEND\n");
vm.add_source_breakpoint(0, 0, 2);
vm.run_to_cursor(0, 0, 3).unwrap();
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 2 });
vm.clear_source_breakpoints();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
}
#[test]
fn immediate_wait_interrupt_nested_error_end_and_run() {
use tb_vm::interp::PollResult;
let source="x%=1\nx%=x%+1\nEND\nSUB ask(n AS INTEGER)\nINPUT n\nEND SUB\nSUB fail()\nON LOCAL ERROR GOTO handler\nERROR 6\nEXIT SUB\nhandler:\nx%=ERR\nRESUME NEXT\nEND SUB\nSUB done()\nEND\nEND SUB\nSUB restart()\nRUN\nEND SUB\n";
let mut vm = vm(source);
let mut host = CaptureHost::default();
vm.add_source_breakpoint(0, 0, 2);
vm.run(&mut host);
let location = vm.debug_location().unwrap();
vm.start_immediate(location.frame, "CALL ask(x%)").unwrap();
assert!(matches!(
vm.poll(&mut host, 1000),
PollResult::Waiting { .. }
));
assert!(vm.immediate_active());
vm.rt.abbruch = true;
assert!(matches!(
vm.poll(&mut host, 1000),
PollResult::Event(RunEvent::Interrupted { .. })
));
vm.rt.abbruch = false;
host.tippe("9\r");
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
assert_eq!(vm.debug_location().unwrap(), location);
assert!(matches!(vm.inspect("x%"), Some(Value::Int(9))));
vm.start_immediate(location.frame, "CALL fail()").unwrap();
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
assert_eq!(vm.debug_location().unwrap(), location);
vm.start_immediate(location.frame, "CALL restart()")
.unwrap();
assert_eq!(
vm.run(&mut host),
RunEvent::Restart {
program: None,
line: None
}
);
let mut vm = self::vm(source);
vm.add_source_breakpoint(0, 0, 2);
vm.run(&mut host);
let frame = vm.debug_location().unwrap().frame;
vm.start_immediate(frame, "CALL done()").unwrap();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(vm.is_terminated());
}
#[test]
fn form_event_breakpoint_and_step_over_keep_the_event_frame() {
use tb_frontend::forms::ObjectClass;
let mut catalog = FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
let source="Form1.Show\nx%=1\nEND\nSUB Form_Load()\nCALL bump\nPRINT 4\nEND SUB\nSUB bump()\nPRINT 3\nEND SUB\n";
let mut compiler = ProjectCompiler::default();
let code = compiler
.compile(
"Form1",
&[SourceUnit::new("Form1", "form1.frm", source)],
&catalog,
&[],
)
.unwrap();
let mut vm = Vm::new(code);
vm.debug.compiler = Some(compiler.debug_compiler());
vm.add_source_breakpoint(0, 0, 5);
let mut host = CaptureHost::default();
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 5 });
let frame = vm.debug_location().unwrap().frame;
vm.clear_source_breakpoints();
vm.step_over();
assert_eq!(vm.run(&mut host), RunEvent::Stepped { line: 6 });
assert_eq!(vm.debug_location().unwrap().frame, frame);
vm.add_source_breakpoint(0, 0, 2);
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 2 });
vm.clear_source_breakpoints();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
}
#[test]
fn set_next_rejects_loop_gosub_handler_and_pending_operations_atomically() {
let source = "x%=1\nFOR i%=1 TO 2\nx%=x%+1\nNEXT\nGOSUB worker\nEND\nworker:\nx%=3\nRETURN\n";
let mut vm = vm(source);
let mut host = CaptureHost::default();
vm.add_source_breakpoint(0, 0, 1);
vm.run(&mut host);
let before = vm.debug_location().unwrap();
for line in [3, 4, 8] {
assert!(
vm.set_next_statement(before.frame, 0, line).is_err(),
"line {line}"
);
assert_eq!(vm.debug_location().unwrap(), before);
}
vm.clear_source_breakpoints();
vm.add_source_breakpoint(0, 0, 8);
vm.run(&mut host);
let before = vm.debug_location().unwrap();
assert!(vm.set_next_statement(before.frame, 0, 6).is_err());
assert_eq!(vm.debug_location().unwrap(), before);
}
#[test]
fn same_line_calls_and_immediate_do_not_retrigger_a_line_breakpoint() {
let mut vm = vm("x%=1: CALL bump(x%): x%=x%+1\nEND\nSUB bump(n AS INTEGER)\nn=n+1\nEND SUB\n");
vm.debug.history_on = true;
vm.add_source_breakpoint(0, 0, 1);
let mut host = CaptureHost::default();
assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 1 });
assert!(vm.debug.history.is_empty());
let frame = vm.debug_location().unwrap().frame;
vm.start_immediate(frame, "x%=5").unwrap();
assert_eq!(vm.run(&mut host), RunEvent::Stopped { line: 1 });
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("x%"), Some(Value::Int(3))));
assert_eq!(
vm.debug
.history
.iter()
.filter(|p| p.procedure == 0 && p.line == 1)
.count(),
3
);
}
#[test]
fn immediate_handler_exit_and_cached_new_imports_are_safe() {
let mut compiler = ProjectCompiler::default();
compiler.debug_symbols = true;
let first = SourceUnit::new("ONE", "one.bas", "x%=1\nSTOP\nx%=x%+1\nEND\n");
let old = SourceUnit::new("TWO", "two.bas", "SUB oldproc()\nPRINT 1\nEND SUB\n");
compiler
.compile("P", &[first.clone(), old], &FormCatalog::default(), &[])
.unwrap();
let second = SourceUnit::new(
"TWO",
"two.bas",
"SUB newproc(n AS INTEGER)\nn=n+5\nEND SUB\n",
);
let code = compiler
.compile("P", &[first, second], &FormCatalog::default(), &[])
.unwrap();
let mut vm = Vm::new(code);
vm.debug.compiler = Some(compiler.debug_compiler());
let mut host = CaptureHost::default();
vm.run(&mut host);
let frame = vm.debug_location().unwrap().frame;
assert_eq!(vm.execution_location().unwrap().line, 3);
vm.start_immediate(frame, "CALL newproc(x%)").unwrap();
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
assert!(matches!(vm.inspect("ONE!x%"), Some(Value::Int(6))));
let mut vm=self::vm("CALL work\nEND\nSUB work()\nON LOCAL ERROR GOTO handler\nx%=1\nx%=2\nEXIT SUB\nhandler:\nx%=7\nEND SUB\n");
vm.add_source_breakpoint(0, 0, 6);
vm.run(&mut host);
let before = vm.debug_location().unwrap();
vm.start_immediate(before.frame, "ERROR 5").unwrap();
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
assert_eq!(vm.debug_location().unwrap(), before);
assert!(matches!(vm.inspect("x%"), Some(Value::Int(7))));
vm.clear_source_breakpoints();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
}
#[test]
fn set_next_rejects_else_and_case_entry_but_allows_peer_statements() {
let source="x%=1\nIF x% THEN\nx%=2\nELSE\nx%=3\nEND IF\nSELECT CASE x%\nCASE 1\nx%=4\nCASE ELSE\nx%=5\nEND SELECT\nx%=6\nEND\n";
let mut vm = vm(source);
let mut host = CaptureHost::default();
vm.add_source_breakpoint(0, 0, 1);
vm.run(&mut host);
let before = vm.debug_location().unwrap();
for line in [3, 5, 9, 11] {
assert!(
vm.set_next_statement(before.frame, 0, line).is_err(),
"line {line}"
);
assert_eq!(vm.debug_location().unwrap(), before);
}
vm.set_next_statement(before.frame, 0, 13).unwrap();
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("x%"), Some(Value::Int(6))));
}
#[test]
fn watches_never_initialize_arrays_or_run_user_functions_and_keep_selected_context() {
let mut vm=vm("DIM SHARED changed%\nSTOP\nx%=a%(2)\nCALL rec(2)\nEND\nFUNCTION impure%()\nchanged%=changed%+1\nimpure%=changed%\nEND FUNCTION\nSUB rec(n AS INTEGER)\nIF n>0 THEN CALL rec(n-1)\nPRINT n\nEND SUB\n");
let mut host = CaptureHost::default();
vm.run(&mut host);
let frame = vm.debug_location().unwrap().frame;
let frames = format!("{:?}", vm.debug_frames());
for _ in 0..4 {
assert!(vm.evaluate_watch(frame, "a%(2)").is_err());
assert!(matches!(vm.inspect("a%"), Some(Value::Empty)));
assert!(vm.evaluate_watch(frame, "impure%").is_err());
assert!(matches!(vm.inspect("changed%"), Some(Value::Int(0))));
assert!(vm.evaluate_watch(frame, "1 / 0").is_err());
assert!(matches!(
vm.evaluate_watch(frame, "ERR").unwrap(),
Value::Lng(0)
));
}
assert_eq!(format!("{:?}", vm.debug_frames()), frames);
vm.add_source_breakpoint(0, 0, 12);
vm.run(&mut host);
let frames = vm.debug_frames();
let older = frames[1].id;
vm.clear_source_breakpoints();
vm.debug.watches.push(tb_vm::interp::DebugWatch {
expression: "n=1".into(),
condition: true,
frame: Some(older),
value: Err("pending".into()),
});
assert!(matches!(vm.run(&mut host), RunEvent::Breakpoint { .. }));
assert!(matches!(vm.debug.watches[0].value, Ok(Value::Int(-1))));
}

View File

@@ -127,7 +127,7 @@ Library-Symbole/Aufrufvertrag und der native Verbraucher-Nachweis stehen
explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt
keines der beiden Exportziele. keines der beiden Exportziele.
## Befehlsabdeckung nach Change 05 ## Befehlsabdeckung nach Change 06
Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der
folgenden Changes sind sichtbar deaktiviert und zeigen einen folgenden Changes sind sichtbar deaktiviert und zeigen einen
@@ -139,16 +139,16 @@ Output-Fokus unterbricht Ctrl+C sofort die Sitzung, ohne eine BASIC-Taste einzur
| --- | --- | --- | | --- | --- | --- |
| File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Print (UTF-8-Datei), Shell, Exit | — | | File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Print (UTF-8-Datei), Shell, Exit | — |
| Edit | Undo, Cut/Copy/Paste/Clear, New Sub/Function, Event Procedures | — | | Edit | Undo, Cut/Copy/Paste/Clear, New Sub/Function, Event Procedures | — |
| View | Code/Form-Auswahl, Prozedurauswahl, Included File/Lines, Diagnostics, Designer Menu Bar/Value-Fokus, Grid Lines, Output Screen | Next Statement: 06 | | View | Code/Form-Auswahl, Prozedurauswahl, Included File/Lines, Diagnostics, Designer Menu Bar/Value-Fokus, Grid Lines, Output Screen, Next Statement | — |
| Search | Find, Selected Text, Repeat Last Find, Change | — | | Search | Find, Selected Text, Repeat Last Find, Change | — |
| Run | Start/Restart/Continue/Pause, COMMAND$, wirksame Startdatei und beide Make-Dialoge | Native Erzeugung: Phase 6 | | Run | Start/Restart/Continue/Pause, COMMAND$, wirksame Startdatei und beide Make-Dialoge | Native Erzeugung: Phase 6 |
| Debug | Vollständiges Menü und Kürzelzuordnung, einschließlich F7/F8/F9/F10 und History | Watches, Watchpoints, Trace, History, Breakpoints, Break on Errors, Set Next Statement: 06 | | Debug | Breakpoints, Schritte, Calls, Watches/Watchpoints, Direktfenster, Trace/History, Break on Errors und Set Next Statement | — |
| Options | Display, Set Paths, Right Mouse, Save, Syntax Checking einschließlich Zeilenprüfung | Kontext-Hilfe: 07 | | Options | Display, Set Paths, Right Mouse, Save, Syntax Checking einschließlich Zeilenprüfung | Kontext-Hilfe: 07 |
| Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Debug/Calls/Immediate-Inhalte: 06; Hilfeinhalt: 07 | | Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Hilfeinhalt: 07 |
| Designer Tools/Window | Vollständige Toolbox einschließlich Spin, Formliste, Color Palette, Menu Design Window | — | | Designer Tools/Window | Vollständige Toolbox einschließlich Spin, Formliste, Color Palette, Menu Design Window | — |
| Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 | | Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 |
Editor, inkrementelle Übersetzung, Ausführung und Formulardesigner sind umgesetzt. Die gesamte Editor, inkrementelle Übersetzung, Ausführung, Formulardesigner und Debugger sind umgesetzt. Die gesamte
Phase-5-Abnahme erfolgt in Change 08. Phase-5-Abnahme erfolgt in Change 08.
## Programme ausführen ## Programme ausführen
@@ -384,3 +384,88 @@ benötigen weiter ein separates Textziel. Die Designer-Tests prüfen alle
Klassen, Geometrie, Properties, Maus/Tastatur, Transaktionen, Ereignisse und Klassen, Geometrie, Properties, Maus/Tastatur, Transaktionen, Ereignisse und
Speicher-Roundtrips. Der CLI-Integrationstest erzeugt eine FRM über dieselben Speicher-Roundtrips. Der CLI-Integrationstest erzeugt eine FRM über dieselben
Designeraktionen und übersetzt sie mit dem echten `tbc build`-Prozess. Designeraktionen und übersetzt sie mit dem echten `tbc build`-Prozess.
## Debugger und Direktfenster
F9 setzt oder entfernt eine Haltepunktmarke in der physischen Quelldatei.
Für Includes zuerst **View → Included File** öffnen; die schreibgeschützte
Gesamtansicht Included Lines ist kein eindeutiger Haltepunktort. Bei mehrfach
verwendeten Includes gehört die Marke zum ausgewählten Projektmodul.
**●** am Fensterrand bezeichnet einen gebundenen Haltepunkt, **○** eine
inaktive Marke. Textänderungen führen die Marken mit; eine gelöschte
Anweisung deaktiviert ihre Marke. Beim Start werden nur ausführbare Grenzen
der neuen Kompilatrevision gebunden. **Clear All Breakpoints** entfernt alle.
Ein Zeilenbreakpoint hält vor der ersten ausgeführten Anweisung der Zeile;
mehrere durch Doppelpunkt getrennte Anweisungen können einzeln geschritten werden.
| Aktion | Taste / Menü | Verhalten |
| --- | --- | --- |
| Continue | F5 | Führt die angehaltene Anweisung einmal aus; ein späterer Schleifendurchlauf kann erneut halten. |
| Single Step | F8 | Nächste BASIC-Anweisungsgrenze, auch innerhalb eines Aufrufs. |
| Procedure Step | F10 | Nächste Anweisung im ursprünglichen Frame nach dem Aufruf, einschließlich Rekursion und GOSUB; Breakpoints und Fehlerhalte haben Vorrang. |
| Run to Cursor | F7 | Temporäres Laufziel; ein anderer Halt oder das Ende beendet den Cursorlauf. |
| Next Statement | View → Next Statement | Zeigt den tatsächlichen aktuellen Ausführungsort. |
| Calls | Window → Calls | Aufrufkette mit Prozedur und Quelldatei/-zeile. Maus oder Pfeile wählen einen Inspektionsrahmen; der aktive VM-Frame bleibt unverändert. |
**Add Watch**, **Instant Watch / Shift+F9** und **Watchpoint** nehmen einen
BASIC-Ausdruck entgegen. **Window → Debug** zeigt Werte mit Typen und
Diagnosen pro Eintrag. Delete Watch/Delete All Watch löschen Definitionen;
das Debug-Fenster bearbeitet keinen Quelltext. Lokale Variablen, Arrays und
UDT-Felder beziehen sich auf den in Calls gewählten erreichbaren Frame.
`Modul!Variable` qualifiziert einen globalen Namen, beispielsweise
`Daten!satz.Preis` oder `Daten!preise(2)`. Arithmetik und Konvertierungen
stammen aus demselben Frontend und Bytecode wie normale BASIC-Anweisungen.
Automatische Watches sind nebenwirkungsfrei: Literale, Operatoren, vorhandene
Variablen, Array-/Feldzugriffe, String-/Mathematik-Intrinsics, FORMAT$, Arraygrenzen
sowie ERR/ERL sind erlaubt. Benutzerprozeduren, RND, Eingabe-/Datei-/Uhrfunktionen
und andere nicht als rein nachgewiesene Aufrufe liefern eine Diagnose.
Ein noch nicht initialisiertes Array wird durch einen Watch nicht angelegt.
Solche Aufrufe sind über das Direktfenster möglich. Watchpoints benötigen eine
numerische BASIC-Bedingung und halten an der nächsten Anweisungsgrenze, an der
sie wahr ist. Continue überspringt die aktuelle Prüfung einmal; bleibt die
Bedingung wahr, kann die folgende Grenze erneut halten. Ein fehlerhafter
Eintrag löst keinen Halt aus.
**Window → Immediate** öffnet das Direktfenster. Im Break-Modus führt Enter
PRINT, Zuweisungen, Prozeduraufrufe und ERROR aus; Doppelpunkte verbinden
mehrere Anweisungen. Vor der Ausführung wird das gesamte Kommando übersetzt.
Syntax-/Typfehler verändern weder Variablen noch Fortsetzungspunkt.
Erfolgreiche Kommandos kehren zur ursprünglichen Pause zurück. PRINT ist im
Direktfenster und im gemeinsamen Programmbildschirm sichtbar. Zuweisungen,
BYREF-Aufrufe und normale Laufzeitnebenwirkungen bleiben wirksam. Laufzeitfehler
nutzen die bestehenden Handler und RESUME-Regeln; unbehandelte Direktfehler
werden angezeigt, während die ursprüngliche Pause erhalten bleibt.
Run → Pause/Ctrl+Break unterbricht auch ein laufendes Direktkommando. Ein
wartender Prozeduraufruf bleibt fortsetzbar; seine Programmeingaben werden über
Window → Output zugestellt. F5 setzt zunächst das noch aktive Kommando fort;
nach dessen Abschluss setzt ein weiteres F5 das ursprüngliche Programm fort.
END/SYSTEM beenden die Sitzung, RUN startet über den normalen Sitzungsreset.
Direktcode hat den Quellort `<Immediate>` und wird nie im Projekt gespeichert.
**Trace On** verfolgt die aktuelle Quellstelle. **History On** hält höchstens
1.024 tatsächlich begonnene Anweisungen; die ältesten Einträge fallen heraus.
Shift+F8/Shift+F10 navigieren zurück/vorwärts. Die Meldung **HISTORIE** bezeichnet
nur eine historische Quellstelle: Watchwerte gehören weiterhin zum aktuellen
Halt. Variablen, Dateien, Uhr und andere Nebenwirkungen werden nicht zurückgesetzt.
Ein Neustart verwirft Frameauswahl, Verlauf, Instant-Watch und alte Werte,
erhält Watchdefinitionen und bindet Breakpoints neu.
**Break on Errors** hält nach der Handlerwahl und nötigem Stack-Unwinding vor
der ersten Handleranweisung. Die Anzeige nennt Fehlernummer, Ursache und
Handlerziel; Continue führt den Handler einmal aus. ERR/ERL und RESUME behalten
ihren normalen Vertrag. Unbehandelte Programmfehler beenden die Sitzung mit
Fehlerdiagnose; unbehandelte Direktfehler sind ausdrücklich als solche sichtbar.
**Set Next Statement** ist nur an einer freien Anweisungsgrenze im aktiven
Frame zulässig. Das Ziel muss ausführbar sein und denselben Kontroll- und
Schleifenkontext besitzen. Fremde Frames, Eintritt in Handler/GOSUB-Bereiche,
aktive GOSUB-/Fehlerbehandlung, Warteoperationen und ein aktives Direktkommando
werden mit Begründung abgewiesen. Die Prüfung erfolgt vor jeder Änderung.
Ein erlaubter Sprung führt keine übersprungenen Anweisungen aus.
Cursorziele setzen den unveränderten Quellstand der pausierten Sitzung voraus;
nach Änderungen zuerst neu starten. F5 kann weiterhin nach ausdrücklicher
Auswahl das gekennzeichnete alte Kompilat fortsetzen; seine Watches verwenden
die ursprünglichen Symbole und Slots.

View File

@@ -33,7 +33,7 @@ Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Text
| 03 | [Editor und inkrementelle Übersetzung](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile | | 03 | [Editor und inkrementelle Übersetzung](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile |
| 04 | [Ausführung und Output](../2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck | | 04 | [Ausführung und Output](../2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck |
| 05 | [Formular-Designer](../2026-09-06-phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode | | 05 | [Formular-Designer](../2026-09-06-phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode |
| 06 | [Debugger und Direktfenster](../../phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte | | 06 | [Debugger und Direktfenster](../2026-09-06-phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte |
| 07 | [Hilfesystem](../../phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation | | 07 | [Hilfesystem](../../phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation |
| 08 | [Integration und Phasenabnahme](../../phase-5-08-integration-und-phasenabnahme/proposal.md) | 0107 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status | | 08 | [Integration und Phasenabnahme](../../phase-5-08-integration-und-phasenabnahme/proposal.md) | 0107 | Durchgängiger Arbeitsablauf, Befehlsabdeckung und nachgewiesener PLAN-Status |

View File

@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-vm/interp.rs, Debug-Quellorte und Frontend/Codegen für Ausdrücke und Direktfenster; tb-ide-Debugfenster. Keine zweite Auswertungsmaschine, kein Zeitreise-Debugger. tb-vm/interp.rs, Debug-Quellorte und Frontend/Codegen für Ausdrücke und Direktfenster; tb-ide-Debugfenster. Keine zweite Auswertungsmaschine, kein Zeitreise-Debugger.
**Abhängigkeiten:** [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md). **Abhängigkeiten:** [phase-5-03-editor-und-inkrementelle-uebersetzung](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -1,27 +1,27 @@
## 1. Quellgenaue Halte und Schritte ## 1. Quellgenaue Halte und Schritte
- [ ] 1.1 Eine dateigenaue Breakpoint-Bindung ergänzen und bestehende Modul-/Zeilen-API erhalten; Tests mit zwei Includes derselben physischen Zeile im selben Modul halten nur im gewählten Include. - [x] 1.1 Eine dateigenaue Breakpoint-Bindung ergänzen und bestehende Modul-/Zeilen-API erhalten; Tests mit zwei Includes derselben physischen Zeile im selben Modul halten nur im gewählten Include.
- [ ] 1.2 Editierbare Breakpoint-Marken mit Neubindung an Kompilatrevisionen integrieren; Tests für Einfügen/Löschen und nicht ausführbare Zeilen belegen richtige Verschiebung oder sichtbare Deaktivierung. - [x] 1.2 Editierbare Breakpoint-Marken mit Neubindung an Kompilatrevisionen integrieren; Tests für Einfügen/Löschen und nicht ausführbare Zeilen belegen richtige Verschiebung oder sichtbare Deaktivierung.
- [ ] 1.3 Continue-/Single-Step-Verhalten und F9/Clear All im IDE-Dispatcher anbinden; Tests belegen Ausführung einer angehaltenen Anweisung genau einmal und erneuten Halt bei späterem Schleifendurchlauf. - [x] 1.3 Continue-/Single-Step-Verhalten und F9/Clear All im IDE-Dispatcher anbinden; Tests belegen Ausführung einer angehaltenen Anweisung genau einmal und erneuten Halt bei späterem Schleifendurchlauf.
- [ ] 1.4 Step Over mit Frameidentität sowie Run to Cursor mit temporärem Halt implementieren; Tests prüfen Rekursion, GOSUB, Eventhandler, vorrangige Breakpoints und Aufräumen des Cursorziels. - [x] 1.4 Step Over mit Frameidentität sowie Run to Cursor mit temporärem Halt implementieren; Tests prüfen Rekursion, GOSUB, Eventhandler, vorrangige Breakpoints und Aufräumen des Cursorziels.
- [ ] 1.5 Calls-Fenster und Next Statement mit getrenntem Inspektions-/Ausführungsrahmen anbinden; ein Rekursionstest navigiert in ältere Frames, ohne den aktiven PC zu verändern. - [x] 1.5 Calls-Fenster und Next Statement mit getrenntem Inspektions-/Ausführungsrahmen anbinden; ein Rekursionstest navigiert in ältere Frames, ohne den aktiven PC zu verändern.
## 2. Ausdrücke und Direktfenster ## 2. Ausdrücke und Direktfenster
- [ ] 2.1 Bestehenden Frontend-/Codegen-Pfad für temporäre Debug-Ausdrücke mit Slotbindung im gewählten Frame zugänglich machen; Tests vergleichen Zahlenkonvertierung, Modulqualifizierung, Arrays und UDT-Felder mit normalem BASIC-Code. - [x] 2.1 Bestehenden Frontend-/Codegen-Pfad für temporäre Debug-Ausdrücke mit Slotbindung im gewählten Frame zugänglich machen; Tests vergleichen Zahlenkonvertierung, Modulqualifizierung, Arrays und UDT-Felder mit normalem BASIC-Code.
- [ ] 2.2 Add/Instant/Delete/Delete All Watch und das read-only Debug Window integrieren; Tests zeigen Fehler pro Eintrag und nachweislich unveränderten Zustand nach wiederholter Beobachtung. - [x] 2.2 Add/Instant/Delete/Delete All Watch und das read-only Debug Window integrieren; Tests zeigen Fehler pro Eintrag und nachweislich unveränderten Zustand nach wiederholter Beobachtung.
- [ ] 2.3 Reinheitsprüfung für automatische Ausdrücke und bedingte Watchpoints implementieren; Tests lehnen zustandsverändernde Aufrufe ab und halten nach einer falsch→wahr-Änderung an der nächsten Grenze. - [x] 2.3 Reinheitsprüfung für automatische Ausdrücke und bedingte Watchpoints implementieren; Tests lehnen zustandsverändernde Aufrufe ab und halten nach einer falsch→wahr-Änderung an der nächsten Grenze.
- [ ] 2.4 Immediate Window mit temporärem Debug-Eintritt für PRINT und Zuweisungen implementieren; Tests belegen sichtbare Werteänderung und erhaltenen ursprünglichen Fortsetzungspunkt. - [x] 2.4 Immediate Window mit temporärem Debug-Eintritt für PRINT und Zuweisungen implementieren; Tests belegen sichtbare Werteänderung und erhaltenen ursprünglichen Fortsetzungspunkt.
- [ ] 2.5 Prozeduraufrufe einschließlich BYREF, ERROR und Runtimefehler im Direktfenster integrieren; Tests prüfen Handler/RESUME, Unterbrechen, wartende Aufrufe sowie END/RUN-Übergänge ohne verlorenen oder doppelten Frame. - [x] 2.5 Prozeduraufrufe einschließlich BYREF, ERROR und Runtimefehler im Direktfenster integrieren; Tests prüfen Handler/RESUME, Unterbrechen, wartende Aufrufe sowie END/RUN-Übergänge ohne verlorenen oder doppelten Frame.
## 3. Historie, Fehler und Ausführungsziel ## 3. Historie, Fehler und Ausführungsziel
- [ ] 3.1 Trace, begrenzte History und Shift+F8/Shift+F10 mit eindeutiger historischer Markierung implementieren; Tests belegen Ringgrenze, Navigation und unveränderte aktuelle Variablen/Dateieffekte. - [x] 3.1 Trace, begrenzte History und Shift+F8/Shift+F10 mit eindeutiger historischer Markierung implementieren; Tests belegen Ringgrenze, Navigation und unveränderte aktuelle Variablen/Dateieffekte.
- [ ] 3.2 Break on Errors zwischen Handlerwahl und erster Handleranweisung einführen; Differentialtests mit deaktivierter Option belegen identische ERR/ERL-, Unwinding- und RESUME-Folge nach Continue. - [x] 3.2 Break on Errors zwischen Handlerwahl und erster Handleranweisung einführen; Differentialtests mit deaktivierter Option belegen identische ERR/ERL-, Unwinding- und RESUME-Folge nach Continue.
- [ ] 3.3 Zulässige Ziele für Set Next Statement aus Statement-/Kontrollkontexten ableiten und vor Mutation prüfen; Tests erlauben gleichrangige Ziele und lehnen fremde Frames, Schleifen-/GOSUB-/Fehlerkontexte ohne Teiländerung ab. - [x] 3.3 Zulässige Ziele für Set Next Statement aus Statement-/Kontrollkontexten ableiten und vor Mutation prüfen; Tests erlauben gleichrangige Ziele und lehnen fremde Frames, Schleifen-/GOSUB-/Fehlerkontexte ohne Teiländerung ab.
- [ ] 3.4 Debuggerzustand an Neustart und Quellrevisionen binden; Tests belegen neu gebundene Breakpoints, erhaltene Watchdefinitionen und verworfene Frames/History/Werte. - [x] 3.4 Debuggerzustand an Neustart und Quellrevisionen binden; Tests belegen neu gebundene Breakpoints, erhaltene Watchdefinitionen und verworfene Frames/History/Werte.
## 4. Abnahme und Bedienung ## 4. Abnahme und Bedienung
- [ ] 4.1 Die vollständigen Debugger-Menüs und F7/F8/F9/F10/Shift+F9 über App-Ereignisse prüfen; dieselben Szenarien müssen auch mit Include- und Formularereigniscode korrekt anhalten. - [x] 4.1 Die vollständigen Debugger-Menüs und F7/F8/F9/F10/Shift+F9 über App-Ereignisse prüfen; dieselben Szenarien müssen auch mit Include- und Formularereigniscode korrekt anhalten.
- [ ] 4.2 Debuggerverträge und die expliziten Grenzen für Watches, History und Set Next Statement dokumentieren; verification.md weist alle Szenarien sowie bestandene VM-/Fehlerhandler-/CLI-Regressionen und Format-/Clippy-Prüfung nach. - [x] 4.2 Debuggerverträge und die expliziten Grenzen für Watches, History und Set Next Statement dokumentieren; verification.md weist alle Szenarien sowie bestandene VM-/Fehlerhandler-/CLI-Regressionen und Format-/Clippy-Prüfung nach.

View File

@@ -0,0 +1,94 @@
# Verification Report: phase-5-06-debugger-und-direktfenster
Stand: 2026-09-06. Proposal, Design, alle 16 Aufgaben und alle sieben
Anforderungen mit elf Szenarien wurden gegen die Implementierung geprüft.
## Ergebnis
| Dimension | Ergebnis |
| --- | --- |
| Completeness | 16/16 Aufgaben, 7/7 Anforderungen |
| Correctness | 11/11 Spec-Szenarien durch ausführbare Prüfungen abgedeckt |
| Coherence | Gemeinsamer Compiler, dieselbe VM, vorhandene Dokument-/Sitzungspfade |
| Offene Befunde | 0 CRITICAL, 0 WARNING, 0 SUGGESTION |
## Anforderungen und Szenarien
VM-Nachweise: `crates/tb-vm/tests/debugger.rs` (14 Integrationstests).
IDE-Nachweise: `crates/tb-ide/tests/debugger.rs` (6 Integrationstests mit
App-Kommandos, Tastatur/Maus, TestBackend und tatsächlicher Sitzung).
| Requirement / Scenario | Implementierung | Ausführbarer Nachweis |
| --- | --- | --- |
| Dateigenaue Breakpoints / Zwei Includes mit gleicher Zeile | `Vm::add_source_breakpoint`, `statement_locations`, `debug_boundary`; IDE-Marken mit Dokument-/Ursprungsmodul-ID | VM `include_identity_qualified_globals_udts_and_older_frames`; IDE `includes_and_deleted_marks_do_not_bind_to_foreign_statements`: nur zweites Include, richtiger Quellort und unveränderter Wert vor der Anweisung. |
| Dateigenaue Breakpoints / Einfügen oberhalb einer Marke | `documents::remap_breakpoints`, gemeinsame Edit-/Undo-Transaktionen, `bind_debug_breakpoints` | IDE `dispatcher_marks_edits_rebind_and_readonly_watches` und `includes_and_deleted_marks_do_not_bind_to_foreign_statements`: Verschieben, Neubinden, Kommentarzeile, Löschen und Undo; inaktive Marken sind sichtbar. |
| Schrittsteuerung und Calls / Rekursion und Prozedurschritt | `step_over` mit Frame-ID und GOSUB-Tiefe; `debug_frames`, `select_debug_frame`; F8/F10 | VM `step_over_recursive_frames_history_and_safe_jump`, `gosub_step_over_breakpoint_priority_and_temporary_target_cleanup`, `form_event_breakpoint_and_step_over_keep_the_event_frame`; IDE `calls_procedure_step_cursor_history_and_restart`, `calls_mouse_inspection_instant_key_delete_and_error_window`, `form_handler_keys_and_old_revision_cursor_guard`. |
| Schrittsteuerung und Calls / Temporäres Laufziel | `run_to_cursor`, `cancel_motion`, IDE-Pause und Sitzungsende | VM `gosub_step_over_breakpoint_priority_and_temporary_target_cleanup`; IDE F7 in `calls_procedure_step_cursor_history_and_restart`. Dauerhafte Breakpoints bleiben erhalten; der unterbrochene Cursorlauf wird nicht später erneut wirksam. |
| Watches und Watchpoints / Beobachtung im lokalen Kontext | `sema::lower_debug`, `DebugCompiler`, `evaluate_watch`; ursprüngliche Slots und Link-Mappings, getrennte Frameauswahl | VM `watches_immediate_byref_and_errors_preserve_continuation`, `include_identity_qualified_globals_udts_and_older_frames`, `watches_never_initialize_arrays_or_run_user_functions_and_keep_selected_context`: CINT, lokale Arrays, UDT-Felder, qualifizierte Globals, ältere rekursive Frames, keine Auto-DIM-/Funktions-/ERR-Nebenwirkungen. IDE Calls-Mausauswahl, Shift+F9, Watchfehler und Löschdialog. |
| Watches und Watchpoints / Watchpoint wird wahr | `evaluate_condition`, `check_watchpoints`, gebundener erreichbarer Inspektionsframe; nur aktive Einträge werden ausgewertet | VM `watchpoint_history_ring_and_error_differential`; IDE `immediate_wait_run_transition_watchpoint_and_trace_are_session_owned`: Halt direkt hinter der Zuweisung, aktualisierter Wert. Nicht numerische oder fehlerhafte Bedingungen lösen keinen Halt aus. |
| Direktfenster / Wert ändern und fortsetzen | `start_immediate`, temporärer Frame mit referenzierten Originalslots, gemeinsame VM-Ausführung und `finish_immediate` | VM `watches_immediate_byref_and_errors_preserve_continuation`: lokale Zuweisung, BYREF-Aufruf, PRINT, Original-PC und fortgesetzter geänderter Wert. IDE `dispatcher_marks_edits_rebind_and_readonly_watches` bedient den tatsächlichen Eingabepfad. |
| Direktfenster / Fehler im Direktfenster | Bestehendes `handle_error`, abgegrenzter Debug-Eintritt, Handler-/RESUME-Rückkehr und Quellort `<Immediate>` | VM `immediate_handler_resume_and_break_on_errors`, `immediate_wait_interrupt_nested_error_end_and_run`, `immediate_handler_exit_and_cached_new_imports_are_safe`: ERROR, lokaler/geerbter Handler, RESUME NEXT, Handlerende, INPUT/Warten, Interrupt, END und RUN. IDE Direktfehleranzeige, Warte-/F5-Folge und echter RUN-Sitzungsreset. |
| Trace und Ausführungshistorie / Historie betrachten | `record_debug_execution`, 1.024-Einträge-Ring, Trace-Quellnavigation, H-/I-/Ausführungsmarken | VM `watchpoint_history_ring_and_error_differential`, `same_line_calls_and_immediate_do_not_retrigger_a_line_breakpoint`; IDE `calls_procedure_step_cursor_history_and_restart`: zweimal zurück, vorwärts, unveränderter PC und unveränderte aktuelle Watchwerte, Reset. History führt nur Quellorte, keine rückspielbaren Zustände. |
| Break on Errors / Behandelter Fehler | Halt nach Handlerwahl/Unwinding an dessen erster Statement-Grenze; gesicherte Ursache, ERR/ERL und RESUME-PC | VM `watchpoint_history_ring_and_error_differential` vergleicht den Lauf mit und ohne Halt; `immediate_handler_resume_and_break_on_errors` und `immediate_wait_interrupt_nested_error_end_and_run` prüfen geerbte/lokale Handler und Fortsetzungsrahmen. |
| Sicheres Set Next Statement / Gültiger und ungültiger Sprung | Statement-Tabelle, strukturierte AST-Kontrollpfade und Bytecode-Schleifenbereiche; vollständige Prüfung vor PC-Änderung | VM `step_over_recursive_frames_history_and_safe_jump`, `set_next_rejects_loop_gosub_handler_and_pending_operations_atomically`, `set_next_rejects_else_and_case_entry_but_allows_peer_statements`: gleichrangiger Sprung wirksam; fremde Prozedur, Schleife, ELSE/CASE und GOSUB abgewiesen ohne Teiländerung. IDE `form_handler_keys_and_old_revision_cursor_guard` prüft den Revisionsschutz. |
## Coherence
- Ein Interpreter: Watches benutzen den regulären Lexer/Parser, Sema, Codegen
und die vorhandenen VM-Instruktionen. Es gibt keine zweite Arithmetik oder
Konvertierungsschicht. Reinheit wird vor Ausführung am erzeugten Bytecode
geprüft; automatische Beobachtung startet weder Host-Polling noch Fehlerhandler.
- Originale Modul-/Zeilen-Breakpoint-APIs bleiben erhalten. Neue Debuggerpfade
sind opt-in; der CLI-Runtimepfad, das TBC-Format und seine Quelldaten bleiben
unverändert. IDE-Compiler behalten vollständige Importkontexte für spätere
Direktaufrufe auch nach inkrementeller Übersetzung.
- Dokumentmarken verwenden dieselben Edits und Undo-Einheiten wie der Editor.
Formularname und Dateiname dürfen verschieden sein. Cursorziele werden nur
gegen die unveränderte pausierte Kompilatrevision akzeptiert.
- Direktkommandos benutzen den bestehenden kooperativen Poll-/Warte-/RUN-Pfad.
Handlerende entfernt keine Locals eines pausierten Frames. Syntaxfehler
mutieren nichts; Laufzeitnebenwirkungen bleiben ausdrücklich wirksam.
- Die Grenzen für reine Watches, History ohne Zeitreise sowie konservatives
Set Next Statement sind in `docs/ide-bedienung.md` dokumentiert. Die
gemeinsame Phase-5-Abnahme bleibt Change 08; native Erzeugung bleibt Phase 6.
## Behobene Verifikationsbefunde
- Zeilenbreakpoints merken ihren letzten Eintritt pro Frame. Ein zurückkehrender
Aufruf oder Direktcode löst keinen zweiten Halt auf derselben Doppelpunktzeile
aus; spätere Schleifendurchläufe halten weiterhin.
- History nimmt eine Grenze erst beim tatsächlichen Ausführen auf. Die vor
einem Direktkommando noch nicht ausgeführte Grenze bleibt danach erhalten.
- Break on Errors markiert den ersten Handler-Einstieg, nicht einen veralteten
Fehler-PC nach dem Unwinding. Ereigniszustellung verschiebt diesen Halt nicht.
- Direktfehler benutzen eine gesicherte Kommando-Rückkehr; auch Handlerende
ohne RESUME erhält die ursprünglichen Frame-Slots und den Fortsetzungspunkt.
- ELSE-/CASE-Arme bekommen eigene Kontrollkontexte. Ein gleicher Frame allein
reicht nicht zur Freigabe von Set Next Statement.
- Include-Marken behalten ihr Ursprungsmodul; Formularmarken benutzen die
Dokumentidentität statt der Annahme, Formularname und Dateiname seien gleich.
- Automatische Watches führen kein Auto-DIM oder Benutzerfunktionen aus;
Fehler verändern ERR nicht. Watchpoints berücksichtigen ihren ausgewählten
erreichbaren Frame und zeigen Fehler pro Eintrag.
- Gewöhnliches STOP/Pause erhält den bisherigen Output-Fokus. Spezifische
Debuggerhalte navigieren zum Quelltext; die vorhandenen Ausgabetests bleiben
unverändert. Die alte Platzhalter-Erwartung „folgt in 06“ wurde durch die
Verfügbarkeit des implementierten Kommandos ersetzt.
## Abschlussprüfungen
- `cargo test --workspace`: 587 bestanden, 0 fehlgeschlagen, 2 bestehende
Ignore-Fälle (Golden-Output-Generator und externe VBDOS-Referenzsuite).
Enthalten sind alle VM-/Fehlerhandler-/CLI-/IDE-Regressionen sowie die
20 neuen Debugger-Integrationstests.
- `cargo clippy --workspace --all-targets -- -D warnings`: bestanden.
- `cargo fmt --all -- --check`: bestanden.
- `git diff --check`: bestanden.
- `openspec validate --all --strict`: 23/23 gültig; bestehende INFO-Hinweise
zur Länge von Requirement-Texten sind keine Validierungsfehler.
Keine angeforderte Prüfdimension wurde ausgelassen. Die Prüfungen sind
reproduzierbar und headless; Maus-/Tasten- und Renderingpfade wurden über
App-Ereignisse und TestBackend geprüft. Es bestehen keine offenen Befunde.
Der Change ist vollständig umgesetzt und zur separaten Synchronisierung und
Archivierung bereit.

View File

@@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise. tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise.
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../archive/2026-09-06-phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md). **Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../archive/2026-09-06-phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../archive/2026-09-06-phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -0,0 +1,72 @@
# ide-debugger Specification
## Purpose
Stellt quellgenaues Debuggen innerhalb derselben TBVM bereit, einschließlich Ausführungssteuerung, Variablenbeobachtung, Direktfenster und nachvollziehbarer Fehlerhalte.
## Requirements
### Requirement: Dateigenaue Breakpoints
F9 und Toggle Breakpoint SHALL Haltepunkte nach Modul, physischer Quelldatei und Zeile setzen und entfernen. Nur ausführbare Anweisungsgrenzen SHALL binden; nicht bindbare Marken SHALL sichtbar als inaktiv gelten. Clear All SHALL alle entfernen. Continue SHALL die angehaltene Anweisung genau einmal ausführen, bevor derselbe Halt erneut ausgelöst werden kann.
#### Scenario: Zwei Includes mit gleicher Zeile
- **WHEN** ein Modul zwei Includes mit ausführbarer Zeile 2 verwendet und nur die zweite Datei einen Haltepunkt besitzt
- **THEN** hält ausschließlich die Anweisung der zweiten Datei an, mit richtigem Quellort und unverändertem Zustand vor ihrer Ausführung
#### Scenario: Einfügen oberhalb einer Marke
- **WHEN** oberhalb eines Breakpoints Zeilen eingefügt und danach neu übersetzt wird
- **THEN** wird die Marke mit dem Dokumentedit verschoben und an der neuen Revision neu gebunden oder sichtbar deaktiviert; sie trifft keine fremde Anweisung
### Requirement: Schrittsteuerung und Calls
F8 SHALL eine BASIC-Anweisung einschließlich Betreten eines Aufrufs ausführen. F10 SHALL den aktuellen Aufruf bis zur nächsten Anweisung desselben Aufrufrahmens ausführen; Breakpoints und Fehlerhalte SHALL dabei Vorrang behalten. F7 SHALL temporär bis zum Cursor laufen, ohne vorhandene Breakpoints zu verlieren. Calls SHALL die Aufrufkette mit Prozedur und Quellort zeigen; Next Statement SHALL zur tatsächlichen Ausführungsstelle navigieren.
#### Scenario: Rekursion und Prozedurschritt
- **WHEN** F10 an einem rekursiven Aufruf verwendet wird
- **THEN** endet der Schritt im ursprünglichen Aufrufrahmen hinter dem Aufruf und nicht in einem gleichnamigen rekursiven Rahmen
#### Scenario: Temporäres Laufziel
- **WHEN** Run to Cursor zuvor durch einen anderen Breakpoint unterbrochen oder das Programm beendet wird
- **THEN** bleiben dauerhafte Breakpoints erhalten und ein beendeter oder abgebrochener Cursorlauf hinterlässt keinen ungewollten temporären Haltepunkt
### Requirement: Watches und Watchpoints
Add Watch, Instant Watch/Shift+F9, Watchpoint, Delete Watch und Delete All Watch SHALL typisierte BASIC-Ausdrücke im ausgewählten erreichbaren Aufrufrahmen einschließlich Modulqualifizierung, Arrayelementen und UDT-Feldern anbieten. Automatische Beobachtung SHALL den Programmzustand unverändert lassen; nicht nebenwirkungsfrei auswertbare Ausdrücke SHALL mit Grund abgewiesen werden. Watchpoints SHALL bei wahrer Bedingung an einer Anweisungsgrenze halten. Auswertungsfehler SHALL den jeweiligen Eintrag markieren und die Sitzung erhalten.
#### Scenario: Beobachtung im lokalen Kontext
- **WHEN** eine rekursive Prozedur pausiert und ein anderer erreichbarer Rahmen in Calls gewählt wird
- **THEN** zeigen dessen Watches die Werte dieses Rahmens einschließlich lokaler Arrays und qualifizierter Globals, ohne den Ausführungsrahmen zu wechseln
#### Scenario: Watchpoint wird wahr
- **WHEN** eine Zuweisung eine zuvor falsche Watchpoint-Bedingung wahr macht
- **THEN** hält die Ausführung an der nächsten sicheren Anweisungsgrenze mit aktualisierten Werten
### Requirement: Direktfenster im pausierten Kontext
Window→Immediate SHALL ein bedarfsweise sichtbares Direktfenster öffnen. Im Break-Modus SHALL es PRINT, Zuweisungen, Prozeduraufrufe und ERROR n über dieselben Sprach-, Typ- und Laufzeitregeln wie Projektcode ausführen. Änderungen an erreichbaren Variablen SHALL danach im Programm sichtbar sein. Ungültige Eingaben SHALL eine Diagnose liefern, ohne den angehaltenen Fortsetzungspunkt zu verlieren. Während eines laufenden Kommandos SHALL Unterbrechen möglich bleiben.
#### Scenario: Wert ändern und fortsetzen
- **WHEN** im lokalen Break-Kontext eine Zuweisung und ein BYREF-Prozeduraufruf eingegeben werden und anschließend F5 folgt
- **THEN** verwendet das Programm die geänderten Werte und setzt am vorherigen Programmpunkt fort; temporärer Direktcode bleibt kein Teil des gespeicherten Projekts
#### Scenario: Fehler im Direktfenster
- **WHEN** ERROR n oder ein fehlerhaftes Direktkommando ausgeführt wird
- **THEN** wird der Fehler nach den geltenden Laufzeitregeln angezeigt beziehungsweise behandelt, die IDE bleibt bedienbar und der Zustand der pausierten Sitzung ist eindeutig erkennbar
### Requirement: Trace und Ausführungshistorie
Trace On SHALL die aktuelle Quellstelle während des Laufs sichtbar verfolgen. History On SHALL ausgeführte Quellstellen in einem begrenzten Verlauf halten; Shift+F8/Shift+F10 SHALL darin rückwärts/vorwärts navigieren. Historische Auswahl SHALL sich von der aktuellen Ausführungsstelle unterscheiden und MUST NOT Variablen, Uhr, Dateien oder andere Nebenwirkungen zurückspulen. Ein Neustart SHALL den Verlauf zurücksetzen.
#### Scenario: Historie betrachten
- **WHEN** nach drei Zuweisungen zweimal rückwärts in History navigiert wird
- **THEN** zeigt die IDE frühere Quellorte als Historie, während Variablenwerte und nächster realer Ausführungspunkt unverändert bleiben
### Requirement: Break on Errors
Bei aktivem Break on Errors SHALL die IDE auch vor der ersten Anweisung eines ausgewählten Fehlerhandlers anhalten und Fehlernummer, verursachenden Quellort und Handlerziel zeigen. Continue SHALL die bestehende Fehlerbehandlung genau einmal fortführen; ohne Debuggeroption SHALL das bisherige Verhalten unverändert bleiben. Unbehandelte Fehler SHALL als solche unterscheidbar bleiben.
#### Scenario: Behandelter Fehler
- **WHEN** ein Laufzeitfehler einen ON-ERROR-Handler auswählt und Break on Errors aktiv ist
- **THEN** hält die IDE vor dem Handlercode; nach Continue stimmen ERR, ERL, Unwinding und RESUME-Verhalten mit dem Lauf ohne Debuggerhalt überein
### Requirement: Sicheres Set Next Statement
Set Next Statement SHALL im Break-Modus den nächsten Ausführungspunkt auf eine ausführbare Stelle desselben aktiven Prozedurrahmens setzen können, sofern Kontroll-, Schleifen-, GOSUB- und Fehlerzustand kompatibel bleiben. Ein unzulässiger Wechsel SHALL begründet und vollständig ohne Zustandsänderung abgewiesen werden. Erfolgreiches Setzen SHALL keine übersprungenen Anweisungen ausführen.
#### Scenario: Gültiger und ungültiger Sprung
- **WHEN** zunächst eine gleichrangige Folgeanweisung und danach eine Stelle in einer anderen Prozedur als nächstes Statement gewählt wird
- **THEN** ist die erste Wahl wirksam und die zweite abgewiesen, ohne den ersten gültigen Fortsetzungspunkt zu verändern