Phase 4: Formularmodell und Objektsprache

This commit is contained in:
2026-09-04 17:30:32 +02:00
parent 5cf5a6582c
commit 54488b5b67
35 changed files with 7195 additions and 627 deletions

View File

@@ -11,6 +11,7 @@ tb-frontend.workspace = true
thiserror.workspace = true
log.workspace = true
tb-runtime.workspace = true
tb-ui.workspace = true
[[bench]]
name = "compile"

View File

@@ -8,10 +8,12 @@
use std::fmt;
use std::rc::Rc;
use tb_frontend::forms::{FormObject, ObjectClass};
use tb_frontend::hir::HEventProc;
use tb_runtime::value::{TypeInit, UdtLayout};
pub const TBC_MAGIC: &[u8; 4] = b"TBC\0";
pub const TBC_VERSION: u16 = 1;
pub const TBC_VERSION: u16 = 3;
/// Vergleichsoperator (Operand der `Cmp*`-Instruktionen).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -52,7 +54,10 @@ impl fmt::Display for LoadError {
match self {
LoadError::BadMagic => write!(f, "Keine .tbc-Datei (Magic fehlt)"),
LoadError::Version(v) => {
write!(f, "Unbekannte .tbc-Formatversion {v} (unterstützt: {TBC_VERSION})")
write!(
f,
"Unbekannte .tbc-Formatversion {v} (unterstützt: {TBC_VERSION})"
)
}
LoadError::Corrupt(what) => write!(f, "Beschädigte .tbc-Datei ({what})"),
}
@@ -303,6 +308,14 @@ instrs! {
0xB1 RetProc;
0xB2 RetFn;
0xB3 CallBuiltin(a: u16, b: u8);
0xB4 LoadObjectProperty(a: u16, b: u16, c: bool);
0xB5 StoreObjectProperty(a: u16, b: u16, c: bool);
0xB6 PushObject(a: u16, b: bool);
0xB7 TypeOf(a: u8);
0xB8 ObjectMethod(a: u16, b: u16, c: u8);
0xB9 ObjectLoad(a: u16, b: bool, c: bool); // unload?, Index liegt auf Stack?
0xBA LoadDynamicObjectProperty(a: u16);
0xBB StoreDynamicObjectProperty(a: u16);
// 0xE0 — Ereignis-Traps (Sprachreferenz §8); Kennung vom Stack
0xE0 TrapDefine(a: u8, b: u32); // Quellenart, Sprungziel
@@ -375,6 +388,8 @@ pub struct CompiledModule {
pub data: Vec<DataItem>,
/// Sprungtabellen für `ON n GOTO/GOSUB`.
pub jump_tables: Vec<Vec<u32>>,
pub objects: Vec<FormObject>,
pub event_procs: Vec<HEventProc>,
}
fn w_string(out: &mut Vec<u8>, s: &str) {
@@ -458,6 +473,22 @@ impl CompiledModule {
}
sections.push((*b"JMPT", jmpt));
let mut objs = Vec::new();
objs.extend_from_slice(&(self.objects.len() as u32).to_le_bytes());
for o in &self.objects {
w_string(&mut objs, &o.name);
objs.push(o.class.id());
w_string(&mut objs, o.parent_form.as_deref().unwrap_or(""));
objs.push(o.array as u8);
}
objs.extend_from_slice(&(self.event_procs.len() as u32).to_le_bytes());
for e in &self.event_procs {
objs.extend_from_slice(&e.object.to_le_bytes());
w_string(&mut objs, &e.event);
objs.extend_from_slice(&e.proc.to_le_bytes());
}
sections.push((*b"OBJS", objs));
// Header + Abschnittstabelle
let mut out = Vec::new();
out.extend_from_slice(TBC_MAGIC);
@@ -567,7 +598,13 @@ impl CompiledModule {
for _ in 0..n_instr {
code.push(Instr::decode(&mut cr)?);
}
procs.push(ProcCode { name, n_params, locals_init, local_names, code });
procs.push(ProcCode {
name,
n_params,
locals_init,
local_names,
code,
});
}
let mut r = section(b"DATA")?;
@@ -591,6 +628,31 @@ impl CompiledModule {
jump_tables.push(t);
}
let mut r = section(b"OBJS")?;
let n = r.u32()? as usize;
let mut objects = Vec::with_capacity(n);
for _ in 0..n {
let name = r.string()?;
let class = ObjectClass::from_id(r.u8()?).ok_or(LoadError::Corrupt("Objektklasse"))?;
let parent = r.string()?;
let array = r.u8()? != 0;
objects.push(FormObject {
name,
class,
parent_form: (!parent.is_empty()).then_some(parent),
array,
});
}
let n = r.u32()? as usize;
let mut event_procs = Vec::with_capacity(n);
for _ in 0..n {
event_procs.push(HEventProc {
object: r.u16()?,
event: r.string()?,
proc: r.u16()?,
});
}
Ok(CompiledModule {
name,
option_base,
@@ -601,6 +663,8 @@ impl CompiledModule {
procs,
data,
jump_tables,
objects,
event_procs,
})
}
}
@@ -660,8 +724,13 @@ mod tests {
local_names: vec![],
code: vec![Instr::Stmt(1), Instr::PushStr(0), Instr::End],
}],
data: vec![DataItem { text: "1.5".into(), line: 3 }],
data: vec![DataItem {
text: "1.5".into(),
line: 3,
}],
jump_tables: vec![vec![4, 9]],
objects: vec![],
event_procs: vec![],
};
let bytes = m.to_tbc();
let back = CompiledModule::from_tbc(&bytes).unwrap();
@@ -687,6 +756,8 @@ mod tests {
procs: vec![],
data: vec![],
jump_tables: vec![],
objects: vec![],
event_procs: vec![],
};
let mut bytes = m.to_tbc();
bytes[4] = 0xFF; // Version hochsetzen

View File

@@ -43,9 +43,14 @@ pub fn compile(hir: &HirModule) -> CompiledModule {
data: hir
.data
.iter()
.map(|d| DataItem { text: d.text.clone(), line: d.line })
.map(|d| DataItem {
text: d.text.clone(),
line: d.line,
})
.collect(),
jump_tables: cg.jump_tables,
objects: hir.objects.clone(),
event_procs: hir.event_procs.clone(),
}
}
@@ -68,6 +73,7 @@ fn type_init(t: &HTy) -> TypeInit {
HTy::Str => TypeInit::Str,
HTy::FixedStr(n) => TypeInit::FixedStr(*n),
HTy::Udt(id) => TypeInit::Udt(*id),
HTy::Form | HTy::Control => TypeInit::Empty,
}
}
@@ -169,8 +175,8 @@ impl Codegen {
for (idx, label) in std::mem::take(&mut ctx.fixups) {
// Modulweites `ON ERROR GOTO` in einer Prozedur: das Label lebt
// im Modulrumpf, nicht im eigenen.
let modulweit = proc.kind != hir::HProcKind::Main
&& matches!(ctx.code[idx], Instr::OnErrorGoto(_));
let modulweit =
proc.kind != hir::HProcKind::Main && matches!(ctx.code[idx], Instr::OnErrorGoto(_));
let pc = if modulweit {
self.modul_label_pc
.get(label as usize)
@@ -241,6 +247,52 @@ impl Codegen {
HStmtKind::Assign { place, value } => {
self.store_place(ctx, place, |cg, ctx| cg.expr(ctx, value));
}
HStmtKind::SetObjectProperty {
object,
index,
property,
value,
} => {
if let Some(index) = index {
self.expr(ctx, index);
}
self.expr(ctx, value);
ctx.emit(Instr::StoreObjectProperty(
*object,
*property,
index.is_some(),
));
}
HStmtKind::SetDynamicObjectProperty {
object,
property,
value,
} => {
self.expr(ctx, object);
self.expr(ctx, value);
let property = self.pool(property);
ctx.emit(Instr::StoreDynamicObjectProperty(property));
}
HStmtKind::ObjectMethod {
object,
method,
args,
} => {
for arg in args {
self.expr(ctx, arg);
}
ctx.emit(Instr::ObjectMethod(*object, *method, args.len() as u8));
}
HStmtKind::ObjectLoad {
object,
index,
unload,
} => {
if let Some(index) = index {
self.expr(ctx, index);
}
ctx.emit(Instr::ObjectLoad(*object, *unload, index.is_some()));
}
HStmtKind::Print { items, trailing } => {
for item in items {
match item {
@@ -277,12 +329,21 @@ impl Codegen {
}
ctx.emit(Instr::Field(fields.len() as u8));
}
HStmtKind::LsetRset { rset, target, value } => {
HStmtKind::LsetRset {
rset,
target,
value,
} => {
self.make_ref(ctx, target);
self.expr(ctx, value);
ctx.emit(Instr::LsetRset(*rset));
}
HStmtKind::GetPut { put, file, recnum, var } => {
HStmtKind::GetPut {
put,
file,
recnum,
var,
} => {
self.expr(ctx, file);
if let Some(r) = recnum {
self.expr(ctx, r);
@@ -301,12 +362,19 @@ impl Codegen {
HTy::Udt(i) => (7, *i),
// Variable Strings: Länge erst zur Laufzeit.
HTy::Str => (8, 0),
HTy::Form | HTy::Control => (8, 0),
}
}
};
ctx.emit(Instr::GetPut(*put, recnum.is_some(), art, zusatz));
}
HStmtKind::Input { file, line_mode, prompt, question, targets } => {
HStmtKind::Input {
file,
line_mode,
prompt,
question,
targets,
} => {
if let Some(f) = file {
// Dateinummer zuerst, dann die Referenzen darüber.
self.expr(ctx, f);
@@ -506,7 +574,12 @@ impl Codegen {
}
}
HStmtKind::Restore(idx) => ctx.emit(Instr::Restore(*idx)),
HStmtKind::Dim { slot, elem, dims, redim } => {
HStmtKind::Dim {
slot,
elem,
dims,
redim,
} => {
for (lo, hi) in dims {
self.expr(ctx, lo);
self.expr(ctx, hi);
@@ -637,6 +710,36 @@ impl Codegen {
ctx.emit(Instr::PushStr(idx));
}
HExpr::Load(p) => self.load_place(ctx, p),
HExpr::ObjectProperty {
object,
index,
property,
..
} => {
if let Some(index) = index {
self.expr(ctx, index);
}
ctx.emit(Instr::LoadObjectProperty(
*object,
*property,
index.is_some(),
));
}
HExpr::DynamicObjectProperty { object, property } => {
self.expr(ctx, object);
let property = self.pool(property);
ctx.emit(Instr::LoadDynamicObjectProperty(property));
}
HExpr::ObjectRef { object, index, .. } => {
if let Some(index) = index {
self.expr(ctx, index);
}
ctx.emit(Instr::PushObject(*object, index.is_some()));
}
HExpr::TypeOf { value, class } => {
self.expr(ctx, value);
ctx.emit(Instr::TypeOf(class.id()));
}
HExpr::Conv { from, to, arg } => {
self.expr(ctx, arg);
emit_conv(ctx, *from, *to);
@@ -1276,7 +1379,12 @@ mod tests {
let m = compile_src("SUB Inc (x%)\nx% = x% + 1\nEND SUB\nn% = 1\nInc n%\nInc (n%)");
let code = main_code(&m);
assert!(code.contains(&Instr::MakeRefGlobal(0)));
assert!(code.iter().filter(|i| matches!(i, Instr::Call(1, 1))).count() == 2);
assert!(
code.iter()
.filter(|i| matches!(i, Instr::Call(1, 1)))
.count()
== 2
);
// Prozedurrumpf liest/schreibt über Referenz
let sub = &m.procs[1].code;
assert!(sub.contains(&Instr::LoadRef(0)));
@@ -1309,7 +1417,12 @@ mod tests {
let m = compile_src("DATA 1, 2\nREAD a%, b%\nRESTORE\nREAD c%");
let code = main_code(&m);
assert_eq!(m.data.len(), 2);
assert!(code.iter().filter(|i| matches!(i, Instr::ReadData(1))).count() == 3);
assert!(
code.iter()
.filter(|i| matches!(i, Instr::ReadData(1)))
.count()
== 3
);
assert!(code.contains(&Instr::Restore(0)));
assert!(code.contains(&Instr::ConvR8I2));
}
@@ -1318,7 +1431,9 @@ mod tests {
fn fehlerbehandlung_emit() {
let m = compile_src("ON ERROR GOTO H\nERROR 5\nEND\nH:\nRESUME NEXT");
let code = main_code(&m);
assert!(code.iter().any(|i| matches!(i, Instr::OnErrorGoto(t) if *t > 0)));
assert!(code
.iter()
.any(|i| matches!(i, Instr::OnErrorGoto(t) if *t > 0)));
assert!(code.contains(&Instr::RaiseError));
assert!(code.contains(&Instr::ResumeNext));
}

View File

@@ -17,6 +17,7 @@ use tb_runtime::errors::RuntimeError;
use tb_runtime::host::Host;
use tb_runtime::traps::Quelle;
use tb_runtime::value::{self, default_value, ArrayObj, RecordObj, TypeInit, Value, VarRef};
use tb_ui::forms::{FormEvent, FormsModel, PropertyValue, ShowResult};
/// Warum die VM die Kontrolle abgibt.
#[derive(Debug, Clone, PartialEq)]
@@ -24,12 +25,24 @@ pub enum RunEvent {
/// `END`, `SYSTEM` oder Programmende.
Ended,
/// `STOP` — VM-Zustand bleibt fortsetzbar (IDE: CONT).
Stopped { line: u32 },
Breakpoint { line: u32 },
Stepped { line: u32 },
Interrupted { line: u32 },
Stopped {
line: u32,
},
Breakpoint {
line: u32,
},
Stepped {
line: u32,
},
Interrupted {
line: u32,
},
/// Unbehandelter Laufzeitfehler.
Error { code: u16, line: u32, message: String },
Error {
code: u16,
line: u32,
message: String,
},
}
const F_STEP: u32 = 1;
@@ -58,6 +71,15 @@ struct Frame {
/// Satz würde dem Handler leere Modulvariablen zeigen. Sein `RETURN`
/// beendet den Handler (design.md, D2).
trap: Option<Quelle>,
waiting_form: Option<u16>,
pending_show: Option<u16>,
form_event: Option<FormEventReturn>,
}
#[derive(Clone, Copy)]
enum FormEventReturn {
Normal,
Unload(u16),
}
pub struct Vm {
@@ -84,6 +106,7 @@ pub struct Vm {
tick_zaehler: u32,
breakpoints: HashSet<u32>,
data_ptr: usize,
pub forms: FormsModel,
}
/// Quellenarten, wie der Codegenerator sie kodiert.
@@ -126,6 +149,7 @@ impl Vm {
.iter()
.map(|t| default_value(t, &module.udts))
.collect();
let forms = FormsModel::new(module.objects.clone(), 80, 25);
let mut vm = Vm {
globals,
locals: Vec::new(),
@@ -142,6 +166,7 @@ impl Vm {
tick_zaehler: 0,
breakpoints: HashSet::new(),
data_ptr: 0,
forms,
module,
};
// ISAM leitet das Satzlayout aus dem Typ der `OPEN`-Anweisung ab und
@@ -173,9 +198,156 @@ impl Vm {
last_stmt_pc: 0,
line: 0,
trap: None,
waiting_form: None,
pending_show: None,
form_event: None,
});
}
pub fn queue_form_event(&mut self, event: FormEvent) {
self.forms.queue(event);
}
fn form_value(v: PropertyValue) -> Value {
match v {
PropertyValue::Integer(v) => Value::Int(v as i16),
PropertyValue::Single(v) => Value::Sng(v),
PropertyValue::String(v) => Value::Str(Rc::from(v)),
PropertyValue::Boolean(v) => Value::Int(if v { -1 } else { 0 }),
PropertyValue::Object(v) => v
.map(|(object, index)| Value::Obj(object, index))
.unwrap_or(Value::Empty),
PropertyValue::IntegerArray(_) => Value::Empty,
}
}
fn property_value(
&self,
object: u16,
property: u16,
v: Value,
) -> Result<PropertyValue, RuntimeError> {
let class = self
.module
.objects
.get(object as usize)
.ok_or(RuntimeError(420))?
.class;
let spec = tb_frontend::forms::properties(class)
.get(property as usize)
.copied()
.ok_or(RuntimeError(422))?;
Ok(match (spec.ty, v) {
(tb_frontend::forms::PropertyType::Integer, Value::Int(v)) => {
PropertyValue::Integer(v as i32)
}
(tb_frontend::forms::PropertyType::Integer, Value::Lng(v)) => PropertyValue::Integer(v),
(tb_frontend::forms::PropertyType::Boolean, Value::Int(v)) => {
PropertyValue::Boolean(v != 0)
}
(tb_frontend::forms::PropertyType::Boolean, Value::Lng(v)) => {
PropertyValue::Boolean(v != 0)
}
(tb_frontend::forms::PropertyType::Single, Value::Sng(v)) => PropertyValue::Single(v),
(tb_frontend::forms::PropertyType::String, Value::Str(v)) => {
PropertyValue::String(v.to_string())
}
(tb_frontend::forms::PropertyType::Object, Value::Obj(object, index)) => {
PropertyValue::Object(Some((object, index)))
}
_ => return Err(RuntimeError::TYPE_MISMATCH),
})
}
fn dispatch_form_event(&mut self, event: FormEvent, on_return: FormEventReturn) -> bool {
if self
.module
.objects
.get(event.object as usize)
.is_some_and(|o| {
!matches!(
o.class,
tb_frontend::forms::ObjectClass::Form | tb_frontend::forms::ObjectClass::Screen
)
})
{
let _ = self
.forms
.set_active_control(event.object, event.array_index);
}
let Some(binding) = self
.module
.event_procs
.iter()
.find(|e| e.object == event.object && e.event.eq_ignore_ascii_case(&event.name))
.cloned()
else {
return false;
};
if let Some(index) = event.array_index {
self.push(Value::Int(index as i16));
}
for arg in event.args {
self.push(Self::form_value(arg));
}
self.push_frame(
binding.proc as usize,
self.module.procs[binding.proc as usize].n_params as usize,
);
if let Some(frame) = self.frames.last_mut() {
frame.form_event = Some(on_return);
}
true
}
fn dispatch_next_form_event(&mut self) -> bool {
while let Some(event) = self.forms.next_event() {
if self.dispatch_form_event(event, FormEventReturn::Normal) {
return true;
}
}
false
}
fn request_unload(&mut self, object: u16) -> Result<(), RuntimeError> {
let event = FormEvent {
object,
array_index: None,
name: "UNLOAD".into(),
args: vec![PropertyValue::Integer(0)],
};
if !self.dispatch_form_event(event, FormEventReturn::Unload(object)) {
self.forms.unload_with(object, |_| {})?;
}
Ok(())
}
fn forms_zustellen(&mut self) -> bool {
self.forms
.resize(self.rt.screen.cols(), self.rt.screen.rows());
while let Some(m) = self.rt.maus.pop_front() {
if let Some(object) = self.forms.active_form() {
let name = match m.art {
tb_runtime::host::MausArt::Druck => "MOUSEDOWN",
tb_runtime::host::MausArt::Loslassen => "MOUSEUP",
tb_runtime::host::MausArt::Bewegung => "MOUSEMOVE",
};
self.forms.queue(FormEvent {
object,
array_index: None,
name: name.into(),
args: vec![
PropertyValue::Integer(m.taste as i32),
PropertyValue::Integer(m.shift as i32),
PropertyValue::Single(m.spalte as f32),
PropertyValue::Single(m.zeile as f32),
],
});
}
}
self.dispatch_next_form_event()
}
/// Anstehendes Ereignis zustellen, falls eines fällig ist. Liefert
/// `true`, wenn ein Handler aufgesetzt wurde.
fn zustellen(&mut self, host: &mut dyn Host) -> bool {
@@ -243,6 +415,9 @@ impl Vm {
last_stmt_pc: 0,
line: 0,
trap: Some(q),
waiting_form: None,
pending_show: None,
form_event: None,
});
}
@@ -358,6 +533,19 @@ impl Vm {
pub fn run(&mut self, host: &mut dyn Host) -> RunEvent {
loop {
if let Some(form) = self.frames.last().and_then(|f| f.waiting_form) {
if !self.forms.is_visible(form) {
self.frames.last_mut().unwrap().waiting_form = None;
} else {
self.rt.tick(host);
self.forms_zustellen();
if self.rt.ende {
return RunEvent::Ended;
}
std::thread::yield_now();
continue;
}
}
let frame = self.frames.last_mut().expect("kein Frame");
let proc = frame.proc;
let pc = frame.pc;
@@ -642,6 +830,9 @@ impl Vm {
self.rt.tick(host);
self.rt.screen.veraenderung_quittieren();
}
if self.forms_zustellen() {
return Ok(Flow::Normal);
}
// Ereigniszustellung (design.md, D1): nur wenn überhaupt
// ein Trap definiert ist — sonst kostet die Grenze nichts.
if self.rt.traps.aktiv() {
@@ -711,6 +902,7 @@ impl Vm {
// lässt den Wert unberührt.
self.stack.push(Value::Int(0));
self.rt.tick(host);
self.forms_zustellen();
self.zustellen(host);
Ok(Flow::Normal)
}
@@ -757,6 +949,202 @@ impl Vm {
self.push(Value::Str(self.module.strings[i as usize].clone()));
Ok(Flow::Normal)
}
I::LoadObjectProperty(object, property, has_index) => {
let index = if has_index {
Some(self.pop_i32()?)
} else {
None
};
if !self.forms.is_loaded_at(object, index) {
self.forms.ensure_loaded_at(object, index)?;
if index.is_none() && self.dispatch_next_form_event() {
let caller = self.frames.len() - 2;
self.frames[caller].pc = self.frames[caller].pc.saturating_sub(1);
return Ok(Flow::Normal);
}
}
let value = self.forms.get_at(object, index, property)?;
self.push(Self::form_value(value));
Ok(Flow::Normal)
}
I::StoreObjectProperty(object, property, has_index) => {
let value = self.pop()?;
let index = if has_index {
Some(self.pop_i32()?)
} else {
None
};
if !self.forms.is_loaded_at(object, index) {
self.forms.ensure_loaded_at(object, index)?;
if index.is_none() {
self.push(value.clone());
}
if index.is_none() && self.dispatch_next_form_event() {
let caller = self.frames.len() - 2;
self.frames[caller].pc = self.frames[caller].pc.saturating_sub(1);
return Ok(Flow::Normal);
}
if index.is_none() {
let value = self.pop()?;
let value = self.property_value(object, property, value)?;
self.forms.set_at(object, index, property, value)?;
return Ok(Flow::Normal);
}
}
let value = self.property_value(object, property, value)?;
self.forms.set_at(object, index, property, value)?;
Ok(Flow::Normal)
}
I::PushObject(object, has_index) => {
let index = if has_index {
Some(self.pop_i32()?)
} else {
None
};
self.forms.ensure_loaded_at(object, index)?;
self.push(Value::Obj(object, index));
self.dispatch_next_form_event();
Ok(Flow::Normal)
}
I::LoadDynamicObjectProperty(property) => {
let Value::Obj(object, index) = self.pop()? else {
return Err(RuntimeError::TYPE_MISMATCH);
};
let class = self
.module
.objects
.get(object as usize)
.ok_or(RuntimeError(420))?
.class;
let name = self
.module
.strings
.get(property as usize)
.ok_or(RuntimeError(422))?;
let property = tb_frontend::forms::property(class, name)
.map(|(property, _)| property)
.ok_or(RuntimeError(422))?;
let value = self.forms.get_at(object, index, property)?;
self.push(Self::form_value(value));
Ok(Flow::Normal)
}
I::StoreDynamicObjectProperty(property) => {
let value = self.pop()?;
let Value::Obj(object, index) = self.pop()? else {
return Err(RuntimeError::TYPE_MISMATCH);
};
let class = self
.module
.objects
.get(object as usize)
.ok_or(RuntimeError(420))?
.class;
let name = self
.module
.strings
.get(property as usize)
.ok_or(RuntimeError(422))?;
let property = tb_frontend::forms::property(class, name)
.map(|(property, _)| property)
.ok_or(RuntimeError(422))?;
let value = self.property_value(object, property, value)?;
self.forms.set_at(object, index, property, value)?;
Ok(Flow::Normal)
}
I::TypeOf(class) => {
let matches = match self.pop()? {
Value::Obj(object, _) => self
.module
.objects
.get(object as usize)
.is_some_and(|o| o.class.id() == class),
_ => false,
};
self.push(Value::Int(if matches { -1 } else { 0 }));
Ok(Flow::Normal)
}
I::ObjectMethod(object, method, argc) => {
let class = self
.module
.objects
.get(object as usize)
.ok_or(RuntimeError(420))?
.class;
let name = *tb_frontend::forms::methods(class)
.get(method as usize)
.ok_or(RuntimeError(421))?;
let resumed_show = self.frames.last().unwrap().pending_show == Some(object);
if resumed_show {
self.frames.last_mut().unwrap().pending_show = None;
} else if class == tb_frontend::forms::ObjectClass::Form
&& name == "SHOW"
&& !self.forms.is_loaded(object)
{
self.forms.ensure_loaded(object)?;
if self.dispatch_next_form_event() {
let caller = self.frames.len() - 2;
self.frames[caller].pending_show = Some(object);
self.frames[caller].pc = self.frames[caller].pc.saturating_sub(1);
return Ok(Flow::Normal);
}
}
let mut args = Vec::with_capacity(argc as usize);
for _ in 0..argc {
args.push(self.pop()?);
}
args.reverse();
if resumed_show && !self.forms.is_loaded(object) {
return Ok(Flow::Normal);
}
match (class, name) {
(tb_frontend::forms::ObjectClass::Form, "SHOW") => {
let style = match args.first() {
None => 0,
Some(Value::Int(v)) => *v as i32,
Some(Value::Lng(v)) => *v,
_ => return Err(RuntimeError::TYPE_MISMATCH),
};
if !matches!(style, 0 | 1) {
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
}
if self.forms.show(object, style == 1)? == ShowResult::ModalWait {
self.frames.last_mut().unwrap().waiting_form = Some(object);
}
}
(tb_frontend::forms::ObjectClass::Form, "HIDE") => self.forms.hide(object)?,
(tb_frontend::forms::ObjectClass::Form, "LOAD") => {
self.forms.ensure_loaded(object)?
}
(tb_frontend::forms::ObjectClass::Form, "UNLOAD") => {
self.request_unload(object)?
}
(tb_frontend::forms::ObjectClass::Screen, "SHOW") => {
self.forms.screen_show(true)
}
(tb_frontend::forms::ObjectClass::Screen, "HIDE") => {
self.forms.screen_show(false)
}
_ => return Err(RuntimeError::FEATURE_UNAVAILABLE),
}
self.dispatch_next_form_event();
Ok(Flow::Normal)
}
I::ObjectLoad(object, unload, has_index) => {
if has_index {
let index = self.pop_i32()?;
if unload {
self.forms.unload_array(object, index)?;
} else {
self.forms.load_array(object, index)?;
}
} else if unload {
self.request_unload(object)?;
} else {
self.forms.ensure_loaded(object)?;
self.dispatch_next_form_event();
}
Ok(Flow::Normal)
}
I::Dup => {
let v = self.stack.last().cloned().ok_or(RuntimeError(51))?;
self.push(v);
@@ -906,8 +1294,7 @@ impl Vm {
let dst = self.pop_rec()?;
let src = self.pop_rec()?;
if !Rc::ptr_eq(&dst, &src) {
let copied: Vec<Value> =
src.borrow().fields.iter().map(deep_copy).collect();
let copied: Vec<Value> = src.borrow().fields.iter().map(deep_copy).collect();
dst.borrow_mut().fields = copied;
}
Ok(Flow::Normal)
@@ -1334,7 +1721,25 @@ impl Vm {
Ok(Flow::Normal)
}
I::RetProc => {
let event = self.frames.last().and_then(|f| f.form_event);
let cancel = self
.frames
.last()
.and_then(|f| {
let base = f.locals_base;
match self.locals.get(base) {
Some(Value::Int(v)) => Some(*v),
_ => None,
}
})
.unwrap_or(0);
self.pop_frame();
if let Some(FormEventReturn::Unload(object)) = event {
self.forms.unload_with(object, |c| *c = cancel)?;
}
if event.is_some() {
self.dispatch_next_form_event();
}
Ok(Flow::Normal)
}
I::RetFn => {
@@ -1616,13 +2021,7 @@ impl Vm {
match treffer {
Some((nummer, start, laenge)) => {
let datei = self.rt.dateien.get(nummer)?;
tb_runtime::fileio::feld_setzen(
&mut datei.puffer,
start,
laenge,
text,
rset,
);
tb_runtime::fileio::feld_setzen(&mut datei.puffer, start, laenge, text, rset);
let neu = tb_runtime::fileio::feld_lesen(&datei.puffer, start, laenge);
self.write_ref(&ziel, Value::Str(Rc::from(neu.as_str())))?;
}
@@ -1918,7 +2317,10 @@ fn input_value(target: &Value, text: &str) -> Option<Value> {
let t = text.trim();
match target {
Value::Str(_) => {
let s = t.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(t);
let s = t
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(t);
Some(Value::Str(Rc::from(s)))
}
Value::Int(_) => {
@@ -1946,4 +2348,3 @@ fn strict_number(t: &str) -> Option<f64> {
let cleaned = t.replace(['d', 'D'], "E").replace('e', "E");
cleaned.parse::<f64>().ok()
}

View File

@@ -27,3 +27,17 @@ pub fn compile_source(
let hir = analysis.hir.expect("diagnose-frei, aber kein HIR");
Ok(codegen::compile(&hir))
}
pub fn compile_source_with_forms(
module_name: &str,
source: &str,
forms: &tb_frontend::forms::FormCatalog,
) -> Result<bytecode::CompiledModule, Vec<Diagnostic>> {
let analysis = tb_frontend::analyze_source_with_forms(module_name, source, forms);
if !analysis.diagnostics.is_empty() {
return Err(analysis.diagnostics);
}
Ok(codegen::compile(
&analysis.hir.expect("diagnose-frei, aber kein HIR"),
))
}

View File

@@ -99,7 +99,10 @@ fn gemischte_arithmetik() {
#[test]
fn logik_bitweise() {
assert_eq!(out("PRINT 6 AND 3; 6 OR 3; 6 XOR 3; NOT 0"), " 2 7 5 -1 \n");
assert_eq!(
out("PRINT 6 AND 3; 6 OR 3; 6 XOR 3; NOT 0"),
" 2 7 5 -1 \n"
);
// Operanden werden gerundet: 1.5 AND 1 → 2 AND 1 = 0
assert_eq!(out("PRINT 1.5 AND 1"), " 0 \n");
}
@@ -122,12 +125,18 @@ fn stringvergleich_und_verkettung() {
#[test]
fn for_ohne_durchlauf() {
// Spec-Szenario: FOR i% = 3 TO 1 → Körper wird nicht betreten
assert_eq!(out("FOR i% = 3 TO 1\nPRINT i%\nNEXT\nPRINT \"ende\""), "ende\n");
assert_eq!(
out("FOR i% = 3 TO 1\nPRINT i%\nNEXT\nPRINT \"ende\""),
"ende\n"
);
}
#[test]
fn for_mit_negativem_step() {
assert_eq!(out("FOR i% = 3 TO 1 STEP -1\nPRINT i%;\nNEXT\nPRINT"), " 3 2 1 \n");
assert_eq!(
out("FOR i% = 3 TO 1 STEP -1\nPRINT i%;\nNEXT\nPRINT"),
" 3 2 1 \n"
);
}
#[test]
@@ -172,11 +181,13 @@ fn exit_for_und_do() {
fn kontrollfluss_korpusdatei() {
// 5.2-Verifikation: kontrollfluss.bas byte-genau korrekt.
let src = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat/kontrollfluss.bas"),
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/compat/kontrollfluss.bas"),
)
.unwrap();
let want = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat/kontrollfluss.out"),
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/compat/kontrollfluss.out"),
)
.unwrap();
assert_eq!(out(&src), want);
@@ -268,7 +279,8 @@ fn data_read_restore() {
// Anmerkung: unquotierte DATA-Texte verlieren derzeit die
// Groß-/Kleinschreibung (Lexer normalisiert Bezeichner);
// Rohtext-Erhalt ist als Aufgabe in PLAN.md Phase 3 eingeplant.
let src = "DATA 1, 2.5, \"hallo\"\nREAD a%, b!, c$\nPRINT a%; b!; c$\nRESTORE\nREAD x%\nPRINT x%";
let src =
"DATA 1, 2.5, \"hallo\"\nREAD a%, b!, c$\nPRINT a%; b!; c$\nRESTORE\nREAD x%\nPRINT x%";
assert_eq!(out(src), " 1 2.5 hallo\n 1 \n");
}
@@ -444,10 +456,7 @@ fn end_und_system() {
#[test]
fn input_mit_redo() {
let (ev, output) = run_with_input(
"INPUT \"Zahl\"; n%\nPRINT n% * 2",
&["abc", "21"],
);
let (ev, output) = run_with_input("INPUT \"Zahl\"; n%\nPRINT n% * 2", &["abc", "21"]);
assert_eq!(ev, RunEvent::Ended);
assert!(output.contains("Redo from start"));
assert!(output.ends_with(" 42 \n"), "{output}");
@@ -464,7 +473,10 @@ fn line_input_liest_ganze_zeile() {
#[test]
fn mid_anweisung_mutiert() {
assert_eq!(out("s$ = \"hallo\"\nMID$(s$, 2, 2) = \"EY\"\nPRINT s$"), "hEYlo\n");
assert_eq!(
out("s$ = \"hallo\"\nMID$(s$, 2, 2) = \"EY\"\nPRINT s$"),
"hEYlo\n"
);
}
#[test]
@@ -495,12 +507,10 @@ fn unsupported_feature_fehler_73() {
#[test]
fn open_for_isam_endet_nicht_mehr_mit_fehler_73() {
let _dir = TempVerzeichnis::neu("open_isam");
let (ev, ausgabe) = run(
"TYPE T\n f AS INTEGER\nEND TYPE\n\
let (ev, ausgabe) = run("TYPE T\n f AS INTEGER\nEND TYPE\n\
OPEN \"db.isam\" FOR ISAM T \"Tab\" AS #1\n\
PRINT \"offen\"\n\
CLOSE #1",
);
CLOSE #1");
assert_eq!(ev, RunEvent::Ended, "unerwartetes Ende: {ev:?}\n{ausgabe}");
assert!(ausgabe.contains("offen"), "{ausgabe}");
}
@@ -552,7 +562,10 @@ fn groessenaenderung_wirkt_zur_laufzeit() {
let module = tb_vm::compile_source("TEST", "PRINT \"a\"\nFOR i% = 1 TO 50\nNEXT i%\n").unwrap();
let mut vm = Vm::new(module);
let mut host = CaptureHost::default();
host.ereignis(Ereignis::Groesse { cols: 120, rows: 40 });
host.ereignis(Ereignis::Groesse {
cols: 120,
rows: 40,
});
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert_eq!((vm.rt.screen.cols(), vm.rt.screen.rows()), (120, 40));
// Inhalt bleibt oben links erhalten.
@@ -639,10 +652,7 @@ fn cls_setzt_cursor_zurueck() {
#[test]
fn width_hebt_die_groesse_an() {
assert_eq!(
out("WIDTH 120, 40\nPRINT SCREEN(40, 120)"),
" 32 \n"
);
assert_eq!(out("WIDTH 120, 40\nPRINT SCREEN(40, 120)"), " 32 \n");
}
// ---- ON ERROR-Scoping (Spec sprach-frontend) -------------------------------
@@ -736,8 +746,14 @@ fn time_und_now_stimmen_ueberein() {
#[test]
fn fester_versatz_verschiebt_die_uhr() {
let src = "PRINT INT(TIMER)";
let ohne: f64 = out_mit_zone(src, Zeitzone::Unbekannt).trim().parse().unwrap();
let mit: f64 = out_mit_zone(src, Zeitzone::Fest(3600)).trim().parse().unwrap();
let ohne: f64 = out_mit_zone(src, Zeitzone::Unbekannt)
.trim()
.parse()
.unwrap();
let mit: f64 = out_mit_zone(src, Zeitzone::Fest(3600))
.trim()
.parse()
.unwrap();
// Modulo Tageslänge, damit ein Mitternachtsübergang nichts kaputt macht.
let diff = (mit - ohne).rem_euclid(86_400.0);
assert!((diff - 3600.0).abs() <= 2.0, "Differenz {diff}");
@@ -746,7 +762,10 @@ fn fester_versatz_verschiebt_die_uhr() {
/// `TIMER` zählt ab der lokalen Mitternacht, nicht ab der UTC-Mitternacht.
#[test]
fn timer_zaehlt_ab_lokaler_mitternacht() {
let t = out_mit_zone("PRINT INT(TIMER / 3600); VAL(LEFT$(TIME$, 2))", Zeitzone::Fest(-18000));
let t = out_mit_zone(
"PRINT INT(TIMER / 3600); VAL(LEFT$(TIME$, 2))",
Zeitzone::Fest(-18000),
);
let zahlen: Vec<f64> = t
.split_whitespace()
.filter_map(|w| w.parse().ok())
@@ -1093,3 +1112,169 @@ fn erl_nennt_die_fehlerzeile_nicht_die_handlerzeile() {
110 RESUME NEXT\n");
assert_eq!(out.trim(), "20");
}
fn forms_catalog() -> tb_frontend::forms::FormCatalog {
use tb_frontend::forms::ObjectClass;
let mut c = tb_frontend::forms::FormCatalog::default();
c.add("Form1", ObjectClass::Form, None, false);
c.add("Text1", ObjectClass::TextBox, Some("Form1"), false);
c.add("Command1", ObjectClass::CommandButton, Some("Form1"), true);
c
}
fn form_vm(src: &str) -> Vm {
let module = tb_vm::compile_source_with_forms("FORM1", src, &forms_catalog())
.unwrap_or_else(|d| panic!("Compile-Fehler: {d:?}"));
Vm::new(module)
}
#[test]
fn objektzugriff_laeuft_ueber_objekt_und_eigenschaftsindex() {
let mut vm = form_vm("Form1!Text1.Text = \"hallo\"\nPRINT Form1!Text1.Text");
let ev = vm.run(&mut CaptureHost::default());
assert_eq!(ev, RunEvent::Ended);
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen).trim(), "hallo");
}
#[test]
fn steuerarray_eigenschaften_und_objektparameter_funktionieren() {
let mut vm = form_vm(
"LOAD Command1(3)\nCommand1(3).Caption = \"drei\"\nPRINT Command1(3).Caption\n\
CALL Aus(Text1)\nPRINT Text1.Visible\nCALL FormAus(Form1)\nPRINT Form1.Caption\nEND\n\
SUB Aus(c AS CONTROL)\nPRINT TYPEOF c IS TextBox\nc.Visible = 0\nEND SUB\n\
SUB FormAus(f AS FORM)\nPRINT TYPEOF f IS Form\nf.Caption = \"Formular\"\nEND SUB",
);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert_eq!(
tb_runtime::snapshot::text(&vm.rt.screen).trim(),
"drei\n-1 \n 0 \n-1 \nFormular"
);
}
#[test]
fn modales_show_setzt_nach_unload_fort() {
let mut vm = form_vm(
"DIM SHARED gesehen%\nForm1.Show 1\nPRINT gesehen%\nEND\n\
SUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\n\
SHARED gesehen%\ngesehen% = 1\nUNLOAD Form1\nEND SUB",
);
let mut host = CaptureHost::default();
host.ereignis_nach(
2,
tb_runtime::host::Ereignis::Maus(tb_runtime::host::MausEreignis {
art: tb_runtime::host::MausArt::Druck,
taste: 1,
shift: 0,
zeile: 1,
spalte: 1,
}),
);
let ev = vm.run(&mut host);
assert_eq!(ev, RunEvent::Ended);
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen).trim(), "1");
assert!(host.presents >= 2);
}
#[test]
fn fehlende_ereignisprozedur_verfaellt_und_typeof_prueft_klasse() {
let mut vm = form_vm("Form1.Show\nIF TYPEOF Text1 IS TextBox THEN PRINT \"ja\"");
vm.queue_form_event(tb_ui::forms::FormEvent {
object: 0,
array_index: None,
name: "CLICK".into(),
args: vec![],
});
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen).trim(), "ja");
}
#[test]
fn mausargumente_kommen_in_zellen_an() {
use tb_runtime::host::{Ereignis, MausArt, MausEreignis};
let mut vm = form_vm(
"DIM SHARED b%, s%, gx!, gy!\nForm1.Show\nSTOP\nDOEVENTS\nEND\n\
SUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\n\
SHARED b%, s%, gx!, gy!\nb% = Button: s% = Shift: gx! = X: gy! = Y\nEND SUB",
);
let mut host = CaptureHost::default();
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
host.ereignis(Ereignis::Maus(MausEreignis {
art: MausArt::Druck,
taste: 1,
shift: 2,
zeile: 5,
spalte: 7,
}));
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("b"), Some(Value::Int(1))));
assert!(matches!(vm.inspect("s"), Some(Value::Int(2))));
assert!(matches!(vm.inspect("gx"), Some(Value::Sng(7.0))));
assert!(matches!(vm.inspect("gy"), Some(Value::Sng(5.0))));
}
#[test]
fn form_load_laeuft_vor_dem_sichtbarsetzen() {
let mut vm = form_vm(
"Form1.Show\nEND\n\
SUB Form_Load()\nPRINT Form1.Visible\nEND SUB",
);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen).trim(), "0");
}
#[test]
fn steuerarray_ereignis_stellt_index_voran() {
let mut vm = form_vm(
"DIM SHARED seen%\nSTOP\nDOEVENTS\nEND\n\
SUB Command1_Click(Index AS INTEGER)\nSHARED seen%\nseen% = Index\nEND SUB",
);
let mut host = CaptureHost::default();
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
vm.forms.load_array(2, 3).unwrap();
vm.queue_form_event(tb_ui::forms::FormEvent {
object: 2,
array_index: Some(3),
name: "CLICK".into(),
args: vec![],
});
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("seen"), Some(Value::Int(3))));
let active =
tb_frontend::forms::property(tb_frontend::forms::ObjectClass::Screen, "ACTIVECONTROL")
.unwrap()
.0;
assert_eq!(
vm.forms.get(3, active).unwrap(),
tb_ui::forms::PropertyValue::Object(Some((2, Some(3))))
);
}
#[test]
fn form_load_laeuft_einmal_und_unload_cancel_verhindert_entladen() {
let mut vm = form_vm(
"DIM SHARED loads%\nx$ = Form1.Caption\nForm1.Hide\nx$ = Form1.Caption\n\
Form1.Show\nUNLOAD Form1\nEND\n\
SUB Form_Load()\nSHARED loads%\nloads% = loads% + 1\nEND SUB\n\
SUB Form_Unload(Cancel AS INTEGER)\nCancel = 1\nEND SUB",
);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert!(matches!(vm.inspect("loads"), Some(Value::Int(1))));
assert!(vm.forms.objects[0].loaded);
assert!(vm.forms.objects[0].visible);
}
#[test]
fn objektcode_erzeugt_keine_zusaetzlichen_zustellopcodes() {
let module = tb_vm::compile_source_with_forms(
"FORM1",
"Form1.Caption = \"x\"\nDOEVENTS\nx$ = Form1.Caption",
&forms_catalog(),
)
.unwrap();
let n = module.procs[0]
.code
.iter()
.filter(|i| matches!(i, tb_vm::bytecode::Instr::Doevents))
.count();
assert_eq!(n, 1, "nur das explizite DOEVENTS ist ein Zustellopcode");
}