diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index 6d99d12..6b0e0f7 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -1,7 +1,7 @@ //! `tbc` — Standalone-Compiler von Terminal Basic. //! //! Unterbefehle (Phase 2): -//! - `tbc run ` Kompilieren und ausführen +//! - `tbc run ` Kompilieren und ausführen //! - `tbc build ` Zu `.tbc` kompilieren //! - `tbc check ` Syntax/Semantik prüfen //! - `tbc convert-frm ` Binärformular in Text wandeln @@ -10,7 +10,6 @@ //! 0 = END/SYSTEM/Programmende · 3 = STOP · 2 = Laufzeitfehler · //! 1 = Compile-Fehler/Bedienfehler. -use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::ExitCode; use tb_runtime::host::{Ereignis, Host}; @@ -26,7 +25,7 @@ fn main() -> ExitCode { Some("convert-frm") => cmd_convert_frm(&args[1..]), _ => { eprintln!( - "Aufruf: tbc run|build|check | tbc convert-frm " + "Aufruf: tbc run|build|check | tbc convert-frm " ); ExitCode::from(1) } @@ -128,33 +127,47 @@ fn include_name(line: &str) -> Option { Some(rest.split('\'').next().unwrap_or_default().to_string()) } -fn expand_includes(path: &Path, source: &str, stack: &mut Vec) -> Result { +fn expand_includes( + path: &Path, + source: &str, + first_line: u32, + stack: &mut Vec, +) -> Result, String> { let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); if stack.contains(&canonical) { return Err(format!("{}: zyklisches $INCLUDE", path.display())); } stack.push(canonical); - let mut out = String::new(); - for line in source.split_inclusive('\n') { + let mut segments = vec![tb_frontend::source::SourceSegment { + file: path.display().to_string(), + first_line, + text: String::new(), + }]; + for (line_no, line) in source.split_inclusive('\n').enumerate() { if let Some(name) = include_name(line) { if name.is_empty() { - return Err(format!("{}: leeres $INCLUDE", path.display())); + return Err(format!( + "{}:{}: leeres $INCLUDE", + path.display(), + first_line + line_no as u32 + )); } let included = relative_case_insensitive(path.parent().unwrap_or(Path::new(".")), &name) - .map_err(|error| format!("{}: {error}", path.display()))?; + .map_err(|e| format!("{}: {e}", path.display()))?; let text = std::fs::read_to_string(&included) - .map_err(|error| format!("{}: {error}", included.display()))?; - out.push_str(&expand_includes(&included, &text, stack)?); - if !out.ends_with('\n') { - out.push('\n'); - } + .map_err(|e| format!("{}: {e}", included.display()))?; + segments.extend(expand_includes(&included, &text, 1, stack)?); } else { - out.push_str(line); + segments.push(tb_frontend::source::SourceSegment { + file: path.display().to_string(), + first_line: first_line + line_no as u32, + text: line.into(), + }); } } stack.pop(); - Ok(out) + Ok(segments) } fn read_form(path: &Path) -> Result { @@ -162,9 +175,8 @@ fn read_form(path: &Path) -> Result { if bytes.starts_with(b"VERSION ") || bytes.starts_with(b"Version ") { let source = std::str::from_utf8(&bytes) .map_err(|_| format!("{}: ungültige Textkodierung", path.display()))?; - let mut form = tb_ui::frm::read_text(&path.display().to_string(), source) + let form = tb_ui::frm::read_text(&path.display().to_string(), source) .map_err(|error| error.to_string())?; - form.code = expand_includes(path, &form.code, &mut Vec::new())?; Ok(form) } else { tb_ui::frm::read_binary(&path.display().to_string(), &bytes) @@ -173,65 +185,15 @@ fn read_form(path: &Path) -> Result { } } -#[derive(Default)] -struct ProjectSymbols { - constants: HashSet, - types: HashSet, -} - -fn append_project_source(target: &mut String, source: &str, symbols: &mut ProjectSymbols) { - let mut in_proc = false; - let mut skip_type = false; - for line in source.split_inclusive('\n') { - let upper = line.trim().to_ascii_uppercase(); - if skip_type { - if upper == "END TYPE" { - skip_type = false; - } - continue; - } - if !in_proc { - if let Some(name) = upper - .strip_prefix("TYPE ") - .and_then(|rest| rest.split_whitespace().next()) - { - if !symbols.types.insert(name.to_string()) { - skip_type = true; - continue; - } - } - if let Some(rest) = upper.strip_prefix("CONST ") { - let names = rest - .split(',') - .filter_map(|part| part.split('=').next()) - .map(str::trim) - .filter(|name| !name.is_empty()) - .collect::>(); - if !names.is_empty() && names.iter().all(|name| symbols.constants.contains(*name)) { - continue; - } - symbols - .constants - .extend(names.into_iter().map(str::to_string)); - } - } - target.push_str(line); - if upper.starts_with("SUB ") - || upper.starts_with("FUNCTION ") - || upper.starts_with("STATIC SUB ") - || upper.starts_with("STATIC FUNCTION ") - { - in_proc = true; - } else if matches!(upper.as_str(), "END SUB" | "END FUNCTION") { - in_proc = false; - } - } - if !target.ends_with('\n') { - target.push('\n'); - } -} - -fn input_sources(path: &Path) -> Result<(String, Vec), String> { +fn input_sources( + path: &Path, +) -> Result< + ( + Vec, + Vec, + ), + String, +> { let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); let paths = if extension.eq_ignore_ascii_case("mak") { let project = std::fs::read_to_string(path) @@ -240,7 +202,7 @@ fn input_sources(path: &Path) -> Result<(String, Vec), Str project .lines() .map(str::trim) - .filter(|line| !line.is_empty()) + .filter(|line| !line.is_empty() && !line.starts_with('\'')) .map(|line| { relative_case_insensitive(base, line) .map_err(|error| format!("{}: {error}", path.display())) @@ -249,9 +211,8 @@ fn input_sources(path: &Path) -> Result<(String, Vec), Str } else { vec![path.to_path_buf()] }; - let mut source = String::new(); + let mut source = Vec::new(); let mut forms = Vec::new(); - let mut symbols = ProjectSymbols::default(); for member in paths { if member .extension() @@ -259,13 +220,18 @@ fn input_sources(path: &Path) -> Result<(String, Vec), Str .is_some_and(|ext| ext.eq_ignore_ascii_case("frm")) { let form = read_form(&member)?; - append_project_source(&mut source, &form.code, &mut symbols); + source.push(tb_frontend::source::SourceUnit { + name: form.root.name.clone(), + segments: expand_includes(&member, &form.code, form.code_line(), &mut Vec::new())?, + }); forms.push(form); } else { let text = std::fs::read_to_string(&member) .map_err(|error| format!("{}: {error}", member.display()))?; - let expanded = expand_includes(&member, &text, &mut Vec::new())?; - append_project_source(&mut source, &expanded, &mut symbols); + source.push(tb_frontend::source::SourceUnit { + name: module_name(&member), + segments: expand_includes(&member, &text, 1, &mut Vec::new())?, + }); } } Ok((source, forms)) @@ -273,19 +239,26 @@ fn input_sources(path: &Path) -> Result<(String, Vec), Str fn compile( path_arg: Option<&String>, -) -> Result< - ( - PathBuf, - tb_vm::bytecode::CompiledModule, - Vec, - ), - ExitCode, -> { +) -> Result<(PathBuf, tb_vm::bytecode::CompiledModule), ExitCode> { let Some(path) = path_arg else { - eprintln!("Aufruf: tbc run|build|check "); + eprintln!("Aufruf: tbc run|build|check "); return Err(ExitCode::from(1)); }; let path = PathBuf::from(path); + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("tbc")) + { + let bytes = std::fs::read(&path).map_err(|e| { + eprintln!("{}: {e}", path.display()); + ExitCode::from(1) + })?; + let module = tb_vm::bytecode::CompiledModule::from_tbc(&bytes).map_err(|e| { + eprintln!("{}: {e}", path.display()); + ExitCode::from(1) + })?; + return Ok((path, module)); + } let (source, forms) = match input_sources(&path) { Ok(input) => input, Err(error) => { @@ -293,22 +266,16 @@ fn compile( return Err(ExitCode::from(1)); } }; - let compiled = if forms.is_empty() { - tb_vm::compile_source(&module_name(&path), &source) - } else { - let mut catalog = tb_frontend::forms::FormCatalog::default(); - for form in &forms { - for object in form.catalog().objects { - catalog.objects.push(object); - } - } - tb_vm::compile_source_with_forms(&forms[0].root.name, &source, &catalog) - }; + let mut catalog = tb_frontend::forms::FormCatalog::default(); + for form in &forms { + catalog.append(&form.catalog()); + } + let compiled = tb_vm::compile_project(&module_name(&path), &source, &catalog, &forms); match compiled { - Ok(m) => Ok((path, m, forms)), + Ok(m) => Ok((path, m)), Err(diags) => { for d in &diags { - eprintln!("{}:{d}", path.display()); + eprintln!("{d}"); } eprintln!("{} Fehler.", diags.len()); Err(ExitCode::from(1)) @@ -324,7 +291,7 @@ fn cmd_check(args: &[String]) -> ExitCode { } fn cmd_build(args: &[String]) -> ExitCode { - let (path, module, _) = match compile(args.first()) { + let (path, module) = match compile(args.first()) { Ok(x) => x, Err(code) => return code, }; @@ -371,7 +338,7 @@ fn cmd_run(args: &[String]) -> ExitCode { RunEvent::Ended => ExitCode::SUCCESS, RunEvent::Stopped { line } => { // STOP außerhalb der IDE: Meldung + Exit-Code ≠ 0 (D6). - eprintln!("STOP in line {line}"); + eprintln!("{}:{line}: STOP in line {line}", vm.current_file()); ExitCode::from(3) } RunEvent::Error { @@ -379,7 +346,11 @@ fn cmd_run(args: &[String]) -> ExitCode { line, message, } => { - eprintln!("Runtime error {code}: {message} in line {line}"); + eprintln!( + "{}:{line}:{}: Runtime error {code}: {message} in line {line}", + vm.current_file(), + vm.current_source_pos().column + ); ExitCode::from(2) } // Ohne Debugger-Flags treten diese Ereignisse nicht auf. @@ -398,7 +369,7 @@ fn run_target(current: &Path, program: &str) -> Result { if Path::new(program).extension().is_some() { return relative_case_insensitive(base, program).map_err(|error| error.to_string()); } - for extension in ["bas", "frm", "mak"] { + for extension in ["bas", "frm", "mak", "tbc"] { let candidate = format!("{program}.{extension}"); if let Ok(path) = relative_case_insensitive(base, &candidate) { return Ok(path); @@ -413,7 +384,7 @@ fn run_chain( size: Option<(usize, usize)>, ) -> Result<(RunEvent, Vm), ExitCode> { let Some(first) = args.first() else { - eprintln!("Aufruf: tbc run "); + eprintln!("Aufruf: tbc run "); return Err(ExitCode::from(1)); }; let mut current = PathBuf::from(first); @@ -421,26 +392,8 @@ fn run_chain( let command = args[1..].join(" "); loop { let current_arg = current.display().to_string(); - let (path, module, forms) = compile(Some(¤t_arg))?; + let (path, module) = compile(Some(¤t_arg))?; let mut vm = Vm::new(module); - if !forms.is_empty() { - let applied = forms.iter().try_for_each(|form| form.apply(&mut vm.forms)); - let first = vm - .forms - .objects - .iter() - .position(|object| { - object - .description - .name - .eq_ignore_ascii_case(&forms[0].root.name) - }) - .unwrap() as u16; - if let Err(error) = applied.and_then(|_| vm.forms.show(first, false).map(|_| ())) { - eprintln!("Runtime error {}: {}", error.0, error); - return Err(ExitCode::from(2)); - } - } if let Some(line) = start_line.take() { if let Err(error) = vm.start_at_line(line) { eprintln!("Runtime error {}: {}", error.0, error); diff --git a/crates/tb-cli/tests/project.rs b/crates/tb-cli/tests/project.rs new file mode 100644 index 0000000..f9a6b2a --- /dev/null +++ b/crates/tb-cli/tests/project.rs @@ -0,0 +1,130 @@ +use std::{ + path::Path, + process::{Command, Output, Stdio}, + time::{Duration, Instant}, +}; + +fn tbc(dir: &Path, command: &str, file: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_tbc")) + .current_dir(dir) + .args([command, file]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let start = Instant::now(); + while child.try_wait().unwrap().is_none() { + if start.elapsed() > Duration::from_secs(10) { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("tbc {command} {file}: Frist überschritten"); + } + std::thread::sleep(Duration::from_millis(10)); + } + child.wait_with_output().unwrap() +} + +#[test] +fn projekt_und_formular_laufen_nach_entfernen_saemtlicher_quellen() { + let dir = std::env::temp_dir().join(format!("tb-tbc-v4-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("main.bas"), "a$=Form1!Text1.Text\nb$=Form1!Text1(2).Text\nForm1.Hide\nCLS\nPRINT a$\nPRINT b$\nCALL Ausgabe\nEND\n").unwrap(); + std::fs::write( + dir.join("lib.bas"), + "'$INCLUDE: 'outer.bi'\nSUB Ausgabe\nPRINT N\nEND SUB\n", + ) + .unwrap(); + std::fs::write(dir.join("outer.bi"), "'$INCLUDE: 'inner.bi'\n").unwrap(); + std::fs::write(dir.join("inner.bi"), "CONST N=7\n").unwrap(); + std::fs::write(dir.join("form.frm"), "VERSION 1.00\nBEGIN Form Form1\n BEGIN TextBox Text1\n Index = 0\n Text = \"hello\"\n END\n BEGIN TextBox Text1\n Index = 2\n Text = \"world\"\n END\nEND\n").unwrap(); + std::fs::write( + dir.join("app.mak"), + "' Projekt\nMAIN.BAS\nLIB.BAS\nFORM.FRM\n", + ) + .unwrap(); + let before = tbc(&dir, "run", "app.mak"); + assert!(before.status.success(), "{before:?}"); + assert_eq!( + String::from_utf8_lossy(&before.stdout), + "hello\nworld\n 7 \n" + ); + let build = tbc(&dir, "build", "app.mak"); + assert!(build.status.success(), "{build:?}"); + let bytes = std::fs::read(dir.join("app.tbc")).unwrap(); + assert_eq!(&bytes[..6], b"TBC\0\x04\0"); + for file in [ + "main.bas", "lib.bas", "outer.bi", "inner.bi", "form.frm", "app.mak", + ] { + std::fs::remove_file(dir.join(file)).unwrap(); + } + let after = tbc(&dir, "run", "app.tbc"); + assert!(after.status.success(), "{after:?}"); + assert_eq!(after.stdout, before.stdout); + for version in [1u16, 2, 3, 5, 32767] { + let mut invalid = bytes.clone(); + invalid[4..6].copy_from_slice(&version.to_le_bytes()); + std::fs::write(dir.join("old.tbc"), invalid).unwrap(); + let out = tbc(&dir, "run", "old.tbc"); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains(&format!("Formatversion {version}")), + "{out:?}" + ); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn cli_meldet_physische_quellorte_auch_nach_verschachtelten_includes() { + let dir = std::env::temp_dir().join(format!("tb-project-origins-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("main.bas"), "CALL Fehler\nEND\n").unwrap(); + std::fs::write(dir.join("lib.bas"), "SUB Fehler\n200 ERROR 6\nEND SUB\n").unwrap(); + std::fs::write(dir.join("app.mak"), "main.bas\nlib.bas\n").unwrap(); + let out = tbc(&dir, "run", "app.mak"); + assert_eq!(out.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("lib.bas:2:5:"), + "{out:?}" + ); + std::fs::write(dir.join("lib.bas"), "SUB Fehler\ns$=42\nEND SUB\n").unwrap(); + let out = tbc(&dir, "check", "app.mak"); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("lib.bas:2:1:"), + "{out:?}" + ); + std::fs::write(dir.join("lib.bas"), "'$INCLUDE: 'outer.bi'\n").unwrap(); + std::fs::write(dir.join("outer.bi"), "'$INCLUDE: 'inner.bi'\n").unwrap(); + std::fs::write(dir.join("inner.bi"), "SUB Fehler\ns$=42\nEND SUB\n").unwrap(); + let out = tbc(&dir, "check", "app.mak"); + assert!(!out.status.success()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("inner.bi:2:1:"), + "{out:?}" + ); + std::fs::write(dir.join("inner.bi"), "SUB Fehler\n200 ERROR 6\nEND SUB\n").unwrap(); + let out = tbc(&dir, "run", "app.mak"); + assert_eq!(out.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("inner.bi:2:5:"), + "{out:?}" + ); + std::fs::write( + dir.join("error.frm"), + "VERSION 1.00\nBEGIN Form F\nEND\n\nSUB Form_Load\nERROR 6\nEND SUB\n", + ) + .unwrap(); + assert!(tbc(&dir, "build", "error.frm").status.success()); + std::fs::remove_file(dir.join("error.frm")).unwrap(); + let out = tbc(&dir, "run", "error.tbc"); + assert_eq!(out.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("error.frm:6:1:"), + "{out:?}" + ); + std::fs::write(dir.join("empty.bas"), "").unwrap(); + assert!(tbc(&dir, "run", "empty.bas").status.success()); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/tb-frontend/src/ast.rs b/crates/tb-frontend/src/ast.rs index 59e6e71..676ad92 100644 --- a/crates/tb-frontend/src/ast.rs +++ b/crates/tb-frontend/src/ast.rs @@ -49,12 +49,12 @@ pub enum BinOp { #[derive(Debug, Clone, PartialEq)] pub enum Expr { - IntLit(i16), - LongLit(i32), - SingleLit(f32), - DoubleLit(f64), - CurrencyLit(i64), - StrLit(String), + IntLit(i16, SourcePos), + LongLit(i32, SourcePos), + SingleLit(f32, SourcePos), + DoubleLit(f64, SourcePos), + CurrencyLit(i64, SourcePos), + StrLit(String, SourcePos), /// Benannter Zugriff: Variable, Arrayelement, Funktionsaufruf oder /// Konstante — Auflösung erfolgt in der Semantik. Name { @@ -90,7 +90,13 @@ pub enum Expr { impl Expr { pub fn pos(&self) -> SourcePos { match self { - Expr::Name { pos, .. } + Expr::IntLit(_, pos) + | Expr::LongLit(_, pos) + | Expr::SingleLit(_, pos) + | Expr::DoubleLit(_, pos) + | Expr::CurrencyLit(_, pos) + | Expr::StrLit(_, pos) + | Expr::Name { pos, .. } | Expr::Unary { pos, .. } | Expr::Binary { pos, .. } | Expr::TypeOf { pos, .. } => *pos, @@ -234,7 +240,7 @@ pub enum EventAction { #[derive(Debug, Clone, PartialEq)] pub enum Stmt { Label(String), - LineNumber(u32), + LineNumber(u32, SourcePos), Assign { target: Expr, value: Expr, @@ -259,7 +265,7 @@ pub enum Stmt { If { cond: Expr, then_body: Vec, - elseifs: Vec<(Expr, Vec)>, + elseifs: Vec<(Expr, Vec, SourcePos)>, else_body: Option>, pos: SourcePos, }, @@ -275,17 +281,20 @@ pub enum Stmt { step: Option, body: Vec, pos: SourcePos, + end_pos: SourcePos, }, DoLoop { pre: Option<(bool, Expr)>, // (ist UNTIL, Bedingung) post: Option<(bool, Expr)>, body: Vec, pos: SourcePos, + end_pos: SourcePos, }, While { cond: Expr, body: Vec, pos: SourcePos, + end_pos: SourcePos, }, Goto { target: LabelRef, diff --git a/crates/tb-frontend/src/forms.rs b/crates/tb-frontend/src/forms.rs index e2bb8b9..9938bdb 100644 --- a/crates/tb-frontend/src/forms.rs +++ b/crates/tb-frontend/src/forms.rs @@ -678,6 +678,7 @@ pub struct FormObject { pub name: String, pub class: ObjectClass, pub parent_form: Option, + pub parent: Option, pub array: bool, } @@ -698,6 +699,13 @@ impl FormCatalog { self.objects.push(FormObject { name: name.into().to_uppercase(), class, + parent: parent_form + .and_then(|name| { + self.objects + .iter() + .rposition(|o| o.name.eq_ignore_ascii_case(name)) + }) + .map(|id| id as u16), parent_form: parent_form.map(str::to_uppercase), array, }); @@ -712,16 +720,33 @@ impl FormCatalog { .map(|(i, o)| (i as u16, o)) } + pub fn append(&mut self, other: &Self) { + let offset = self.objects.len() as u16; + self.objects + .extend(other.objects.iter().cloned().map(|mut object| { + object.parent = object.parent.map(|id| id + offset); + object + })); + } + + pub fn find_in(&self, name: &str, form: &str) -> Option<(u16, &FormObject)> { + self.objects + .iter() + .enumerate() + .find(|(_, o)| o.name.eq_ignore_ascii_case(name) && self.belongs_to(o, form)) + .map(|(id, o)| (id as u16, o)) + } + pub fn belongs_to(&self, object: &FormObject, form: &str) -> bool { - let mut parent = object.parent_form.as_deref(); + let mut parent = object.parent; for _ in 0..self.objects.len() { - let Some(name) = parent else { return false }; - if name.eq_ignore_ascii_case(form) { + let Some(object) = parent.and_then(|id| self.objects.get(id as usize)) else { + return false; + }; + if object.name.eq_ignore_ascii_case(form) { return true; } - parent = self - .find(name) - .and_then(|(_, object)| object.parent_form.as_deref()); + parent = object.parent; } false } diff --git a/crates/tb-frontend/src/hir.rs b/crates/tb-frontend/src/hir.rs index cb4117d..c3667f0 100644 --- a/crates/tb-frontend/src/hir.rs +++ b/crates/tb-frontend/src/hir.rs @@ -124,10 +124,22 @@ pub struct DataItem { pub line: u32, } +/// Aufgelöste COMMON-Deklaration für die projektweite Slotverknüpfung. +#[derive(Debug, Clone)] +pub struct HCommon { + pub slot: u16, + pub block: Option, + pub key: String, + pub ty: HTy, + pub dims: Option, Option)>>, + pub pos: crate::SourcePos, +} + #[derive(Debug)] pub struct HirModule { pub name: String, pub globals: Vec, + pub commons: Vec, pub udts: Vec, /// Prozeduren; Index 0 ist das Hauptprogramm. pub procs: Vec, @@ -382,6 +394,8 @@ pub enum Builtin { pub enum HExpr { Int(i16), Lng(i32), + /// TYPE-Tabellenreferenz für ISAM; beim Projektlinken zu versetzen. + UdtId(u16), Sng(f32), Dbl(f64), Cur(i64), @@ -513,6 +527,7 @@ pub enum HResume { #[derive(Debug)] pub struct HStmt { + pub pos: crate::SourcePos, pub line: u32, pub kind: HStmtKind, } @@ -596,12 +611,14 @@ pub enum HStmtKind { }, /// DO/LOOP, WHILE/WEND (nur `pre`) — Bedingungen: (ist_until, Ausdruck). Loop { + end_pos: crate::SourcePos, pre: Option<(bool, HExpr)>, post: Option<(bool, HExpr)>, body: Vec, exit_label: LabelId, }, For { + end_pos: crate::SourcePos, var: HPlace, ty: NumTy, from: HExpr, diff --git a/crates/tb-frontend/src/lexer.rs b/crates/tb-frontend/src/lexer.rs index 376c652..1906162 100644 --- a/crates/tb-frontend/src/lexer.rs +++ b/crates/tb-frontend/src/lexer.rs @@ -342,6 +342,7 @@ pub fn lex(source: &str) -> LexOutput { } let start = i; let pos = SourcePos { + source: 0, line: line_no, column: (start + 1) as u32, }; @@ -413,6 +414,7 @@ pub fn lex(source: &str) -> LexOutput { TokenKind::Num(NumValue::Int(v as u16 as i16)) } else { diagnostics.push(Diagnostic { + file: None, pos, message: "Overflow".into(), }); @@ -421,6 +423,7 @@ pub fn lex(source: &str) -> LexOutput { tokens.push(Token { kind, pos }); } Err(_) => diagnostics.push(Diagnostic { + file: None, pos, message: "Syntax error".into(), }), @@ -496,6 +499,7 @@ pub fn lex(source: &str) -> LexOutput { (Some(Suffix::Integer), _) => { if dval > i16::MAX as f64 || dval < i16::MIN as f64 { diagnostics.push(Diagnostic { + file: None, pos, message: "Overflow".into(), }); @@ -505,6 +509,7 @@ pub fn lex(source: &str) -> LexOutput { (Some(Suffix::Long), _) => { if dval > i32::MAX as f64 || dval < i32::MIN as f64 { diagnostics.push(Diagnostic { + file: None, pos, message: "Overflow".into(), }); @@ -518,6 +523,7 @@ pub fn lex(source: &str) -> LexOutput { } (Some(Suffix::Str), _) => { diagnostics.push(Diagnostic { + file: None, pos, message: "Syntax error".into(), }); @@ -674,6 +680,7 @@ pub fn lex(source: &str) -> LexOutput { } other => { diagnostics.push(Diagnostic { + file: None, pos, message: format!("Syntax error ('{other}')"), }); @@ -692,6 +699,7 @@ pub fn lex(source: &str) -> LexOutput { tokens.push(Token { kind: TokenKind::Eol, pos: SourcePos { + source: 0, line: line_no, column: (chars.len() + 1) as u32, }, @@ -704,6 +712,7 @@ pub fn lex(source: &str) -> LexOutput { tokens.push(Token { kind: TokenKind::Eof, pos: SourcePos { + source: 0, line: (source.lines().count() + 1) as u32, column: 1, }, diff --git a/crates/tb-frontend/src/lib.rs b/crates/tb-frontend/src/lib.rs index 8157640..c4015d1 100644 --- a/crates/tb-frontend/src/lib.rs +++ b/crates/tb-frontend/src/lib.rs @@ -11,10 +11,12 @@ pub mod hir; pub mod lexer; pub mod parser; pub mod sema; +pub mod source; /// Quelltextposition für Diagnostik (1-basiert, wie im IDE-Vorbild). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct SourcePos { + pub source: u32, pub line: u32, pub column: u32, } @@ -23,12 +25,16 @@ pub struct SourcePos { /// Meldungen des Vorbilds, wo es eine Entsprechung gibt. #[derive(Debug, Clone)] pub struct Diagnostic { + pub file: Option, pub pos: SourcePos, pub message: String, } impl std::fmt::Display for Diagnostic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(file) = &self.file { + write!(f, "{file}:")?; + } write!(f, "{}:{}: {}", self.pos.line, self.pos.column, self.message) } } diff --git a/crates/tb-frontend/src/parser.rs b/crates/tb-frontend/src/parser.rs index f44b526..33d7203 100644 --- a/crates/tb-frontend/src/parser.rs +++ b/crates/tb-frontend/src/parser.rs @@ -106,6 +106,7 @@ impl<'a> P<'a> { fn err(&mut self, msg: impl Into) { let pos = self.pos(); self.diags.push(Diagnostic { + file: None, pos, message: msg.into(), }); @@ -199,13 +200,13 @@ impl<'a> P<'a> { if let TokenKind::Num(NumValue::Int(n)) = self.k() { if n >= 0 { self.advance(); - return Some(Stmt::LineNumber(n as u32)); + return Some(Stmt::LineNumber(n as u32, pos)); } } if let TokenKind::Num(NumValue::Long(n)) = self.k() { if n >= 0 { self.advance(); - return Some(Stmt::LineNumber(n as u32)); + return Some(Stmt::LineNumber(n as u32, pos)); } } if let TokenKind::Ident { name, suffix: None } = self.k() { @@ -816,7 +817,7 @@ impl<'a> P<'a> { { if w == "ALL" { self.advance(); - let args = vec![Expr::LongLit(ROLLBACK_ALL)]; + let args = vec![Expr::LongLit(ROLLBACK_ALL, pos)]; return Some(Stmt::Call { name, suffix: None, @@ -1210,11 +1211,13 @@ impl<'a> P<'a> { let stop = |p: &P| p.is_kw(Kw::ElseIf) || p.is_kw(Kw::Else) || p.at_end_pair(Kw::If); let then_body = self.parse_stmt_list(stop); let mut elseifs = Vec::new(); - while self.eat_kw(Kw::ElseIf) { + while self.is_kw(Kw::ElseIf) { + let elseif_pos = self.pos(); + self.advance(); let c = self.parse_expr()?; self.expect_kw(Kw::Then, "THEN"); let b = self.parse_stmt_list(stop); - elseifs.push((c, b)); + elseifs.push((c, b, elseif_pos)); } let else_body = if self.eat_kw(Kw::Else) { Some(self.parse_stmt_list(|p: &P| p.at_end_pair(Kw::If))) @@ -1349,6 +1352,7 @@ impl<'a> P<'a> { None }; let body = self.parse_stmt_list(|p: &P| p.is_kw(Kw::Next)); + let end_pos = self.pos(); if self.eat_kw(Kw::Next) { // Optional: Zählvariable(n) hinter NEXT while matches!(self.k(), TokenKind::Ident { .. }) { @@ -1367,6 +1371,7 @@ impl<'a> P<'a> { step, body, pos, + end_pos, }) } @@ -1380,6 +1385,7 @@ impl<'a> P<'a> { None }; let body = self.parse_stmt_list(|p: &P| p.is_kw(Kw::Loop)); + let end_pos = self.pos(); let mut post = None; if self.eat_kw(Kw::Loop) { if self.eat_kw(Kw::While) { @@ -1395,6 +1401,7 @@ impl<'a> P<'a> { post, body, pos, + end_pos, }) } @@ -1402,10 +1409,16 @@ impl<'a> P<'a> { self.advance(); // WHILE let cond = self.parse_expr()?; let body = self.parse_stmt_list(|p: &P| p.is_kw(Kw::Wend)); + let end_pos = self.pos(); if !self.eat_kw(Kw::Wend) { self.err("WHILE without WEND"); } - Some(Stmt::While { cond, body, pos }) + Some(Stmt::While { + cond, + body, + pos, + end_pos, + }) } /// `ON [(n)] GOSUB ziel`. Liefert `None` und lässt den Cursor @@ -2214,18 +2227,20 @@ impl<'a> P<'a> { } match self.k() { TokenKind::Num(n) => { + let pos = self.pos(); self.advance(); Some(match n { - NumValue::Int(v) => Expr::IntLit(v), - NumValue::Long(v) => Expr::LongLit(v), - NumValue::Single(v) => Expr::SingleLit(v), - NumValue::Double(v) => Expr::DoubleLit(v), - NumValue::Currency(v) => Expr::CurrencyLit(v), + NumValue::Int(v) => Expr::IntLit(v, pos), + NumValue::Long(v) => Expr::LongLit(v, pos), + NumValue::Single(v) => Expr::SingleLit(v, pos), + NumValue::Double(v) => Expr::DoubleLit(v, pos), + NumValue::Currency(v) => Expr::CurrencyLit(v, pos), }) } TokenKind::Str(s) => { + let pos = self.pos(); self.advance(); - Some(Expr::StrLit(s)) + Some(Expr::StrLit(s, pos)) } TokenKind::LParen => { self.advance(); diff --git a/crates/tb-frontend/src/sema.rs b/crates/tb-frontend/src/sema.rs index d0f535d..60d10cd 100644 --- a/crates/tb-frontend/src/sema.rs +++ b/crates/tb-frontend/src/sema.rs @@ -150,6 +150,7 @@ struct Scope { /// Stack der Schleifen-Exit-Labels für `EXIT FOR`/`EXIT DO`. loop_exits: Vec<(LoopKind, LabelId)>, current_line: u32, + current_pos: SourcePos, } #[derive(PartialEq, Clone, Copy)] @@ -412,7 +413,13 @@ pub fn lower_with_forms( { catalog.add(&module.name, ObjectClass::Form, None, false); } - let mut s = Sema { + let mut s = new_sema(module, &catalog); + let hir = s.run(module); + (hir, s.diags) +} + +fn new_sema(module: &Module, catalog: &FormCatalog) -> Sema { + Sema { diags: Vec::new(), deftypes: [const { None }; 26], explicit: false, @@ -427,16 +434,79 @@ pub fn lower_with_forms( module_labels: HashMap::new(), module_line_labels: HashMap::new(), globals: Vec::new(), + commons: Vec::new(), data: Vec::new(), data_marks_name: HashMap::new(), data_marks_line: HashMap::new(), hir_procs: Vec::new(), isam_typ: HashMap::new(), - forms: catalog, + forms: catalog.clone(), + module_name: module.name.clone(), event_procs: Vec::new(), - }; - let hir = s.run(module); - (hir, s.diags) + } +} + +/// Exportdeklarationen mit den Typen und Konstanten ihres Ursprungsmoduls. +/// Rümpfe werden hier nicht übersetzt; dafür bleibt der reguläre Sema-Durchlauf zuständig. +pub fn export_declarations(module: &Module, constants: &[Stmt]) -> Vec { + let mut s = new_sema(module, &FormCatalog::default()); + let mut scope = Scope::default(); + let mut declarations = Vec::new(); + let local: HashSet<_> = module + .body + .iter() + .flat_map(|stmt| match stmt { + Stmt::ConstDecl { items, .. } => items.iter().map(|i| i.0.as_str()).collect(), + _ => Vec::new(), + }) + .collect(); + for stmt in constants { + if let Stmt::ConstDecl { items, .. } = stmt { + if !local.contains(items[0].0.as_str()) { + s.lower_stmt(stmt, &mut scope, &mut Vec::new()); + } + } + } + for stmt in &module.body { + match stmt { + Stmt::DefType { .. } => s.lower_stmt(stmt, &mut scope, &mut Vec::new()), + Stmt::ConstDecl { items, pos } => { + for item in items { + let declaration = Stmt::ConstDecl { + items: vec![item.clone()], + pos: *pos, + }; + s.lower_stmt(&declaration, &mut scope, &mut Vec::new()); + let value = match s.consts.get(&item.0).and_then(|(_, value)| value.as_ref()) { + Some(ConstVal::Num(n)) => Expr::DoubleLit(*n, item.2.pos()), + Some(ConstVal::Str(value)) => Expr::StrLit(value.clone(), item.2.pos()), + None => item.2.clone(), + }; + declarations.push(Stmt::ConstDecl { + items: vec![(item.0.clone(), item.1, value)], + pos: *pos, + }); + } + } + Stmt::TypeDecl { .. } => declarations.push(stmt.clone()), + _ => {} + } + } + for proc in &module.procs { + let mut sig = proc.sig.clone(); + if sig.suffix.is_none() && sig.kind == ProcKind::Function { + sig.suffix = + Suffix::from_char(s.var_key(&sig.name, &None, false).chars().last().unwrap()); + } + for param in &mut sig.params { + if param.suffix.is_none() && param.as_type.is_none() { + param.suffix = + Suffix::from_char(s.var_key(¶m.name, &None, false).chars().last().unwrap()); + } + } + declarations.push(Stmt::Declare { sig, pos: proc.pos }); + } + declarations } struct Sema { @@ -467,6 +537,7 @@ struct Sema { module_line_labels: HashMap, /// Globale Slots (Modulvariablen, STATICs, versteckte Temps). globals: Vec, + commons: Vec, /// DATA-Konstanten des Moduls (aus dem Prescan, statisch). data: Vec, data_marks_name: HashMap, @@ -474,12 +545,14 @@ struct Sema { /// Fertige HIR-Prozeduren nach Id (0 = Hauptprogramm). hir_procs: Vec>, forms: FormCatalog, + module_name: String, event_procs: Vec, } impl Sema { fn err(&mut self, pos: SourcePos, msg: impl Into) { self.diags.push(Diagnostic { + file: None, pos, message: msg.into(), }); @@ -489,7 +562,10 @@ impl Sema { // Pass 1: Prozeduren, DECLAREs und TYPEs registrieren. for stmt in &module.body { match stmt { - Stmt::Declare { sig, .. } => self.register_proc(sig), + Stmt::Declare { sig, pos } => self.register_proc(sig, *pos), + Stmt::DefType { .. } => { + self.lower_stmt(stmt, &mut Scope::default(), &mut Vec::new()) + } Stmt::TypeDecl { name, fields, pos } => { let mut hfields = Vec::new(); for (fname, ftype) in fields { @@ -514,8 +590,12 @@ impl Sema { _ => {} } } + let mut defined = HashSet::new(); for proc in &module.procs { - self.register_proc(&proc.sig); + if !defined.insert(proc.sig.name.clone()) { + self.err(proc.pos, "Duplicate definition"); + } + self.register_proc(&proc.sig, proc.pos); } for proc in &module.procs { self.register_event_proc(proc); @@ -524,6 +604,8 @@ impl Sema { self.hir_procs .resize_with(self.next_proc_id as usize, || None); + self.deftypes = [const { None }; 26]; + // Pass 2: Modulrumpf. Labels, Zeilennummern und DATA-Positionen // werden vorab eingesammelt (RESTORE nach vorn, statisches DATA). let mut scope = Scope { @@ -550,7 +632,9 @@ impl Sema { // Pass 3: Prozedurrümpfe. for proc in &module.procs { + let deftypes = self.deftypes.clone(); self.lower_proc(proc); + self.deftypes = deftypes; } // Zusammensetzen: fehlende Rümpfe (nur DECLARE) behalten ihre @@ -574,7 +658,7 @@ impl Sema { name: format!("ARG{index}"), ty: self.h_ty(ty), array: *array, - by_ref: !array && !matches!(ty, Ty::Udt(_)), + by_ref: !array, }) .collect::>(); let mut locals = params @@ -614,6 +698,7 @@ impl Sema { Some(hir::HirModule { name: module.name.clone(), globals: std::mem::take(&mut self.globals), + commons: std::mem::take(&mut self.commons), udts: std::mem::take(&mut self.udt_defs), procs, data: std::mem::take(&mut self.data), @@ -638,11 +723,11 @@ impl Sema { let hty = self.h_ty(&ty); let slot = scope.locals.len() as u16; scope.locals.push(hir::HVar { - name: p.name.clone(), + name: key.trim_end_matches("\u{1}AS").to_string(), ty: hty.clone(), array: p.array, }); - let by_ref = !p.array && !matches!(ty, Ty::Udt(_)); + let by_ref = !p.array; scope.vars.insert( key, VarInfo { @@ -669,7 +754,7 @@ impl Sema { let key = self.var_key(&proc.sig.name, &proc.sig.suffix, false); let slot = scope.locals.len() as u16; scope.locals.push(hir::HVar { - name: proc.sig.name.clone(), + name: key.clone(), ty: self.h_ty(&ret), array: false, }); @@ -721,7 +806,7 @@ impl Sema { .insert(n.clone(), self.data.len() as u32); } } - Stmt::LineNumber(n) => { + Stmt::LineNumber(n, _) => { let id = scope.new_label(); scope.line_labels.insert(*n, id); if module_data { @@ -745,7 +830,7 @@ impl Sema { .. } => { self.prescan(then_body, scope, module_data); - for (_, b) in elseifs { + for (_, b, _) in elseifs { self.prescan(b, scope, module_data); } if let Some(b) = else_body { @@ -773,18 +858,24 @@ impl Sema { } } - fn register_proc(&mut self, sig: &ProcSig) { + fn register_proc(&mut self, sig: &ProcSig, pos: SourcePos) { let ret = match sig.kind { ProcKind::Function => self.name_ty(&sig.name, &sig.suffix), ProcKind::Sub => Ty::Unknown, }; - let params = sig + let params: Vec<_> = sig .params .iter() .map(|p| (self.param_ty(p), p.array)) .collect(); let id = match self.procs.get(&sig.name) { - Some(p) => p.id, + Some(p) => { + let id = p.id; + if p.kind != sig.kind || p.ret != ret || p.params != params { + self.err(pos, "Parameter type mismatch"); + } + id + } None => { let id = self.next_proc_id; self.next_proc_id += 1; @@ -895,14 +986,9 @@ impl Sema { fn event_binding(&self, name: &str) -> Option<(u16, ObjectClass, String)> { let (base, event) = name.rsplit_once('_')?; let found = if base == "FORM" { - self.forms - .objects - .iter() - .enumerate() - .find(|(_, o)| o.class == ObjectClass::Form) - .map(|(i, o)| (i as u16, o)) + self.current_form() } else { - self.forms.find(base) + self.find_object(base) }?; Some((found.0, found.1.class, event.to_string())) } @@ -916,12 +1002,68 @@ impl Sema { } } + fn current_form(&self) -> Option<(u16, &forms::FormObject)> { + self.forms + .find(&self.module_name) + .filter(|(_, o)| o.class == ObjectClass::Form) + .or_else(|| { + let mut forms = self + .forms + .objects + .iter() + .enumerate() + .filter(|(_, o)| o.class == ObjectClass::Form); + let (id, object) = forms.next()?; + forms.next().is_none().then_some((id as u16, object)) + }) + } + + fn find_object(&self, name: &str) -> Option<(u16, &forms::FormObject)> { + self.forms + .find_in(name, &self.module_name) + .or_else(|| { + self.forms + .find(name) + .filter(|(_, o)| o.class == ObjectClass::Form || o.class == ObjectClass::Screen) + }) + .or_else(|| { + let mut matches = self + .forms + .objects + .iter() + .enumerate() + .filter(|(_, o)| o.name.eq_ignore_ascii_case(name)); + let (id, object) = matches.next()?; + matches.next().is_none().then_some((id as u16, object)) + }) + } + + fn scoped_object( + &mut self, + name: &str, + parent: Option<&str>, + pos: SourcePos, + ) -> Option<(u16, forms::FormObject)> { + if let Some(parent) = parent { + if let Some((id, object)) = self.forms.find_in(name, parent) { + return Some((id, object.clone())); + } + self.err( + pos, + format!("Control '{name}' not found in form '{parent}'"), + ); + None + } else { + self.object_by_name(name, pos) + } + } + fn object_by_name( &mut self, name: &str, pos: SourcePos, ) -> Option<(u16, crate::forms::FormObject)> { - if let Some((id, object)) = self.forms.find(name) { + if let Some((id, object)) = self.find_object(name) { return Some((id, object.clone())); } self.err(pos, format!("Unknown object '{name}'")); @@ -936,14 +1078,9 @@ impl Sema { if self.declared_var(scope, name).is_some() || self.consts.contains_key(name) { return None; } - let (object, form) = self - .forms - .objects - .iter() - .enumerate() - .find(|(_, object)| object.class == ObjectClass::Form)?; + let (object, form) = self.current_form()?; let (property, spec) = forms::property(form.class, name)?; - Some((object as u16, property, spec)) + Some((object, property, spec)) } fn object_property( @@ -958,17 +1095,7 @@ impl Sema { let (object, member) = path.split_once('.')?; (object, member, None) }; - let (id, object) = self.object_by_name(object_name, pos)?; - if let Some(parent) = parent { - let parent_ok = self.forms.belongs_to(&object, parent); - if !parent_ok { - self.err( - pos, - format!("Control '{object_name}' not found in form '{parent}'"), - ); - return None; - } - } + let (id, object) = self.scoped_object(object_name, parent, pos)?; let Some((property, spec)) = forms::property(object.class, member) else { self.err( pos, @@ -991,16 +1118,7 @@ impl Sema { let (object, member) = path.split_once('.')?; (object, member, None) }; - let (id, object) = self.object_by_name(object_name, pos)?; - if let Some(parent) = parent { - if !self.forms.belongs_to(&object, parent) { - self.err( - pos, - format!("Control '{object_name}' not found in form '{parent}'"), - ); - return None; - } - } + let (id, object) = self.scoped_object(object_name, parent, pos)?; Some((id, object, member.to_string())) } @@ -1118,7 +1236,7 @@ impl Sema { fn alloc_global(&mut self, name: &str, ty: &Ty, array: bool) -> u16 { let slot = self.globals.len() as u16; self.globals.push(hir::HVar { - name: name.to_string(), + name: name.trim_end_matches("\u{1}AS").to_string(), ty: self.h_ty(ty), array, }); @@ -1138,7 +1256,7 @@ impl Sema { } else { let slot = scope.locals.len() as u16; scope.locals.push(hir::HVar { - name: name.to_string(), + name: name.trim_end_matches("\u{1}AS").to_string(), ty: self.h_ty(ty), array, }); @@ -1255,7 +1373,7 @@ impl Sema { let ty = self.name_ty(name, suffix); // DEF FN: freie Namen binden an Modulvariablen (Vorbild-Semantik). let info = if scope.is_def_fn { - let slot = self.alloc_global(name, &ty, array); + let slot = self.alloc_global(&key, &ty, array); let info = VarInfo { ty, array, @@ -1267,7 +1385,7 @@ impl Sema { self.module_vars.insert(key, info.clone()); info } else { - let (slot, global) = self.alloc_var(scope, name, &ty, array); + let (slot, global) = self.alloc_var(scope, &key, &ty, array); let info = VarInfo { ty, array, @@ -1285,12 +1403,12 @@ impl Sema { /// Konstantenfaltung für `CONST`-Ausdrücke. fn fold_const(&self, e: &Expr) -> Option { match e { - Expr::IntLit(v) => Some(ConstVal::Num(*v as f64)), - Expr::LongLit(v) => Some(ConstVal::Num(*v as f64)), - Expr::SingleLit(v) => Some(ConstVal::Num(*v as f64)), - Expr::DoubleLit(v) => Some(ConstVal::Num(*v)), - Expr::CurrencyLit(v) => Some(ConstVal::Num(*v as f64 / 10_000.0)), - Expr::StrLit(s) => Some(ConstVal::Str(s.clone())), + Expr::IntLit(v, _) => Some(ConstVal::Num(*v as f64)), + Expr::LongLit(v, _) => Some(ConstVal::Num(*v as f64)), + Expr::SingleLit(v, _) => Some(ConstVal::Num(*v as f64)), + Expr::DoubleLit(v, _) => Some(ConstVal::Num(*v)), + Expr::CurrencyLit(v, _) => Some(ConstVal::Num(*v as f64 / 10_000.0)), + Expr::StrLit(s, _) => Some(ConstVal::Str(s.clone())), Expr::Paren(e) => self.fold_const(e), Expr::Name { name, args: None, .. @@ -1400,15 +1518,18 @@ impl Sema { // ---- Anweisungen ------------------------------------------------------- fn lower_body(&mut self, stmts: &[Stmt], scope: &mut Scope) -> Vec { + let enclosing = (scope.current_line, scope.current_pos); let mut out = Vec::new(); for stmt in stmts { self.lower_stmt(stmt, scope, &mut out); } + (scope.current_line, scope.current_pos) = enclosing; out } fn push(&mut self, out: &mut Vec, scope: &Scope, kind: HStmtKind) { out.push(HStmt { + pos: scope.current_pos, line: scope.current_line, kind, }); @@ -1419,6 +1540,7 @@ impl Sema { let pos = stmt_pos(stmt); if pos.line > 0 { scope.current_line = pos.line; + scope.current_pos = pos; } match stmt { Stmt::Data { .. } @@ -1430,7 +1552,7 @@ impl Sema { self.push(out, scope, HStmtKind::Label(id)); } } - Stmt::LineNumber(n) => { + Stmt::LineNumber(n, _) => { if let Some(id) = scope.line_labels.get(n).copied() { self.push(out, scope, HStmtKind::Label(id)); } @@ -1637,7 +1759,7 @@ impl Sema { } return; } - if self.forms.find(name).is_some() { + if self.find_object(name).is_some() { self.err(*pos, format!("Object '{name}' has no default property")); self.lower_expr(value, scope); return; @@ -1895,11 +2017,12 @@ impl Sema { Some(b) => self.lower_body(b, scope), None => Vec::new(), }; - for (ec, eb) in elseifs.iter().rev() { + for (ec, eb, elseif_pos) in elseifs.iter().rev() { let c2 = self.lower_cond(ec, scope); let body2 = self.lower_body(eb, scope); let stmt = HStmt { - line: scope.current_line, + pos: *elseif_pos, + line: elseif_pos.line, kind: HStmtKind::If { cond: c2, then: body2, @@ -1920,6 +2043,7 @@ impl Sema { step, body, pos, + end_pos, } => { let (place, vt) = self.lower_place(var, scope); if !is_num(&vt) { @@ -1956,6 +2080,7 @@ impl Sema { ty, from: from_e, to: to_e, + end_pos: *end_pos, step: step_e, limit_slot, step_slot, @@ -1966,7 +2091,11 @@ impl Sema { } } Stmt::DoLoop { - pre, post, body, .. + pre, + post, + body, + end_pos, + .. } => { let pre_c = pre.as_ref().map(|(u, c)| (*u, self.lower_cond(c, scope))); let exit_label = scope.new_label(); @@ -1978,6 +2107,7 @@ impl Sema { out, scope, HStmtKind::Loop { + end_pos: *end_pos, pre: pre_c, post: post_c, body: hbody, @@ -1985,7 +2115,12 @@ impl Sema { }, ); } - Stmt::While { cond, body, .. } => { + Stmt::While { + cond, + body, + end_pos, + .. + } => { let c = self.lower_cond(cond, scope); let exit_label = scope.new_label(); let hbody = self.lower_body(body, scope); @@ -1993,6 +2128,7 @@ impl Sema { out, scope, HStmtKind::Loop { + end_pos: *end_pos, pre: Some((false, c)), post: None, body: hbody, @@ -2120,8 +2256,11 @@ impl Sema { let ty = self.decl_ty(d); let key = self.var_key(&d.name, &d.suffix, d.as_type.is_some()); if let Entry::Vacant(entry) = scope.vars.entry(key) { - let slot = - self.alloc_global(&format!("STATIC.{}", d.name), &ty, d.dims.is_some()); + let slot = self.alloc_global( + &format!("STATIC.{}", entry.key()), + &ty, + d.dims.is_some(), + ); entry.insert(VarInfo { ty, array: d.dims.is_some(), @@ -2133,7 +2272,12 @@ impl Sema { } } } - Stmt::CommonDecl { shared, decls, .. } => { + Stmt::CommonDecl { + shared, + decls, + block, + .. + } => { for d in decls { if *shared && scope.is_module { self.shared_vars.insert(self.var_key( @@ -2143,6 +2287,30 @@ impl Sema { )); } self.declare(d, false, scope, out); + if scope.is_module { + let key = self.var_key(&d.name, &d.suffix, d.as_type.is_some()); + let slot = self.module_vars[&key].slot; + let dims = d.dims.as_ref().map(|dims| { + dims.iter() + .map(|(lo, hi)| { + let lower = + lo.as_ref().map_or(Some(self.option_base as i32), |e| { + self.common_bound(e) + }); + let upper = self.common_bound(hi); + (lower, upper) + }) + .collect() + }); + self.commons.push(hir::HCommon { + slot, + block: block.clone(), + key, + ty: self.globals[slot as usize].ty.clone(), + dims, + pos: d.pos, + }); + } } } Stmt::SharedDecl { decls, .. } => { @@ -2153,7 +2321,7 @@ impl Sema { Some(v) => v.clone(), None => { let ty = self.decl_ty(d); - let slot = self.alloc_global(&d.name, &ty, d.dims.is_some()); + let slot = self.alloc_global(&key, &ty, d.dims.is_some()); let v = VarInfo { ty, array: d.dims.is_some(), @@ -2255,7 +2423,7 @@ impl Sema { body, pos, } => { - self.lower_def_fn(name, suffix, params, None, Some(body), scope, *pos); + self.lower_def_fn(name, suffix, params, None, Some(body), *pos); } Stmt::DefFnBlock { name, @@ -2264,7 +2432,7 @@ impl Sema { body, pos, } => { - self.lower_def_fn(name, suffix, params, Some(body), None, scope, *pos); + self.lower_def_fn(name, suffix, params, Some(body), None, *pos); } // ---- Datei-E/A (Grammatik Phase 1, Laufzeit Phase 3) ---- Stmt::Open { @@ -2318,7 +2486,7 @@ impl Sema { ne, HExpr::Str(table.clone()), HExpr::Str(spalten), - HExpr::Lng(udt as i32), + HExpr::UdtId(udt), ], }, ); @@ -2848,7 +3016,7 @@ impl Sema { ); // Arme in Bedingungs-/Rumpf-Paare übersetzen; CASE ELSE gesondert. - let mut cases: Vec<(HExpr, Vec)> = Vec::new(); + let mut cases: Vec<(HExpr, Vec, SourcePos)> = Vec::new(); let mut else_body: Vec = Vec::new(); for arm in arms { let body = self.lower_body(&arm.body, scope); @@ -2869,13 +3037,14 @@ impl Sema { }, }); } - cases.push((cond.unwrap(), body)); + cases.push((cond.unwrap(), body, arm.pos)); } // Von hinten zu verschachteltem If zusammensetzen. let mut els = else_body; - for (cond, body) in cases.into_iter().rev() { + for (cond, body, pos) in cases.into_iter().rev() { let stmt = HStmt { - line: scope.current_line, + pos, + line: pos.line, kind: HStmtKind::If { cond, then: body, @@ -2947,7 +3116,6 @@ impl Sema { } } - #[allow(clippy::too_many_arguments)] fn lower_def_fn( &mut self, name: &str, @@ -2955,7 +3123,6 @@ impl Sema { params: &[Param], block: Option<&[Stmt]>, single: Option<&Expr>, - scope: &mut Scope, pos: SourcePos, ) { // Registrierung (mit Prozedur-Id). @@ -2981,12 +3148,12 @@ impl Sema { def_fn: true, }, ); - let _ = pos; - // Rumpf-Scope: Parameter (BYVAL) und Rückgabevariable lokal, // freie Namen binden an Modulvariablen. let mut fscope = Scope { is_def_fn: true, + current_line: pos.line, + current_pos: pos, ..Scope::default() }; let mut hparams = Vec::new(); @@ -2995,7 +3162,7 @@ impl Sema { let key = self.var_key(&p.name, &p.suffix, false); let slot = fscope.locals.len() as u16; fscope.locals.push(hir::HVar { - name: p.name.clone(), + name: key.trim_end_matches("\u{1}AS").to_string(), ty: self.h_ty(&ty), array: false, }); @@ -3020,7 +3187,7 @@ impl Sema { let ret_key = self.var_key(name, suffix, false); let ret_slot_idx = fscope.locals.len() as u16; fscope.locals.push(hir::HVar { - name: name.to_string(), + name: name.trim_end_matches("\u{1}AS").to_string(), ty: self.h_ty(&ret), array: false, }); @@ -3045,6 +3212,7 @@ impl Sema { let (e, et) = self.lower_expr(expr, &mut fscope); let value = self.coerce(e, &et, &ret, expr.pos()); vec![HStmt { + pos: fscope.current_pos, line: fscope.current_line, kind: HStmtKind::Assign { place: HPlace { @@ -3072,14 +3240,24 @@ impl Sema { body, label_count: fscope.next_label, }; - // Modul-Scope-Zeile weiterführen. - scope.current_line = scope.current_line.max(fscope.current_line); while self.hir_procs.len() <= id as usize { self.hir_procs.push(None); } self.hir_procs[id as usize] = Some(hproc); } + fn common_bound(&self, expr: &Expr) -> Option { + match self.fold_const(expr)? { + ConstVal::Num(n) + if n.is_finite() + && (i32::MIN as f64..=i32::MAX as f64).contains(&n.round_ties_even()) => + { + Some(n.round_ties_even() as i32) + } + _ => None, + } + } + fn declare(&mut self, d: &VarDecl, redim: bool, scope: &mut Scope, out: &mut Vec) { let ty = self.decl_ty(d); let mut hdims = Vec::new(); @@ -3120,7 +3298,7 @@ impl Sema { return; } } - let (slot, global) = self.alloc_var(scope, &d.name, &ty, is_array); + let (slot, global) = self.alloc_var(scope, &key, &ty, is_array); self.insert_var( scope, key, @@ -3249,13 +3427,7 @@ impl Sema { return; } if matches!(name, "SHOW" | "HIDE") && args.is_empty() { - if let Some((object, _)) = self - .forms - .objects - .iter() - .enumerate() - .find(|(_, object)| object.class == ObjectClass::Form) - { + if let Some((object, _)) = self.current_form() { let method = forms::methods(ObjectClass::Form) .iter() .position(|method| *method == name) @@ -3264,7 +3436,7 @@ impl Sema { out, scope, HStmtKind::ObjectMethod { - object: object as u16, + object, index: None, method, args: Vec::new(), @@ -3343,20 +3515,15 @@ impl Sema { pos: object_pos, } = &args[0] { - if let Some((object, info)) = - self.forms.find(object_name).map(|(i, o)| (i, o.clone())) - { - if index.is_some() && !info.array { - self.err( - *object_pos, - format!("Object '{object_name}' is not an array"), - ); + let (parent, object_name) = object_name + .split_once('!') + .map_or((None, object_name.as_str()), |(parent, name)| { + (Some(parent), name) + }); + if let Some((object, _)) = self.scoped_object(object_name, parent, *object_pos) { + let Ok(index) = self.object_index(object, index, scope, *object_pos) else { return; - } - let index = index - .as_ref() - .and_then(|a| a.first()) - .map(|e| self.lower_num_as(e, scope, NumTy::Lng)); + }; self.push( out, scope, @@ -3368,7 +3535,6 @@ impl Sema { ); return; } - self.err(*object_pos, format!("Unknown object '{object_name}'")); return; } } @@ -3514,7 +3680,7 @@ impl Sema { if self.consts.contains_key(name) { return false; } - if self.forms.find(name).is_some() + if (suffix.is_none() && self.find_object(name).is_some()) || (name.contains('!') && !self.is_udt_path(scope, name)) { return false; @@ -3978,7 +4144,7 @@ impl Sema { /// Greift nur bei Stringliteralen; ein zur Laufzeit gebildeter Gerätename /// bleibt der Datei-E/A überlassen. fn reject_com_device(&mut self, file: &Expr, pos: SourcePos) { - if let Expr::StrLit(s) = file { + if let Expr::StrLit(s, _) = file { if ist_com_geraet(s) { self.err(pos, "Feature unavailable"); } @@ -4108,7 +4274,7 @@ impl Sema { return ( Some(HPlace { base: base_slot, - base_is_ref: false, + base_is_ref: info.by_ref, indices: indices.unwrap_or_default(), fields: fpath, ty: hty, @@ -4157,12 +4323,12 @@ impl Sema { fn lower_expr(&mut self, e: &Expr, scope: &mut Scope) -> (HExpr, Ty) { match e { - Expr::IntLit(v) => (HExpr::Int(*v), Ty::Int), - Expr::LongLit(v) => (HExpr::Lng(*v), Ty::Lng), - Expr::SingleLit(v) => (HExpr::Sng(*v), Ty::Sng), - Expr::DoubleLit(v) => (HExpr::Dbl(*v), Ty::Dbl), - Expr::CurrencyLit(v) => (HExpr::Cur(*v), Ty::Cur), - Expr::StrLit(s) => (HExpr::Str(s.clone()), Ty::Str), + Expr::IntLit(v, _) => (HExpr::Int(*v), Ty::Int), + Expr::LongLit(v, _) => (HExpr::Lng(*v), Ty::Lng), + Expr::SingleLit(v, _) => (HExpr::Sng(*v), Ty::Sng), + Expr::DoubleLit(v, _) => (HExpr::Dbl(*v), Ty::Dbl), + Expr::CurrencyLit(v, _) => (HExpr::Cur(*v), Ty::Cur), + Expr::StrLit(s, _) => (HExpr::Str(s.clone()), Ty::Str), Expr::Paren(inner) => self.lower_expr(inner, scope), Expr::Missing => (HExpr::Int(0), Ty::Unknown), Expr::TypeOf { value, class, pos } => { @@ -4659,7 +4825,11 @@ impl Sema { ); return (HExpr::Int(0), Ty::Unknown); } - if let Some((object, info)) = self.forms.find(name).map(|(id, info)| (id, info.clone())) { + if let Some((object, info)) = self + .find_object(name) + .filter(|_| suffix.is_none()) + .map(|(id, info)| (id, info.clone())) + { if args.is_none() || info.array { let Ok(index) = self.object_index(object, args, scope, pos) else { return (HExpr::Int(0), Ty::Unknown); @@ -5296,7 +5466,7 @@ impl Sema { } /// Position einer Anweisung (für Zeileninfo der Anweisungsgrenzen). -fn stmt_pos(stmt: &Stmt) -> SourcePos { +pub fn stmt_pos(stmt: &Stmt) -> SourcePos { match stmt { Stmt::Assign { pos, .. } | Stmt::Print { pos, .. } @@ -5352,7 +5522,8 @@ fn stmt_pos(stmt: &Stmt) -> SourcePos { | Stmt::End(pos) | Stmt::StopStmt(pos) | Stmt::System(pos) => *pos, - Stmt::Label(_) | Stmt::LineNumber(_) => SourcePos::default(), + Stmt::LineNumber(_, pos) => *pos, + Stmt::Label(_) => SourcePos::default(), } } @@ -5377,10 +5548,10 @@ fn trap_art(device: &str) -> Option { /// Konstanter Zahlenwert eines Ausdrucks, soweit direkt ablesbar. fn const_zahl(e: &Expr) -> Option { match e { - Expr::IntLit(n) => Some(*n as i64), - Expr::LongLit(n) => Some(*n as i64), - Expr::SingleLit(n) => Some(*n as i64), - Expr::DoubleLit(n) => Some(*n as i64), + Expr::IntLit(n, _) => Some(*n as i64), + Expr::LongLit(n, _) => Some(*n as i64), + Expr::SingleLit(n, _) => Some(*n as i64), + Expr::DoubleLit(n, _) => Some(*n as i64), Expr::Paren(inner) => const_zahl(inner), _ => None, } diff --git a/crates/tb-frontend/src/source.rs b/crates/tb-frontend/src/source.rs new file mode 100644 index 0000000..16620dc --- /dev/null +++ b/crates/tb-frontend/src/source.rs @@ -0,0 +1,96 @@ +//! Quellen bleiben auch nach Include-Expansion einem Modul und einer Datei zugeordnet. +use crate::{ast, lexer, parser, Diagnostic, SourcePos}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceFile { + pub module: u16, + pub path: String, +} + +#[derive(Debug, Clone)] +pub struct SourceSegment { + pub file: String, + pub first_line: u32, + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct SourceUnit { + pub name: String, + pub segments: Vec, +} + +impl SourceUnit { + pub fn new(name: &str, file: &str, text: &str) -> Self { + Self { + name: name.into(), + segments: vec![SourceSegment { + file: file.into(), + first_line: 1, + text: text.into(), + }], + } + } + + pub fn parse( + &self, + module: u16, + sources: &mut Vec, + ) -> (ast::Module, Vec) { + let mut text = String::new(); + let mut origins = Vec::new(); + for segment in &self.segments { + let file = SourceFile { + module, + path: segment.file.clone(), + }; + let source = sources.iter().position(|s| s == &file).unwrap_or_else(|| { + sources.push(file); + sources.len() - 1 + }) as u32; + for (line, part) in segment.text.split_inclusive('\n').enumerate() { + origins.push(SourcePos { + source, + line: segment.first_line + line as u32, + column: 1, + }); + text.push_str(part); + if !part.ends_with('\n') { + text.push('\n'); + } + } + } + let map = |pos: &mut SourcePos| { + let origin = origins + .get(pos.line.saturating_sub(1) as usize) + .copied() + .or_else(|| { + origins.last().map(|p| SourcePos { + line: p.line + 1, + ..*p + }) + }) + .unwrap_or_default(); + pos.source = origin.source; + pos.line = origin.line; + }; + let mut lexed = lexer::lex(&text); + for token in &mut lexed.tokens { + map(&mut token.pos); + } + for diagnostic in &mut lexed.diagnostics { + map(&mut diagnostic.pos); + } + let parsed = parser::parse(&self.name, &lexed.tokens); + lexed.diagnostics.extend(parsed.diagnostics); + (parsed.module, lexed.diagnostics) + } +} + +pub fn locate_diagnostics(diagnostics: &mut [Diagnostic], sources: &[SourceFile]) { + for diagnostic in diagnostics { + diagnostic.file = sources + .get(diagnostic.pos.source as usize) + .map(|s| s.path.clone()); + } +} diff --git a/crates/tb-ui/src/forms.rs b/crates/tb-ui/src/forms.rs index 8539a8f..b32ad18 100644 --- a/crates/tb-ui/src/forms.rs +++ b/crates/tb-ui/src/forms.rs @@ -252,12 +252,7 @@ impl FormsModel { return Ok(PropertyValue::Integer(value.saturating_sub(2))); } if spec.name == "PARENT" { - let parent = obj.description.parent_form.as_deref().and_then(|name| { - self.objects - .iter() - .position(|candidate| candidate.description.name.eq_ignore_ascii_case(name)) - .map(|id| (id as u16, None)) - }); + let parent = obj.description.parent.map(|id| (id, None)); return Ok(PropertyValue::Object(parent)); } Ok(obj.properties[property as usize].clone()) @@ -834,12 +829,7 @@ impl FormsModel { if obj.description.class == ObjectClass::Form { return Some(current); } - let parent = obj.description.parent_form.as_deref()?; - current = self - .objects - .iter() - .position(|candidate| candidate.description.name.eq_ignore_ascii_case(parent))? - as u16; + current = obj.description.parent?; } None } @@ -959,7 +949,7 @@ impl FormsModel { } fn select_option(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { - let parent = self.instance(key)?.description.parent_form.clone(); + let parent = self.instance(key)?.description.parent; let value_id = forms::property(ObjectClass::OptionButton, "VALUE") .unwrap() .0 as usize; @@ -969,7 +959,7 @@ impl FormsModel { } let same_group = self.instance(other).is_ok_and(|obj| { obj.description.class == ObjectClass::OptionButton - && obj.description.parent_form == parent + && obj.description.parent == parent }); if same_group { self.instance_mut(other)?.properties[value_id] = PropertyValue::Integer(0); @@ -1006,12 +996,8 @@ impl FormsModel { && self .instance(key) .ok() - .and_then(|obj| obj.description.parent_form.as_deref()) - .and_then(|parent| { - self.objects - .iter() - .find(|obj| obj.description.name.eq_ignore_ascii_case(parent)) - }) + .and_then(|obj| obj.description.parent) + .and_then(|parent| self.objects.get(parent as usize)) .is_some_and(|parent| parent.description.class == ObjectClass::Form); if invalid || title_shortcut { Err(RuntimeError::ILLEGAL_FUNCTION_CALL) @@ -1325,19 +1311,12 @@ impl FormsModel { } fn menu_children(&self, parent: ObjectKey) -> Vec { - let Ok(parent) = self.instance(parent) else { - return Vec::new(); - }; self.keys() .into_iter() .filter(|key| { self.instance(*key).is_ok_and(|object| { object.description.class == ObjectClass::Menu - && object - .description - .parent_form - .as_deref() - .is_some_and(|name| name.eq_ignore_ascii_case(&parent.description.name)) + && object.description.parent == Some(parent.0) }) && self.boolean(*key, "VISIBLE") }) .collect() @@ -1385,23 +1364,15 @@ impl FormsModel { } if shift & umschalt::ALT != 0 { if let Some(ch) = key.chars().next().map(|ch| ch.to_ascii_uppercase()) { - if let Some(target) = - self.form_controls(form).into_iter().find(|candidate| { - Self::access_key(&self.string(*candidate, "CAPTION")) == Some(ch) - && self.boolean(*candidate, "ENABLED") - && self.boolean(*candidate, "VISIBLE") - && self.instance(*candidate).is_ok_and(|obj| { - obj.description.class != ObjectClass::Menu - || obj.description.parent_form.as_deref().is_some_and( - |parent| { - parent.eq_ignore_ascii_case( - &self.objects[form as usize].description.name, - ) - }, - ) - }) - }) - { + if let Some(target) = self.form_controls(form).into_iter().find(|candidate| { + Self::access_key(&self.string(*candidate, "CAPTION")) == Some(ch) + && self.boolean(*candidate, "ENABLED") + && self.boolean(*candidate, "VISIBLE") + && self.instance(*candidate).is_ok_and(|obj| { + obj.description.class != ObjectClass::Menu + || obj.description.parent == Some(form) + }) + }) { if self .instance(target) .is_ok_and(|obj| obj.description.class == ObjectClass::Menu) @@ -1580,21 +1551,14 @@ impl FormsModel { let mut top = self.integer(key, "TOP").unwrap_or(0).max(0) as usize + 1; let width = self.integer(key, "WIDTH").unwrap_or(1).max(1) as usize; let height = self.integer(key, "HEIGHT").unwrap_or(1).max(1) as usize; - let mut parent = obj.description.parent_form.as_deref(); + let mut parent = obj.description.parent; for _ in 0..self.objects.len() { - let Some(name) = parent else { break }; - let Some((id, parent_obj)) = self - .objects - .iter() - .enumerate() - .find(|(_, candidate)| candidate.description.name.eq_ignore_ascii_case(name)) - else { - break; - }; - let parent_key = (id as u16, None); + let Some(id) = parent else { break }; + let parent_obj = self.objects.get(id as usize)?; + let parent_key = (id, None); left += self.integer(parent_key, "LEFT").unwrap_or(0).max(0) as usize; top += self.integer(parent_key, "TOP").unwrap_or(0).max(0) as usize; - parent = parent_obj.description.parent_form.as_deref(); + parent = parent_obj.description.parent; } Some((left, top, width, height)) } @@ -2357,12 +2321,7 @@ impl FormsModel { && self .instance(key) .ok() - .and_then(|obj| obj.description.parent_form.as_deref()) - .is_some_and(|parent| { - parent.eq_ignore_ascii_case( - &self.objects[form as usize].description.name, - ) - }) + .is_some_and(|obj| obj.description.parent == Some(form)) { if self.boolean(key, "VISIBLE") { let caption = format!(" {} ", self.caption(key)); @@ -2419,20 +2378,12 @@ impl FormsModel { .is_some_and(|root| self.root_form(*root) == Some(form)) { let mut popup_left = left + 1; - for root in - self.form_controls(form).into_iter().filter(|key| { - self.instance(*key).is_ok_and(|object| { - object.description.class == ObjectClass::Menu - && object.description.parent_form.as_deref().is_some_and( - |parent| { - parent.eq_ignore_ascii_case( - &self.objects[form as usize].description.name, - ) - }, - ) - }) && self.boolean(*key, "VISIBLE") - }) - { + for root in self.form_controls(form).into_iter().filter(|key| { + self.instance(*key).is_ok_and(|object| { + object.description.class == ObjectClass::Menu + && object.description.parent == Some(form) + }) && self.boolean(*key, "VISIBLE") + }) { if Some(&root) == self.menu_path.first() { break; } diff --git a/crates/tb-ui/src/frm.rs b/crates/tb-ui/src/frm.rs index 1983df2..dfba3a3 100644 --- a/crates/tb-ui/src/frm.rs +++ b/crates/tb-ui/src/frm.rs @@ -56,9 +56,7 @@ impl FormFile { } else { let array = forms::property(node.class, "INDEX") .and_then(|(property, _)| node.properties.get(&property)) - .is_some_and( - |value| matches!(value, PropertyValue::Integer(index) if *index != 0), - ); + .is_some(); catalog.add(&node.name, node.class, parent, array); } for child in &node.children { @@ -70,44 +68,99 @@ impl FormFile { catalog } - pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> { - fn apply_node( + pub fn code_line(&self) -> u32 { + self.original.as_ref().map_or(1, |original| { + original.bytes[..original.bytes.len() - original.code.len()] + .bytes() + .filter(|b| *b == b'\n') + .count() as u32 + + 1 + }) + } + + pub fn initial_values( + &self, + catalog: &FormCatalog, + ) -> Result, tb_runtime::errors::RuntimeError> { + fn collect( node: &FormNode, - model: &mut FormsModel, - menu_depth: usize, + parent: Option, + catalog: &FormCatalog, + out: &mut Vec, + depth: usize, ) -> Result<(), tb_runtime::errors::RuntimeError> { - let menu_depth = if node.class == ObjectClass::Menu { - menu_depth + 1 - } else { - menu_depth - }; - if menu_depth > 6 { - return Err(tb_runtime::errors::RuntimeError::ILLEGAL_FUNCTION_CALL); + let depth = depth + usize::from(node.class == ObjectClass::Menu); + if depth > 6 { + return Err(tb_runtime::errors::RuntimeError(5)); } - let object = model + let object = catalog .objects .iter() - .position(|candidate| candidate.description.name.eq_ignore_ascii_case(&node.name)) + .position(|o| { + o.name.eq_ignore_ascii_case(&node.name) + && o.parent == parent + && o.class == node.class + }) .ok_or(tb_runtime::errors::RuntimeError(420))? as u16; let index = forms::property(node.class, "INDEX") - .and_then(|(property, _)| node.properties.get(&property)) - .and_then(|value| match value { - PropertyValue::Integer(value) => Some(*value), - _ => None, + .and_then(|(id, _)| node.properties.get(&id)) + .and_then(|v| { + if let PropertyValue::Integer(n) = v { + Some(*n) + } else { + None + } }) .unwrap_or(0); - if index != 0 && !model.is_loaded_at(object, Some(index)) { - model.load_design_array(object, index)?; - } - for (property, value) in &node.properties { - model.set_initial_at(object, Some(index), *property, value.clone())?; + if out.iter().any(|v| v.object == object && v.index == index) { + return Err(tb_runtime::errors::RuntimeError(5)); } + out.push(FormInitial { + object, + index, + properties: node.properties.clone(), + }); for child in &node.children { - apply_node(child, model, menu_depth)?; + collect(child, Some(object), catalog, out, depth)?; } Ok(()) } - apply_node(&self.root, model, 0) + let mut values = Vec::new(); + collect(&self.root, None, catalog, &mut values, 0)?; + Ok(values) + } + + pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> { + let catalog = FormCatalog { + objects: model + .objects + .iter() + .map(|o| o.description.clone()) + .collect(), + }; + for initial in self.initial_values(&catalog)? { + initial.apply(model)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct FormInitial { + pub object: u16, + pub index: i32, + pub properties: BTreeMap, +} + +impl FormInitial { + pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> { + if self.index != 0 && !model.is_loaded_at(self.object, Some(self.index)) { + model.load_design_array(self.object, self.index)?; + } + for (property, value) in &self.properties { + model.set_initial_at(self.object, Some(self.index), *property, value.clone())?; + } + Ok(()) } } diff --git a/crates/tb-vm/benches/compile.rs b/crates/tb-vm/benches/compile.rs index 24f9674..9a54681 100644 --- a/crates/tb-vm/benches/compile.rs +++ b/crates/tb-vm/benches/compile.rs @@ -35,12 +35,13 @@ fn generate_module(n_blocks: usize, seed: usize) -> String { } fn compile_all(sources: &[(String, String)]) -> usize { - let mut total = 0; - for (name, src) in sources { - let m = tb_vm::compile_source(name, src).expect("Benchmark-Quelle muss kompilieren"); - total += m.procs.iter().map(|p| p.code.len()).sum::(); - } - total + let units: Vec<_> = sources + .iter() + .map(|(name, src)| tb_frontend::source::SourceUnit::new(name, &format!("{name}.bas"), src)) + .collect(); + let project = tb_vm::compile_project("BENCH", &units, &Default::default(), &[]) + .expect("Benchmark-Projekt muss kompilieren"); + project.procs.iter().map(|p| p.code.len()).sum() } fn main() { diff --git a/crates/tb-vm/src/bytecode.rs b/crates/tb-vm/src/bytecode.rs index eb7a0fe..1470845 100644 --- a/crates/tb-vm/src/bytecode.rs +++ b/crates/tb-vm/src/bytecode.rs @@ -9,11 +9,14 @@ use std::fmt; use std::rc::Rc; use tb_frontend::forms::{FormObject, ObjectClass}; -use tb_frontend::hir::HEventProc; +use tb_frontend::hir::{HEventProc, HParam, HProcKind, HTy, NumTy}; +use tb_frontend::source::SourceFile; use tb_runtime::value::{TypeInit, UdtLayout}; +use tb_ui::forms::PropertyValue; +use tb_ui::frm::FormInitial; pub const TBC_MAGIC: &[u8; 4] = b"TBC\0"; -pub const TBC_VERSION: u16 = 3; +pub const TBC_VERSION: u16 = 4; /// Vergleichsoperator (Operand der `Cmp*`-Instruktionen). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -75,8 +78,15 @@ impl<'a> Reader<'a> { pub fn new(buf: &'a [u8]) -> Self { Reader { buf, pos: 0 } } + fn finish(&self) -> Result<(), LoadError> { + if self.pos != self.buf.len() { + Err(LoadError::Corrupt("überzählige Abschnittsdaten")) + } else { + Ok(()) + } + } fn take(&mut self, n: usize) -> Result<&'a [u8], LoadError> { - if self.pos + n > self.buf.len() { + if n > self.buf.len().saturating_sub(self.pos) { return Err(LoadError::Corrupt("unerwartetes Dateiende")); } let s = &self.buf[self.pos..self.pos + n]; @@ -138,7 +148,11 @@ impl Enc for bool { out.push(*self as u8); } fn dec(r: &mut Reader) -> Result { - Ok(r.u8()? != 0) + match r.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(LoadError::Corrupt("bool")), + } } } @@ -170,6 +184,9 @@ impl Enc for TypeInit { fn dec(r: &mut Reader) -> Result { let tag = r.u8()?; let extra = r.u32()?; + if (tag != 6 && tag != 7 && extra != 0) || (tag == 7 && extra > u16::MAX as u32) { + return Err(LoadError::Corrupt("TypeInit-Zusatz")); + } Ok(match tag { 0 => TypeInit::Int, 1 => TypeInit::Lng, @@ -224,6 +241,8 @@ instrs! { 0x03 StopInstr; 0x04 SystemInstr; 0x05 Unsupported(a: u16); // Name im Stringpool → Fehler 73 + 0x06 Source(a: u32, b: u32); // Quelldatei-ID, physische Spalte der folgenden Stmt-Grenze + 0x07 InitStmt(a: u32); // globale Initialisierung: Quellort/Debugger, noch keine Ereignisse // 0x10 — Konstanten und Stack 0x10 PushInt(a: i16); @@ -234,6 +253,7 @@ instrs! { 0x15 PushStr(a: u16); 0x16 Dup; 0x17 Pop; + 0x18 PushUdtId(a: u16); // TYPE-Index als LONG auf dem Stack (ISAM) // 0x20 — Variablen und Referenzen 0x20 LoadGlobal(a: u16); @@ -260,6 +280,8 @@ instrs! { 0x39 ArrBound(a: bool); // true = LBOUND 0x3A FixStr(a: u32); // auf feste Länge kürzen/padden + 0x3B CommonArr(a: bool, b: u16, c: u8, d: TypeInit); // gemeinsame Initialisierung/Layoutprüfung + // 0x40 — Arithmetik (monomorph) 0x40 AddI2; 0x41 AddI4; 0x42 AddR4; 0x43 AddR8; 0x44 AddCy; 0x45 SubI2; 0x46 SubI4; 0x47 SubR4; 0x48 SubR8; 0x49 SubCy; @@ -355,10 +377,122 @@ instrs! { 0xD6 LsetRset(a: bool); // rset?; Stack: Referenz, Wert // argc, line_mode — Dateinummer liegt unter den Referenzen } +impl Enc for HTy { + fn enc(&self, out: &mut Vec) { + let tag = match self { + HTy::Num(NumTy::Int) => 0, + HTy::Num(NumTy::Lng) => 1, + HTy::Num(NumTy::Sng) => 2, + HTy::Num(NumTy::Dbl) => 3, + HTy::Num(NumTy::Cur) => 4, + HTy::Str => 5, + HTy::FixedStr(_) => 6, + HTy::Udt(_) => 7, + HTy::Form => 8, + HTy::Control => 9, + }; + out.push(tag); + match self { + HTy::FixedStr(n) => n.enc(out), + HTy::Udt(n) => n.enc(out), + _ => {} + } + } + fn dec(r: &mut Reader) -> Result { + Ok(match r.u8()? { + 0 => HTy::Num(NumTy::Int), + 1 => HTy::Num(NumTy::Lng), + 2 => HTy::Num(NumTy::Sng), + 3 => HTy::Num(NumTy::Dbl), + 4 => HTy::Num(NumTy::Cur), + 5 => HTy::Str, + 6 => HTy::FixedStr(r.u32()?), + 7 => HTy::Udt(r.u16()?), + 8 => HTy::Form, + 9 => HTy::Control, + _ => return Err(LoadError::Corrupt("Signaturtyp")), + }) + } +} + +impl Enc for PropertyValue { + fn enc(&self, out: &mut Vec) { + match self { + Self::Integer(v) => { + out.push(0); + v.enc(out); + } + Self::Single(v) => { + out.push(1); + v.enc(out); + } + Self::String(v) => { + out.push(2); + w_string(out, v); + } + Self::Boolean(v) => { + out.push(3); + v.enc(out); + } + Self::Object(v) => { + out.push(4); + v.is_some().enc(out); + if let Some((object, index)) = v { + object.enc(out); + index.is_some().enc(out); + if let Some(index) = index { + index.enc(out); + } + } + } + Self::IntegerArray(v) => { + out.push(5); + (v.len() as u32).enc(out); + for v in v { + v.enc(out); + } + } + } + } + fn dec(r: &mut Reader) -> Result { + Ok(match r.u8()? { + 0 => Self::Integer(i32::dec(r)?), + 1 => Self::Single(f32::dec(r)?), + 2 => Self::String(r.string()?), + 3 => Self::Boolean(bool::dec(r)?), + 4 => Self::Object(if bool::dec(r)? { + Some(( + r.u16()?, + if bool::dec(r)? { + Some(i32::dec(r)?) + } else { + None + }, + )) + } else { + None + }), + 5 => { + let n = r.u32()?; + let mut values = Vec::new(); + for _ in 0..n { + values.push(i32::dec(r)?); + } + Self::IntegerArray(values) + } + _ => return Err(LoadError::Corrupt("Anfangswerttyp")), + }) + } +} + // ---- Modulstruktur ------------------------------------------------------------ #[derive(Debug, Clone)] pub struct ProcCode { + pub module: u16, + pub kind: HProcKind, + pub params: Vec, + pub ret_ty: Option, pub name: String, pub n_params: u16, /// Initialisierung aller Frame-Slots (Parameter zuerst; deren Init @@ -378,6 +512,10 @@ pub struct DataItem { /// Übersetztes Modul — Inhalt des `.tbc`-Containers. #[derive(Debug)] pub struct CompiledModule { + pub modules: Vec<(String, u8)>, + pub sources: Vec, + pub form_initial: Vec, + pub startup_form: Option, pub name: String, /// `OPTION BASE` (Untergrenze impliziter Arrays). pub option_base: u8, @@ -402,9 +540,204 @@ fn w_string(out: &mut Vec, s: &str) { } impl CompiledModule { + pub fn validate(&self) -> Result<(), LoadError> { + let bad = || LoadError::Corrupt("ungültige Tabellenreferenz oder Anfangsdaten"); + if self.procs.is_empty() + || self.sources.is_empty() + || self.modules.is_empty() + || self.modules.len() > u16::MAX as usize + || self.procs.len() > u16::MAX as usize + || self.objects.len() >= u16::MAX as usize + || self.strings.len() >= u16::MAX as usize + || self.globals_init.len() > u16::MAX as usize + || self.global_names.len() != self.globals_init.len() + || self.modules.iter().any(|(_, base)| *base > 1) + || self.option_base != self.modules[0].1 + || self + .sources + .iter() + .any(|s| s.module as usize >= self.modules.len()) + { + return Err(bad()); + } + let ty_ok = + |ty: &TypeInit| !matches!(ty, TypeInit::Udt(id) if *id as usize >= self.udts.len()); + let sig_ok = |ty: &HTy| !matches!(ty, HTy::Udt(id) if *id as usize >= self.udts.len()); + if self.globals_init.iter().any(|t| !ty_ok(t)) { + return Err(bad()); + } + for (id, udt) in self.udts.iter().enumerate() { + if udt + .fields + .iter() + .any(|t| matches!(t, TypeInit::Udt(n) if *n as usize >= id)) + { + return Err(bad()); + } + } + for (id, o) in self.objects.iter().enumerate() { + if o.parent.is_some_and(|parent| parent as usize >= id) { + return Err(bad()); + } + if o.parent_form.as_deref() + != o.parent + .map(|parent| self.objects[parent as usize].name.as_str()) + { + return Err(bad()); + } + } + if self.startup_form.is_some_and(|id| { + self.objects + .get(id as usize) + .is_none_or(|o| o.class != ObjectClass::Form) + }) { + return Err(bad()); + } + for e in &self.event_procs { + if e.object as usize >= self.objects.len() || e.proc as usize >= self.procs.len() { + return Err(bad()); + } + } + let mut initials = std::collections::HashSet::new(); + for initial in &self.form_initial { + let object = self.objects.get(initial.object as usize).ok_or_else(bad)?; + if !initials.insert((initial.object, initial.index)) + || (initial.index != 0 && !object.array) + { + return Err(bad()); + } + for (id, value) in &initial.properties { + use tb_frontend::forms::PropertyType as T; + let spec = tb_frontend::forms::properties(object.class) + .get(*id as usize) + .copied() + .ok_or_else(bad)?; + let valid = match (value, spec.ty) { + (PropertyValue::Integer(v), T::Integer) => { + !spec.min.is_some_and(|min| *v < min) + && !spec.max.is_some_and(|max| *v > max) + && (spec.name != "INDEX" || *v == initial.index) + } + (PropertyValue::Single(v), T::Single) => v.is_finite(), + (PropertyValue::String(_), T::String) + | (PropertyValue::Boolean(_), T::Boolean) + | (PropertyValue::IntegerArray(_), T::IntegerArray) => true, + (PropertyValue::Object(v), T::Object) => { + v.is_none_or(|(id, _)| (id as usize) < self.objects.len()) + } + _ => false, + }; + if !valid { + return Err(bad()); + } + } + } + for p in &self.procs { + if p.module as usize >= self.modules.len() + || p.n_params as usize != p.params.len() + || p.params.len() > p.locals_init.len() + || p.locals_init.len() != p.local_names.len() + || p.locals_init.iter().any(|t| !ty_ok(t)) + || p.params.iter().any(|p| !sig_ok(&p.ty)) + || p.ret_ty.as_ref().is_some_and(|t| !sig_ok(t)) + || matches!(p.kind, HProcKind::Function | HProcKind::DefFn) != p.ret_ty.is_some() + || p.params.iter().zip(&p.locals_init).any(|(param, init)| { + *init + != if param.array { + TypeInit::Empty + } else { + crate::codegen::type_init(¶m.ty) + } + }) + { + return Err(bad()); + } + for instruction in &p.code { + use Instr::*; + let valid = match instruction { + Source(id, _) => (*id as usize) < self.sources.len(), + PushUdtId(id) => (*id as usize) < self.udts.len(), + PushStr(id) | Unsupported(id) => (*id as usize) < self.strings.len(), + LoadGlobal(id) | StoreGlobal(id) | MakeRefGlobal(id) => { + (*id as usize) < self.globals_init.len() + } + LoadLocal(id) | StoreLocal(id) | MakeRefLocal(id) | LoadRef(id) + | StoreRef(id) => (*id as usize) < p.locals_init.len(), + LoadArr(global, id, _, ty) + | DimArr(global, id, _, ty) + | CommonArr(global, id, _, ty) + | RedimArr(global, id, _, ty) => { + ty_ok(ty) + && (*id as usize) + < if *global { + self.globals_init.len() + } else { + p.locals_init.len() + } + } + EraseSlot(global, id) => { + (*id as usize) + < if *global { + self.globals_init.len() + } else { + p.locals_init.len() + } + } + Call(id, argc) => self + .procs + .get(*id as usize) + .is_some_and(|p| p.n_params == *argc as u16), + Jump(pc) | JumpIfFalse(pc) | JumpIfTrue(pc) | Gosub(pc) | RetGosubTo(pc) + | OnErrorLocal(pc) | ResumeLabel(pc) => (*pc as usize) < p.code.len(), + OnErrorGoto(pc) => self + .procs + .first() + .is_some_and(|p| (*pc as usize) < p.code.len()), + OnJump(id, _) => self + .jump_tables + .get(*id as usize) + .is_some_and(|t| t.iter().all(|pc| (*pc as usize) < p.code.len())), + Restore(id) => (*id as usize) <= self.data.len(), + Input(_, _, id, _) => *id == u16::MAX || (*id as usize) < self.strings.len(), + LoadObjectProperty(id, prop, _) + | StoreObjectProperty(id, prop, _) + | LoadObjectIndexedProperty(id, prop) + | StoreObjectIndexedProperty(id, prop) => { + self.objects.get(*id as usize).is_some_and(|o| { + ((*prop & 0x7fff) as usize) + < tb_frontend::forms::properties(o.class).len() + }) + } + PushObject(id, _) | ObjectLoad(id, _, _) => (*id as usize) < self.objects.len(), + ObjectMethod(id, method, _) | ObjectMethodFn(id, method, _) => { + self.objects.get(*id as usize).is_some_and(|o| { + (*method as usize) < tb_frontend::forms::methods(o.class).len() + }) + } + LoadDynamicObjectProperty(name) | StoreDynamicObjectProperty(name) => { + (*name as usize) < self.strings.len() + } + GetPut(_, _, 7, id) => (*id as usize) < self.udts.len(), + GetPut(_, _, kind, _) => *kind <= 8, + TypeOf(class) => ObjectClass::from_id(*class).is_some(), + TrapDefine(kind, pc) => *kind <= 3 && (*pc as usize) < p.code.len(), + TrapDisable(kind) => *kind <= 3, + TrapSet(kind, state) => *kind <= 3 && *state <= 2, + Run(kind) => *kind <= 2, + ReadData(kind) => *kind <= 1, + _ => true, + }; + if !valid { + return Err(bad()); + } + } + } + Ok(()) + } + /// `.tbc`-Container schreiben: Magic, Version, Flags, Abschnittstabelle - /// (Kennung/Offset/Länge), Abschnitte MODN, CONS, TYPS, GLOB, PROC - /// (mit eingebettetem Code und Zeileninfo), DATA, JMPT. + /// (Kennung/Offset/Länge), Abschnitte MODN, SRCS, CONS, TYPS, GLOB, PROC + /// (mit eingebettetem Code und Quellorten), DATA, JMPT, OBJS. pub fn to_tbc(&self) -> Vec { let mut sections: Vec<([u8; 4], Vec)> = Vec::new(); @@ -413,6 +746,19 @@ impl CompiledModule { modn.push(self.option_base); sections.push((*b"MODN", modn)); + let mut srcs = Vec::new(); + (self.modules.len() as u32).enc(&mut srcs); + for (name, base) in &self.modules { + w_string(&mut srcs, name); + base.enc(&mut srcs); + } + (self.sources.len() as u32).enc(&mut srcs); + for source in &self.sources { + source.module.enc(&mut srcs); + w_string(&mut srcs, &source.path); + } + sections.push((*b"SRCS", srcs)); + let mut cons = Vec::new(); cons.extend_from_slice(&(self.strings.len() as u32).to_le_bytes()); for s in &self.strings { @@ -443,6 +789,19 @@ impl CompiledModule { proc.extend_from_slice(&(self.procs.len() as u32).to_le_bytes()); for p in &self.procs { w_string(&mut proc, &p.name); + p.module.enc(&mut proc); + (p.kind as u8).enc(&mut proc); + (p.params.len() as u32).enc(&mut proc); + for param in &p.params { + w_string(&mut proc, ¶m.name); + param.ty.enc(&mut proc); + param.array.enc(&mut proc); + param.by_ref.enc(&mut proc); + } + p.ret_ty.is_some().enc(&mut proc); + if let Some(ty) = &p.ret_ty { + ty.enc(&mut proc); + } proc.extend_from_slice(&p.n_params.to_le_bytes()); proc.extend_from_slice(&(p.locals_init.len() as u32).to_le_bytes()); for (init, name) in p.locals_init.iter().zip(&p.local_names) { @@ -484,6 +843,7 @@ impl CompiledModule { objs.push(o.class.id()); w_string(&mut objs, o.parent_form.as_deref().unwrap_or("")); objs.push(o.array as u8); + o.parent.unwrap_or(u16::MAX).enc(&mut objs); } objs.extend_from_slice(&(self.event_procs.len() as u32).to_le_bytes()); for e in &self.event_procs { @@ -491,6 +851,17 @@ impl CompiledModule { w_string(&mut objs, &e.event); objs.extend_from_slice(&e.proc.to_le_bytes()); } + self.startup_form.unwrap_or(u16::MAX).enc(&mut objs); + (self.form_initial.len() as u32).enc(&mut objs); + for initial in &self.form_initial { + initial.object.enc(&mut objs); + initial.index.enc(&mut objs); + (initial.properties.len() as u32).enc(&mut objs); + for (id, value) in &initial.properties { + id.enc(&mut objs); + value.enc(&mut objs); + } + } sections.push((*b"OBJS", objs)); // Header + Abschnittstabelle @@ -527,8 +898,13 @@ impl CompiledModule { if version != TBC_VERSION { return Err(LoadError::Version(version)); } - let _flags = r.u16()?; + if r.u16()? != 0 { + return Err(LoadError::Corrupt("Header-Flags")); + } let n_sections = r.u32()? as usize; + if n_sections != 9 { + return Err(LoadError::Corrupt("Abschnittsanzahl")); + } let mut table = Vec::new(); for _ in 0..n_sections { let id: [u8; 4] = r.take(4)?.try_into().unwrap(); @@ -536,6 +912,27 @@ impl CompiledModule { let len = r.u32()? as usize; table.push((id, off, len)); } + let expected = [ + *b"MODN", *b"SRCS", *b"CONS", *b"TYPS", *b"GLOB", *b"PROC", *b"DATA", *b"JMPT", + *b"OBJS", + ]; + let mut seen = std::collections::HashSet::new(); + let mut ranges = Vec::new(); + for (id, off, len) in &table { + if !expected.contains(id) + || !seen.insert(*id) + || *off < r.pos + || *off > buf.len() + || *len > buf.len() - *off + { + return Err(LoadError::Corrupt("Abschnittstabelle")); + } + ranges.push((*off, off + len)); + } + ranges.sort_unstable(); + if ranges.windows(2).any(|pair| pair[0].1 > pair[1].0) { + return Err(LoadError::Corrupt("überlappende Abschnitte")); + } let section = |id: &[u8; 4]| -> Result { for (sid, off, len) in &table { if sid == id { @@ -552,44 +949,86 @@ impl CompiledModule { let name = r.string()?; let option_base = r.u8()?; + r.finish()?; + let mut r = section(b"SRCS")?; + let n = r.u32()?; + let mut modules = Vec::new(); + for _ in 0..n { + modules.push((r.string()?, r.u8()?)); + } + let n = r.u32()?; + let mut sources = Vec::new(); + for _ in 0..n { + sources.push(SourceFile { + module: r.u16()?, + path: r.string()?, + }); + } + r.finish()?; let mut r = section(b"CONS")?; let n = r.u32()? as usize; - let mut strings = Vec::with_capacity(n); + let mut strings = Vec::new(); for _ in 0..n { strings.push(Rc::from(r.string()?.as_str())); } + r.finish()?; let mut r = section(b"TYPS")?; let n = r.u32()? as usize; - let mut udts = Vec::with_capacity(n); + let mut udts = Vec::new(); for _ in 0..n { let name = r.string()?; let nf = r.u32()? as usize; - let mut fields = Vec::with_capacity(nf); + let mut fields = Vec::new(); for _ in 0..nf { fields.push(TypeInit::dec(&mut r)?); } udts.push(UdtLayout { name, fields }); } + r.finish()?; let mut r = section(b"GLOB")?; let n = r.u32()? as usize; - let mut globals_init = Vec::with_capacity(n); - let mut global_names = Vec::with_capacity(n); + let mut globals_init = Vec::new(); + let mut global_names = Vec::new(); for _ in 0..n { globals_init.push(TypeInit::dec(&mut r)?); global_names.push(r.string()?); } + r.finish()?; let mut r = section(b"PROC")?; let n = r.u32()? as usize; - let mut procs = Vec::with_capacity(n); + let mut procs = Vec::new(); for _ in 0..n { let name = r.string()?; + let module = r.u16()?; + let kind = match r.u8()? { + 0 => HProcKind::Main, + 1 => HProcKind::Sub, + 2 => HProcKind::Function, + 3 => HProcKind::DefFn, + _ => return Err(LoadError::Corrupt("Prozedurart")), + }; + let np = r.u32()?; + let mut params = Vec::new(); + for _ in 0..np { + params.push(HParam { + name: r.string()?, + ty: HTy::dec(&mut r)?, + array: bool::dec(&mut r)?, + by_ref: bool::dec(&mut r)?, + }); + } + let ret_ty = if bool::dec(&mut r)? { + Some(HTy::dec(&mut r)?) + } else { + None + }; let n_params = r.u16()?; let nl = r.u32()? as usize; - let mut locals_init = Vec::with_capacity(nl); - let mut local_names = Vec::with_capacity(nl); + let mut locals_init = Vec::new(); + let mut local_names = Vec::new(); for _ in 0..nl { locals_init.push(TypeInit::dec(&mut r)?); local_names.push(r.string()?); @@ -598,11 +1037,16 @@ impl CompiledModule { let code_len = r.u32()? as usize; let code_bytes = r.take(code_len)?; let mut cr = Reader::new(code_bytes); - let mut code = Vec::with_capacity(n_instr); + let mut code = Vec::new(); for _ in 0..n_instr { code.push(Instr::decode(&mut cr)?); } + cr.finish()?; procs.push(ProcCode { + module, + kind, + params, + ret_ty, name, n_params, locals_init, @@ -611,44 +1055,49 @@ impl CompiledModule { }); } + r.finish()?; let mut r = section(b"DATA")?; let n = r.u32()? as usize; - let mut data = Vec::with_capacity(n); + let mut data = Vec::new(); for _ in 0..n { let text = r.string()?; let line = r.u32()?; data.push(DataItem { text, line }); } + r.finish()?; let mut r = section(b"JMPT")?; let n = r.u32()? as usize; - let mut jump_tables = Vec::with_capacity(n); + let mut jump_tables = Vec::new(); for _ in 0..n { let m = r.u32()? as usize; - let mut t = Vec::with_capacity(m); + let mut t = Vec::new(); for _ in 0..m { t.push(r.u32()?); } jump_tables.push(t); } + r.finish()?; let mut r = section(b"OBJS")?; let n = r.u32()? as usize; - let mut objects = Vec::with_capacity(n); + let mut objects = Vec::new(); 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; + let array = bool::dec(&mut r)?; + let parent_id = r.u16()?; objects.push(FormObject { name, class, parent_form: (!parent.is_empty()).then_some(parent), + parent: (parent_id != u16::MAX).then_some(parent_id), array, }); } let n = r.u32()? as usize; - let mut event_procs = Vec::with_capacity(n); + let mut event_procs = Vec::new(); for _ in 0..n { event_procs.push(HEventProc { object: r.u16()?, @@ -657,7 +1106,35 @@ impl CompiledModule { }); } - Ok(CompiledModule { + let startup = r.u16()?; + let startup_form = (startup != u16::MAX).then_some(startup); + let n = r.u32()?; + let mut form_initial = Vec::new(); + for _ in 0..n { + let object = r.u16()?; + let index = i32::dec(&mut r)?; + let n = r.u32()?; + let mut properties = std::collections::BTreeMap::new(); + for _ in 0..n { + if properties + .insert(r.u16()?, PropertyValue::dec(&mut r)?) + .is_some() + { + return Err(LoadError::Corrupt("doppelte Anfangseigenschaft")); + } + } + form_initial.push(FormInitial { + object, + index, + properties, + }); + } + r.finish()?; + let module = CompiledModule { + modules, + sources, + form_initial, + startup_form, name, option_base, strings, @@ -669,7 +1146,9 @@ impl CompiledModule { jump_tables, objects, event_procs, - }) + }; + module.validate()?; + Ok(module) } } @@ -712,6 +1191,13 @@ mod tests { #[test] fn tbc_roundtrip() { let m = CompiledModule { + modules: vec![("TEST".into(), 1)], + sources: vec![SourceFile { + module: 0, + path: "test.bas".into(), + }], + form_initial: vec![], + startup_form: None, name: "TEST".into(), option_base: 1, strings: vec![Rc::from("Hallo"), Rc::from("Welt")], @@ -722,6 +1208,10 @@ mod tests { fields: vec![TypeInit::FixedStr(30), TypeInit::Dbl], }], procs: vec![ProcCode { + module: 0, + kind: HProcKind::Main, + params: vec![], + ret_ty: None, name: "TEST".into(), n_params: 0, locals_init: vec![], @@ -751,6 +1241,13 @@ mod tests { #[test] fn unbekannte_version_wird_abgelehnt() { let m = CompiledModule { + modules: vec![("TEST".into(), 0)], + sources: vec![SourceFile { + module: 0, + path: "test.bas".into(), + }], + form_initial: vec![], + startup_form: None, name: "T".into(), option_base: 0, strings: vec![], diff --git a/crates/tb-vm/src/codegen.rs b/crates/tb-vm/src/codegen.rs index 781d459..345856b 100644 --- a/crates/tb-vm/src/codegen.rs +++ b/crates/tb-vm/src/codegen.rs @@ -16,6 +16,12 @@ use tb_runtime::value::TypeInit; pub fn compile(hir: &HirModule) -> CompiledModule { let mut cg = Codegen { + common_arrays: hir + .commons + .iter() + .filter(|c| c.dims.is_some()) + .map(|c| c.slot) + .collect(), strings: Vec::new(), string_ids: HashMap::new(), jump_tables: Vec::new(), @@ -30,6 +36,13 @@ pub fn compile(hir: &HirModule) -> CompiledModule { procs.push(cg.compile_proc(proc)); } CompiledModule { + modules: vec![(hir.name.clone(), hir.option_base)], + sources: vec![tb_frontend::source::SourceFile { + module: 0, + path: hir.name.clone(), + }], + form_initial: vec![], + startup_form: None, name: hir.name.clone(), option_base: hir.option_base, strings: cg.strings, @@ -67,7 +80,7 @@ fn slot_init(v: &hir::HVar) -> TypeInit { } } -fn type_init(t: &HTy) -> TypeInit { +pub(crate) fn type_init(t: &HTy) -> TypeInit { match t { HTy::Num(NumTy::Int) => TypeInit::Int, HTy::Num(NumTy::Lng) => TypeInit::Lng, @@ -82,6 +95,7 @@ fn type_init(t: &HTy) -> TypeInit { } struct Codegen { + common_arrays: std::collections::HashSet, strings: Vec>, string_ids: HashMap, jump_tables: Vec>, @@ -95,6 +109,7 @@ struct Codegen { } struct ProcCtx { + pos: tb_frontend::SourcePos, code: Vec, /// LabelId → Instruktionsindex. label_pc: Vec>, @@ -134,6 +149,10 @@ impl ProcCtx { self.label_pc[label as usize] = Some(pc); } fn emit(&mut self, i: Instr) { + if matches!(i, Instr::Stmt(_) | Instr::InitStmt(_)) { + self.code + .push(Instr::Source(self.pos.source, self.pos.column)); + } self.code.push(i); } /// Sprunginstruktion mit noch unbekanntem Ziel emittieren. @@ -162,6 +181,7 @@ impl Codegen { fn compile_proc(&mut self, proc: &hir::HProc) -> ProcCode { let mut ctx = ProcCtx { + pos: tb_frontend::SourcePos::default(), code: Vec::new(), label_pc: vec![None; proc.label_count as usize], fixups: Vec::new(), @@ -238,6 +258,10 @@ impl Codegen { } ProcCode { + module: 0, + kind: proc.kind, + params: proc.params.clone(), + ret_ty: proc.ret_ty.clone(), name: proc.name.clone(), n_params: proc.params.len() as u16, locals_init: proc.locals.iter().map(slot_init).collect(), @@ -268,13 +292,14 @@ impl Codegen { } fn stmt_inner(&mut self, ctx: &mut ProcCtx, proc: &hir::HProc, stmt: &HStmt, boundary: bool) { + ctx.pos = stmt.pos; match &stmt.kind { HStmtKind::Label(l) => { ctx.bind(*l); return; } _ if boundary => ctx.emit(Instr::Stmt(stmt.line)), - _ => {} + _ => ctx.emit(Instr::InitStmt(stmt.line)), } match &stmt.kind { HStmtKind::Label(_) => unreachable!(), @@ -478,6 +503,7 @@ impl Codegen { } } HStmtKind::Loop { + end_pos, pre, post, body, @@ -496,6 +522,8 @@ impl Codegen { for s in body { self.stmt(ctx, proc, s); } + ctx.pos = *end_pos; + ctx.emit(Instr::Stmt(end_pos.line)); match post { Some((is_until, cond)) => { self.expr(ctx, cond); @@ -511,6 +539,7 @@ impl Codegen { ctx.bind(*exit_label); } HStmtKind::For { + end_pos, var, ty, from, @@ -533,7 +562,7 @@ impl Codegen { *step_slot, body, *exit_label, - stmt.line, + *end_pos, ); } HStmtKind::Goto(l) => ctx.emit_jump(Instr::Jump(0), *l), @@ -663,6 +692,8 @@ impl Codegen { let init = type_init(elem); if *redim { ctx.emit(Instr::RedimArr(global, s, dims.len() as u8, init)); + } else if global && self.common_arrays.contains(&s) { + ctx.emit(Instr::CommonArr(global, s, dims.len() as u8, init)); } else { ctx.emit(Instr::DimArr(global, s, dims.len() as u8, init)); } @@ -697,7 +728,7 @@ impl Codegen { step_slot: Option, body: &[HStmt], exit_label: u16, - line: u32, + end_pos: tb_frontend::SourcePos, ) { // Startwert, Grenze, ggf. Schritt einmal auswerten. self.store_place(ctx, var, |cg, ctx| cg.expr(ctx, from)); @@ -715,15 +746,6 @@ impl Codegen { let l_test = ctx.new_label(); let l_body = ctx.new_label(); ctx.bind(l_test); - // Anweisungsgrenze für den Rücksprung von `NEXT`. Sie kann nicht - // auf die Grenze des `FOR` zeigen — dort stünde die Initialisierung - // noch einmal. - // - // ponytail: gemeldet wird die Zeile des `FOR`, nicht die des - // `NEXT` — die trägt das HIR nicht. Ceiling: braucht der Debugger - // in Phase 5 die genaue Zeile, bekommt `HStmtKind::For` ein Feld - // `next_line`. - ctx.emit(Instr::Stmt(line)); match const_step { Some(s) => { // Vergleichsrichtung zur Compilezeit. @@ -757,7 +779,9 @@ impl Codegen { for s in body { self.stmt(ctx, proc, s); } - // NEXT: inkrementieren, zurück zum Test. + // NEXT: eigene Quellgrenze für Fehler, RESUME und Debugger. + ctx.pos = end_pos; + ctx.emit(Instr::Stmt(end_pos.line)); self.store_place(ctx, var, |cg, ctx| { cg.load_place(ctx, var); match (step, step_slot) { @@ -777,6 +801,7 @@ impl Codegen { match e { HExpr::Int(v) => ctx.emit(Instr::PushInt(*v)), HExpr::Lng(v) => ctx.emit(Instr::PushLng(*v)), + HExpr::UdtId(id) => ctx.emit(Instr::PushUdtId(*id)), HExpr::Sng(v) => ctx.emit(Instr::PushSng(*v)), HExpr::Dbl(v) => ctx.emit(Instr::PushDbl(*v)), HExpr::Cur(v) => ctx.emit(Instr::PushCur(*v)), diff --git a/crates/tb-vm/src/interp.rs b/crates/tb-vm/src/interp.rs index 0f2cf75..5162034 100644 --- a/crates/tb-vm/src/interp.rs +++ b/crates/tb-vm/src/interp.rs @@ -70,6 +70,8 @@ struct Frame { /// Instruktionsindex der zuletzt begonnenen Anweisung (`Stmt`). last_stmt_pc: usize, line: u32, + source: u32, + column: u32, /// Gesetzt, wenn dieser Frame der Handler eines Ereignis-Traps ist. /// Er läuft im Modulrumpf und **teilt dessen Locals** — ein eigener /// Satz würde dem Handler leere Modulvariablen zeigen. Sein `RETURN` @@ -117,14 +119,14 @@ pub struct Vm { erl: u32, /// Zuletzt durchlaufene numerische Zeilennummer (0 = keine). zeile_nr: u32, - module_handler: Handler, + module_handlers: Vec, in_handler: bool, resume_pc: usize, // Steuerung flags: u32, /// Zählt Anweisungsgrenzen für die regelmäßige Ereignisabholung. tick_zaehler: u32, - breakpoints: HashSet, + breakpoints: HashSet<(u16, u32)>, data_ptr: usize, start_pc: Option, pub forms: FormsModel, @@ -170,7 +172,15 @@ impl Vm { .iter() .map(|t| default_value(t, &module.udts)) .collect(); - let forms = FormsModel::new(module.objects.clone(), 80, 25); + let mut forms = FormsModel::new(module.objects.clone(), 80, 25); + for initial in &module.form_initial { + initial + .apply(&mut forms) + .expect("validierte Forms-Anfangsdaten"); + } + if let Some(form) = module.startup_form { + forms.show(form, false).expect("validiertes Startformular"); + } let mut vm = Vm { globals, locals: Vec::new(), @@ -180,7 +190,7 @@ impl Vm { err: 0, erl: 0, zeile_nr: 0, - module_handler: Handler::None, + module_handlers: vec![Handler::None; module.modules.len()], in_handler: false, resume_pc: 0, flags: 0, @@ -202,7 +212,12 @@ impl Vm { let target = self.module.procs[0] .code .iter() - .position(|instruction| matches!(instruction, Instr::Stmt(found) if *found == line)); + .position(|instruction| matches!(instruction, Instr::SetErl(found) if *found == line)) + .or_else(|| { + self.module.procs[0].code.iter().position( + |instruction| matches!(instruction, Instr::Stmt(found) if *found == line), + ) + }); let target = target.ok_or(RuntimeError(8))?; if self.module.procs[0] .code @@ -229,6 +244,8 @@ impl Vm { } let stack_base = self.stack.len(); self.frames.push(Frame { + source: 0, + column: 0, proc, pc: 0, locals_base, @@ -361,7 +378,12 @@ impl Vm { if !matches!(self.module.procs[proc].code.as_slice(), [Instr::RetProc]) { return Ok(false); } - let name = self.module.procs[proc].name.to_ascii_uppercase(); + let name = self.module.procs[proc] + .name + .rsplit('!') + .next() + .unwrap() + .to_ascii_uppercase(); if !matches!( name.as_str(), "CMNDLGREGISTER" @@ -767,6 +789,8 @@ impl Vm { let locals_base = self.frames[0].locals_base; let stack_base = self.stack.len(); self.frames.push(Frame { + source: 0, + column: 0, proc: 0, pc: ziel as usize, locals_base, @@ -821,17 +845,54 @@ impl Vm { } pub fn add_breakpoint(&mut self, line: u32) { - self.breakpoints.insert(line); + self.breakpoints.insert((0, line)); self.flags |= F_BREAK; } pub fn remove_breakpoint(&mut self, line: u32) { - self.breakpoints.remove(&line); + self.breakpoints.remove(&(0, line)); if self.breakpoints.is_empty() { self.flags &= !F_BREAK; } } + pub fn add_module_breakpoint(&mut self, module: u16, line: u32) { + self.breakpoints.insert((module, line)); + self.flags |= F_BREAK; + } + + pub fn remove_module_breakpoint(&mut self, module: u16, line: u32) { + self.breakpoints.remove(&(module, line)); + if self.breakpoints.is_empty() { + self.flags &= !F_BREAK; + } + } + + pub fn current_source_pos(&self) -> tb_frontend::SourcePos { + self.frames + .last() + .map(|f| tb_frontend::SourcePos { + source: f.source, + line: f.line, + column: f.column, + }) + .unwrap_or_default() + } + + pub fn current_module(&self) -> u16 { + self.module + .sources + .get(self.current_source_pos().source as usize) + .map_or(0, |s| s.module) + } + + pub fn current_file(&self) -> &str { + self.module + .sources + .get(self.current_source_pos().source as usize) + .map_or(&self.module.name, |s| &s.path) + } + pub fn current_line(&self) -> u32 { self.frames.last().map(|f| f.line).unwrap_or(0) } @@ -843,26 +904,58 @@ impl Vm { .unwrap_or("") } - /// Variableninspektion (Debugger): erst Locals des obersten Frames - /// (Referenzen werden aufgelöst), dann Modulvariablen. Ein - /// Typ-Suffix (`n%`, `s$`) wird toleriert — Slots tragen Basisnamen. + /// Variableninspektion: lokale Namen vor Modulvariablen, explizite + /// Typ-Suffixe vor einer eindeutigen Suche nach dem Basisnamen. pub fn inspect(&self, name: &str) -> Option { - let name = name.trim_end_matches(['%', '&', '!', '#', '$', '@']); + fn matches(stored: &str, requested: &str) -> bool { + let suffixes = ['%', '&', '!', '#', '$', '@']; + stored.eq_ignore_ascii_case(requested) + || (!(stored.ends_with(suffixes) && requested.ends_with(suffixes)) + && stored + .trim_end_matches(suffixes) + .eq_ignore_ascii_case(requested.trim_end_matches(suffixes))) + } + fn unique(mut ids: impl Iterator) -> Option { + let first = ids.next()?; + ids.next().is_none().then_some(first) + } if let Some(f) = self.frames.last() { let p = &self.module.procs[f.proc]; - for (i, n) in p.local_names.iter().enumerate() { - if n.eq_ignore_ascii_case(name) { - let v = self.locals[f.locals_base + i].clone(); - return Some(self.deref_for_inspect(v)); - } + if let Some(i) = unique( + p.local_names + .iter() + .enumerate() + .filter(|(_, n)| matches(n, name)) + .map(|(i, _)| i), + ) { + return Some(self.deref_for_inspect(self.locals[f.locals_base + i].clone())); } } - for (i, n) in self.module.global_names.iter().enumerate() { - if n.eq_ignore_ascii_case(name) { - return Some(self.deref_for_inspect(self.globals[i].clone())); - } + let qualified = format!( + "{}!{name}", + self.module.modules[self.current_module() as usize].0 + ); + if let Some(id) = unique( + self.module + .global_names + .iter() + .enumerate() + .filter(|(_, n)| matches(n, name) || matches(n, &qualified)) + .map(|(i, _)| i), + ) { + return Some(self.globals[id].clone()); } - None + let id = unique( + self.module + .global_names + .iter() + .enumerate() + .filter(|(_, n)| { + matches(n.split_once('!').map_or(n.as_str(), |(_, name)| name), name) + }) + .map(|(i, _)| i), + )?; + Some(self.globals[id].clone()) } /// Arrayelement inspizieren. @@ -993,8 +1086,15 @@ impl Vm { break; } } - if target.is_none() && self.module_handler != Handler::None { - target = Some((0, self.module_handler)); + if target.is_none() { + for frame in self.frames.iter().rev() { + let module = self.module.sources[frame.source as usize].module as usize; + let handler = self.module_handlers[module]; + if handler != Handler::None { + target = Some((0, handler)); + break; + } + } } let Some((depth, handler)) = target else { return Some(self.error_event(code, line)); @@ -1039,7 +1139,7 @@ impl Vm { let code = &self.module.procs[proc].code; let mut i = from + 1; while i < code.len() { - if matches!(code[i], Instr::Stmt(_)) { + if matches!(code[i], Instr::Stmt(_) | Instr::InitStmt(_)) { return i; } i += 1; @@ -1189,7 +1289,7 @@ impl Vm { match cell { Value::Arr(a) => Ok(a.clone()), Value::Empty => { - let lo = self.module.option_base as i32; + let lo = self.module.modules[self.current_module() as usize].1 as i32; let bounds = vec![(lo, 10); dims as usize]; let arr = ArrayObj::new(elem.clone(), bounds, &self.module.udts)?; let handle = Rc::new(std::cell::RefCell::new(arr)); @@ -1223,7 +1323,9 @@ impl Vm { fn exec(&mut self, instr: Instr, pc: usize, host: &mut dyn Host) -> Result { use Instr as I; match instr { - I::Stmt(line) => { + I::Source(_, _) => Ok(Flow::Normal), + I::Stmt(line) | I::InitStmt(line) => { + let initializing = matches!(instr, I::InitStmt(_)); if line == 0 { if let Some(target) = self.start_pc.take() { self.frames[0].pc = target; @@ -1231,6 +1333,13 @@ impl Vm { } let f = self.frames.last_mut().unwrap(); f.line = line; + if let Some(I::Source(source, column)) = pc + .checked_sub(1) + .and_then(|pc| self.module.procs[f.proc].code.get(pc)) + { + f.source = *source; + f.column = *column; + } f.last_stmt_pc = pc; // Zustellpunkt: anzeigen, wenn sich der Bildschirm geändert // hat, und regelmäßig Ereignisse abholen. Das ist keine @@ -1238,20 +1347,27 @@ impl Vm { // Größenänderungen kämen nie an. self.tick_zaehler = self.tick_zaehler.wrapping_add(1); self.forms.render(&mut self.rt.screen); - if self.rt.screen.ist_veraendert() || self.tick_zaehler.is_multiple_of(1024) { + if !initializing + && (self.rt.screen.ist_veraendert() || self.tick_zaehler.is_multiple_of(1024)) + { self.tick(host); self.rt.screen.veraenderung_quittieren(); } let erster_eintritt = std::mem::take(&mut self.frames.last_mut().unwrap().handler_start); - if !erster_eintritt && self.zustellen(host, Zustellpunkt::Anweisung) { + if !initializing + && !erster_eintritt + && self.zustellen(host, Zustellpunkt::Anweisung) + { return Ok(Flow::Normal); } if self.flags != 0 { if self.flags & F_STEP != 0 { return Ok(Flow::Event(RunEvent::Stepped { line })); } - if self.flags & F_BREAK != 0 && self.breakpoints.contains(&line) { + if self.flags & F_BREAK != 0 + && self.breakpoints.contains(&(self.current_module(), line)) + { return Ok(Flow::Event(RunEvent::Breakpoint { line })); } if self.flags & F_POLL != 0 && self.rt.abbruch { @@ -1328,6 +1444,10 @@ impl Vm { self.push(Value::Lng(v)); Ok(Flow::Normal) } + I::PushUdtId(id) => { + self.push(Value::Lng(id as i32)); + Ok(Flow::Normal) + } I::PushSng(v) => { self.push(Value::Sng(v)); Ok(Flow::Normal) @@ -1716,13 +1836,25 @@ impl Vm { a.data[flat as usize] = v; Ok(Flow::Normal) } - I::DimArr(global, slot, dims, elem) => { + I::DimArr(global, slot, dims, ref elem) + | I::CommonArr(global, slot, dims, ref elem) => { + let common = matches!(instr, I::CommonArr(..)); let bounds = self.pop_bounds(dims)?; let cell = self.slot_value(global, slot); + if common { + if let Value::Arr(array) = cell { + let array = array.borrow(); + return if &array.elem == elem && array.dims == bounds { + Ok(Flow::Normal) + } else { + Err(RuntimeError::TYPE_MISMATCH) + }; + } + } if !matches!(cell, Value::Empty) { return Err(RuntimeError::DUPLICATE_DEFINITION); } - let arr = ArrayObj::new(elem, bounds, &self.module.udts)?; + let arr = ArrayObj::new(elem.clone(), bounds, &self.module.udts)?; *self.slot_value(global, slot) = Value::Arr(Rc::new(std::cell::RefCell::new(arr))); Ok(Flow::Normal) } @@ -2293,7 +2425,8 @@ impl Vm { // ---- Fehlerbehandlung ---- I::OnErrorGoto(t) => { - self.module_handler = Handler::Goto(t); + let module = self.current_module() as usize; + self.module_handlers[module] = Handler::Goto(t); Ok(Flow::Normal) } I::OnErrorLocal(t) => { @@ -2301,7 +2434,8 @@ impl Vm { Ok(Flow::Normal) } I::OnErrorDisable => { - self.module_handler = Handler::None; + let module = self.current_module() as usize; + self.module_handlers[module] = Handler::None; Ok(Flow::Normal) } I::OnErrorLocalDisable => { @@ -2312,7 +2446,8 @@ impl Vm { if local { self.frames.last_mut().unwrap().local_handler = Handler::ResumeNext; } else { - self.module_handler = Handler::ResumeNext; + let module = self.current_module() as usize; + self.module_handlers[module] = Handler::ResumeNext; } Ok(Flow::Normal) } diff --git a/crates/tb-vm/src/lib.rs b/crates/tb-vm/src/lib.rs index df3e69d..9eb6e0b 100644 --- a/crates/tb-vm/src/lib.rs +++ b/crates/tb-vm/src/lib.rs @@ -12,20 +12,20 @@ pub mod bytecode; pub mod codegen; pub mod interp; -use tb_frontend::Diagnostic; +pub mod project; +pub use project::compile_project; +use tb_frontend::{source::SourceUnit, Diagnostic}; -/// Komplette Übersetzung: Quelltext → Bytecode-Modul. -/// Bei Diagnosen (Compile-Fehlern) wird kein Kompilat erzeugt. +/// Einmodul-API auf derselben Pipeline wie vollständige Projekte. pub fn compile_source( module_name: &str, source: &str, ) -> Result> { - let analysis = tb_frontend::analyze_source(module_name, source); - if !analysis.diagnostics.is_empty() { - return Err(analysis.diagnostics); - } - let hir = analysis.hir.expect("diagnose-frei, aber kein HIR"); - Ok(codegen::compile(&hir)) + compile_source_with_forms( + module_name, + source, + &tb_frontend::forms::FormCatalog::default(), + ) } pub fn compile_source_with_forms( @@ -33,11 +33,10 @@ pub fn compile_source_with_forms( source: &str, forms: &tb_frontend::forms::FormCatalog, ) -> Result> { - 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"), - )) + compile_project( + module_name, + &[SourceUnit::new(module_name, module_name, source)], + forms, + &[], + ) } diff --git a/crates/tb-vm/src/project.rs b/crates/tb-vm/src/project.rs new file mode 100644 index 0000000..07db625 --- /dev/null +++ b/crates/tb-vm/src/project.rs @@ -0,0 +1,682 @@ +//! Gemeinsame Tabellenauflösung getrennter Modulübersetzungen. +use crate::{ + bytecode::{CompiledModule, Instr}, + codegen, +}; +use std::collections::{HashMap, HashSet}; +use tb_frontend::{ + ast::{Module, Stmt, TypeName}, + forms::FormCatalog, + hir::{HProcKind, HTy}, + source::{locate_diagnostics, SourceUnit}, + Diagnostic, SourcePos, +}; +use tb_runtime::value::TypeInit; +use tb_ui::frm::FormFile; + +fn diagnostic(message: impl Into) -> Diagnostic { + Diagnostic { + file: None, + pos: SourcePos::default(), + message: message.into(), + } +} + +pub fn compile_project( + name: &str, + units: &[SourceUnit], + catalog: &FormCatalog, + forms: &[FormFile], +) -> Result> { + if units.is_empty() || units.len() > u16::MAX as usize { + return Err(vec![diagnostic("Projekt ohne Module oder zu viele Module")]); + } + let mut sources = Vec::new(); + let mut diagnostics = Vec::new(); + let mut names = HashSet::new(); + let parsed: Vec<_> = units + .iter() + .enumerate() + .map(|(id, unit)| { + let (module, errors) = unit.parse(id as u16, &mut sources); + if !names.insert(unit.name.to_uppercase()) { + let mut error = diagnostic(format!("Duplicate definition: module {}", unit.name)); + error.pos = module_pos(&module); + error.file = unit.segments.first().map(|s| s.file.clone()); + diagnostics.push(error); + } + diagnostics.extend(errors); + module + }) + .collect(); + locate_diagnostics(&mut diagnostics, &sources); + if !diagnostics.is_empty() { + return Err(diagnostics); + } + let mut catalog = catalog.clone(); + if catalog.find("SCREEN").is_none() { + catalog.add( + "SCREEN", + tb_frontend::forms::ObjectClass::Screen, + None, + false, + ); + } + for module in &parsed { + if module + .body + .iter() + .any(|s| matches!(s, Stmt::MetaForm { .. })) + && catalog.find(&module.name).is_none() + { + catalog.add( + &module.name, + tb_frontend::forms::ObjectClass::Form, + None, + false, + ); + } + } + let mut exports: Vec<_> = parsed + .iter() + .map(|m| tb_frontend::sema::export_declarations(m, &[])) + .collect(); + // Konstantenabhängigkeiten können beliebig über Module verteilt sein. + // Jeder erfolgreiche Durchlauf löst mindestens eine weitere Deklaration auf. + let constant_count: usize = exports + .iter() + .flatten() + .filter(|s| matches!(s, Stmt::ConstDecl { .. })) + .count(); + for _ in 0..constant_count { + let mut constants: HashMap<_, Vec<_>> = HashMap::new(); + for stmt in exports.iter().flatten() { + if let Stmt::ConstDecl { items, .. } = stmt { + constants.entry(&items[0].0).or_default().push(stmt.clone()); + } + } + let constants: Vec<_> = constants + .into_values() + .filter(|s| s.len() == 1) + .flatten() + .collect(); + let next: Vec<_> = parsed + .iter() + .map(|m| tb_frontend::sema::export_declarations(m, &constants)) + .collect(); + if next == exports { + break; + } + exports = next; + } + let mut parts = Vec::new(); + let mut commons = Vec::new(); + for module in &parsed { + let mut module = module.clone(); + import_declarations(&mut module, &parsed, &exports); + let (hir, errors) = tb_frontend::sema::lower_with_forms(&module, &catalog); + diagnostics.extend(errors); + if let Some(hir) = hir { + parts.push(codegen::compile(&hir)); + commons.push(hir.commons); + } + } + locate_diagnostics(&mut diagnostics, &sources); + if !diagnostics.is_empty() { + return Err(diagnostics); + } + let mut result = link(name, parts, &parsed, &commons).map_err(|error| { + let mut errors = vec![error]; + locate_diagnostics(&mut errors, &sources); + errors + })?; + result.sources = sources; + let objects = FormCatalog { + objects: result.objects.clone(), + }; + for form in forms { + result + .form_initial + .extend(form.initial_values(&objects).map_err(|e| { + vec![diagnostic(format!( + "{}: ungültige Forms-Anfangsdaten ({e})", + form.root.name + ))] + })?); + } + result.startup_form = forms + .first() + .and_then(|form| objects.find(&form.root.name).map(|(id, _)| id)); + result + .validate() + .map_err(|e| vec![diagnostic(e.to_string())])?; + Ok(result) +} + +/// Lokale Definition gewinnt; außerhalb ihres Moduls muss ein Name eindeutig sein. +fn type_definition<'a>(name: &str, origin: usize, all: &'a [Module]) -> Option<(usize, &'a Stmt)> { + let mut candidates = all.iter().enumerate().flat_map(|(id, module)| { + module.body.iter().filter_map(move |stmt| { + matches!(stmt, Stmt::TypeDecl { name: n, .. } if n == name).then_some((id, stmt)) + }) + }); + if let Some(local) = candidates.clone().find(|(id, _)| *id == origin) { + return Some(local); + } + let first = candidates.next()?; + candidates.next().is_none().then_some(first) +} + +fn same_type( + name: &str, + a: usize, + b: usize, + all: &[Module], + visiting: &mut HashSet<(usize, usize, String)>, +) -> bool { + let ( + Some((a, Stmt::TypeDecl { fields: af, .. })), + Some((b, Stmt::TypeDecl { fields: bf, .. })), + ) = (type_definition(name, a, all), type_definition(name, b, all)) + else { + return false; + }; + if a == b { + return true; + } + if !visiting.insert((a, b, name.into())) { + return false; + } + let equal = af.len() == bf.len() + && af.iter().zip(bf).all(|((an, at), (bn, bt))| { + an == bn + && match (at, bt) { + (TypeName::Udt(an), TypeName::Udt(bn)) => { + an == bn && same_type(an, a, b, all, visiting) + } + _ => at == bt, + } + }); + visiting.remove(&(a, b, name.into())); + equal +} + +fn import_type( + name: &str, + origin: usize, + target: usize, + all: &[Module], + imported: &mut Vec, + seen: &mut HashMap<(usize, String), String>, +) -> String { + let Some((origin, Stmt::TypeDecl { fields, pos, .. })) = type_definition(name, origin, all) + else { + return name.into(); + }; + if origin == target + || (type_definition(name, target, all).is_some_and(|(id, _)| id == target) + && same_type(name, origin, target, all, &mut HashSet::new())) + { + return name.into(); + } + let key = (origin, name.to_string()); + if let Some(alias) = seen.get(&key) { + return alias.clone(); + } + // Öffentliche eindeutige Namen bleiben erhalten. Konfliktbehaftete Abhängigkeiten + // bekommen einen internen Modulnamen, damit das lokale Layout unangetastet bleibt. + let alias = if type_definition(name, target, all).is_some_and(|(id, _)| id == origin) { + name.to_string() + } else { + format!("{}!{name}", all[origin].name) + }; + seen.insert(key, alias.clone()); + let fields = fields + .iter() + .map(|(field, ty)| { + let ty = match ty { + TypeName::Udt(name) => { + TypeName::Udt(import_type(name, origin, target, all, imported, seen)) + } + _ => ty.clone(), + }; + (field.clone(), ty) + }) + .collect(); + imported.push(Stmt::TypeDecl { + name: alias.clone(), + fields, + pos: *pos, + }); + alias +} + +fn import_declarations(module: &mut Module, all: &[Module], exports: &[Vec]) { + let target = all.iter().position(|m| m.name == module.name).unwrap(); + let mut procedures: HashSet<_> = module.procs.iter().map(|p| p.sig.name.clone()).collect(); + let mut local_constants = HashSet::new(); + let mut local_types = HashSet::new(); + for stmt in &module.body { + match stmt { + Stmt::Declare { sig, .. } => { + procedures.insert(sig.name.clone()); + } + Stmt::ConstDecl { items, .. } => { + local_constants.extend(items.iter().map(|i| i.0.clone())) + } + Stmt::TypeDecl { name, .. } => { + local_types.insert(name.clone()); + } + _ => {} + } + } + let mut foreign: HashMap<_, Vec<_>> = HashMap::new(); + for (origin, declarations) in exports.iter().enumerate().filter(|(id, _)| *id != target) { + for stmt in declarations { + let key = match stmt { + Stmt::Declare { sig, .. } => (0, sig.name.clone()), + Stmt::ConstDecl { items, .. } => (1, items[0].0.clone()), + Stmt::TypeDecl { name, .. } => (2, name.clone()), + _ => continue, + }; + foreign.entry(key).or_default().push((origin, stmt)); + } + } + let mut names: Vec<_> = foreign.keys().cloned().collect(); + names.sort(); + let mut imported = Vec::new(); + let mut seen_types = HashMap::new(); + for key in names { + let candidates = &foreign[&key]; + if candidates.len() != 1 { + continue; + } + let (origin, stmt) = candidates[0]; + match stmt { + Stmt::Declare { sig, pos } if !procedures.contains(&sig.name) => { + let mut sig = sig.clone(); + for param in &mut sig.params { + if let Some(TypeName::Udt(name)) = ¶m.as_type { + param.as_type = Some(TypeName::Udt(import_type( + name, + origin, + target, + all, + &mut imported, + &mut seen_types, + ))); + } + } + imported.push(Stmt::Declare { sig, pos: *pos }); + } + Stmt::ConstDecl { items, .. } if !local_constants.contains(&items[0].0) => { + imported.push(stmt.clone()) + } + Stmt::TypeDecl { name, .. } if !local_types.contains(name) => { + import_type(name, origin, target, all, &mut imported, &mut seen_types); + } + _ => {} + } + } + imported.append(&mut module.body); + let mut types = Vec::new(); + imported.retain(|stmt| { + if matches!(stmt, Stmt::TypeDecl { .. }) { + types.push(stmt.clone()); + false + } else { + true + } + }); + let mut known = HashSet::new(); + let mut ordered = Vec::new(); + while !types.is_empty() { + let next = types.iter().position(|stmt| match stmt { + Stmt::TypeDecl { fields, .. } => fields.iter().all(|(_, ty)| match ty { + TypeName::Udt(name) => known.contains(name), + _ => true, + }), + _ => unreachable!(), + }); + let Some(index) = next else { break }; + let stmt = types.remove(index); + if let Stmt::TypeDecl { name, .. } = &stmt { + known.insert(name.clone()); + } + ordered.push(stmt); + } + ordered.extend(types); // Sema diagnostiziert fehlende oder zyklische Typen. + ordered.extend(imported); + module.body = ordered; +} + +fn proc_pos(module: &Module, name: &str) -> SourcePos { + module + .body + .iter() + .find_map(|stmt| match stmt { + Stmt::Declare { sig, pos } if sig.name == name => Some(*pos), + _ => None, + }) + .or_else(|| { + module + .procs + .iter() + .find(|p| p.sig.name == name) + .map(|p| p.pos) + }) + .unwrap_or_else(|| module_pos(module)) +} +fn module_pos(module: &Module) -> SourcePos { + module + .body + .iter() + .map(tb_frontend::sema::stmt_pos) + .find(|p| p.line > 0) + .or_else(|| module.procs.first().map(|p| p.pos)) + .unwrap_or_default() +} + +fn remap_type(ty: &mut TypeInit, ids: &[u16]) { + if let TypeInit::Udt(id) = ty { + *id = ids[*id as usize]; + } +} +fn remap_signature(ty: &mut HTy, ids: &[u16]) { + if let HTy::Udt(id) = ty { + *id = ids[*id as usize]; + } +} + +fn link( + name: &str, + mut parts: Vec, + ast: &[Module], + module_commons: &[Vec], +) -> Result { + let at = |pos, message| Diagnostic { + file: None, + pos, + message, + }; + let mut result = codegen::compile(&tb_frontend::analyze_source(name, "").hir.unwrap()); + result.modules = parts + .iter() + .map(|p| (p.name.clone(), p.option_base)) + .collect(); + result.objects = parts[0].objects.clone(); + result.strings.clear(); + result.procs.clear(); + result.option_base = parts[0].option_base; + let mut proc_maps = Vec::new(); + let mut count = 1usize; + let mut definitions: HashMap> = HashMap::new(); + for (part, module) in parts.iter().zip(ast) { + let defined: HashSet<_> = module.procs.iter().map(|p| p.sig.name.as_str()).collect(); + let mut map = vec![0]; + for p in part.procs.iter().skip(1) { + let id = u16::try_from(count) + .map_err(|_| at(proc_pos(module, &p.name), "Zu viele Prozeduren".into()))?; + count += 1; + map.push(id); + if defined.contains(p.name.as_str()) || p.kind == HProcKind::DefFn { + definitions.entry(p.name.clone()).or_default().push(id); + } + } + proc_maps.push(map); + } + // DECLARE-Platzhalter auf die tatsächliche, eindeutig bestimmte Definition binden. + for (module_id, part) in parts.iter().enumerate() { + let defined: HashSet<_> = ast[module_id] + .procs + .iter() + .map(|p| p.sig.name.as_str()) + .collect(); + for (id, proc) in part.procs.iter().enumerate().skip(1) { + if !defined.contains(proc.name.as_str()) && proc.kind != HProcKind::DefFn { + if let Some(candidates) = definitions.get(&proc.name) { + if candidates.len() != 1 { + return Err(at( + proc_pos(&ast[module_id], &proc.name), + format!("Ambiguous subprogram: {}", proc.name), + )); + } + proc_maps[module_id][id] = candidates[0]; + } + } + } + } + let splits: Vec<_> = parts + .iter() + .map(|p| { + p.procs[0] + .code + .iter() + .position(|i| matches!(i, Instr::Stmt(0))) + .map_or(0, |i| i.saturating_sub(1)) + }) + .collect(); + let mut init_starts = Vec::new(); + let mut body_starts = Vec::new(); + let mut pc = 0; + for split in &splits { + init_starts.push(pc); + pc += split; + } + for (part, split) in parts.iter().zip(&splits) { + body_starts.push(pc); + pc += part.procs[0].code.len() - split - 1; + } + let mut main = parts[0].procs[0].clone(); + main.code.clear(); + main.name = name.into(); + let mut initializers = Vec::new(); + let mut bodies = Vec::new(); + let mut common: HashMap<_, (u16, tb_frontend::hir::HCommon)> = HashMap::new(); + for (module_id, part) in parts.iter_mut().enumerate() { + let mut types = Vec::new(); + for udt in &part.udts { + let mut udt = udt.clone(); + udt.name = udt.name.rsplit('!').next().unwrap().to_string(); + for ty in &mut udt.fields { + remap_type(ty, &types); + } + let id = result + .udts + .iter() + .position(|u| u.name == udt.name && u.fields == udt.fields) + .unwrap_or_else(|| { + result.udts.push(udt); + result.udts.len() - 1 + }); + types.push( + u16::try_from(id) + .map_err(|_| at(module_pos(&ast[module_id]), "Zu viele TYPEs".into()))?, + ); + } + let commons: HashMap<_, _> = module_commons[module_id] + .iter() + .map(|c| (c.slot, c)) + .collect(); + let mut globals = Vec::new(); + for (slot, (ty, name)) in part.globals_init.iter().zip(&part.global_names).enumerate() { + let mut ty = ty.clone(); + remap_type(&mut ty, &types); + let declaration = commons.get(&(slot as u16)); + let key = declaration.map(|c| (c.block.clone(), c.key.clone())); + let mut common_ty = declaration.map(|c| c.ty.clone()); + if let Some(ty) = &mut common_ty { + remap_signature(ty, &types); + } + let id = if let Some((id, previous)) = key.as_ref().and_then(|key| common.get_mut(key)) + { + let declaration = declaration.unwrap(); + let compatible_dims = match (&previous.dims, &declaration.dims) { + (None, None) => true, + (Some(a), Some(b)) if a.is_empty() || b.is_empty() => true, + (Some(a), Some(b)) => { + a.len() == b.len() + && a.iter().zip(b).all(|((al, ah), (bl, bh))| { + al.zip(*bl).is_none_or(|(a, b)| a == b) + && ah.zip(*bh).is_none_or(|(a, b)| a == b) + }) + } + _ => false, + }; + if Some(&previous.ty) != common_ty.as_ref() || !compatible_dims { + return Err(at( + declaration.pos, + format!("COMMON type or bounds mismatch: {name}"), + )); + } + if let (Some(previous), Some(current)) = (&mut previous.dims, &declaration.dims) { + if previous.is_empty() { + *previous = current.clone(); + } else { + for ((lo, hi), (new_lo, new_hi)) in previous.iter_mut().zip(current) { + *lo = lo.or(*new_lo); + *hi = hi.or(*new_hi); + } + } + } + *id + } else { + let id = u16::try_from(result.globals_init.len()).map_err(|_| { + at( + module_pos(&ast[module_id]), + "Zu viele globale Variablen".into(), + ) + })?; + result.globals_init.push(ty); + result.global_names.push(if ast.len() == 1 { + name.clone() + } else { + format!("{}!{name}", part.name) + }); + if let Some(key) = key { + let mut declaration = (*declaration.unwrap()).clone(); + declaration.ty = common_ty.unwrap(); + common.insert(key, (id, declaration)); + } + id + }; + globals.push(id); + } + let string_offset = result.strings.len(); + if string_offset + part.strings.len() >= u16::MAX as usize { + return Err(at( + module_pos(&ast[module_id]), + "Zu viele Stringkonstanten".into(), + )); + } + result.strings.append(&mut part.strings); + let data_offset = result.data.len() as u32; + result.data.append(&mut part.data); + let jump_offset = result.jump_tables.len(); + if jump_offset + part.jump_tables.len() > u16::MAX as usize { + return Err(at( + module_pos(&ast[module_id]), + "Zu viele Sprungtabellen".into(), + )); + } + let main_pc = |pc: u32| if (pc as usize) < splits[module_id] { init_starts[module_id] + pc as usize } else { body_starts[module_id] + pc as usize - splits[module_id] } as u32; + for (proc_id, proc) in part.procs.iter_mut().enumerate() { + proc.module = module_id as u16; + for ty in &mut proc.locals_init { + remap_type(ty, &types); + } + for param in &mut proc.params { + remap_signature(&mut param.ty, &types); + } + if let Some(ty) = &mut proc.ret_ty { + remap_signature(ty, &types); + } + if ast.len() > 1 { + proc.name = format!("{}!{}", part.name, proc.name); + } + for instruction in &mut proc.code { + use Instr::*; + match instruction { + PushStr(id) + | Unsupported(id) + | LoadDynamicObjectProperty(id) + | StoreDynamicObjectProperty(id) => *id += string_offset as u16, + Input(_, _, id, _) if *id != u16::MAX => *id += string_offset as u16, + LoadGlobal(id) | StoreGlobal(id) | MakeRefGlobal(id) => { + *id = globals[*id as usize] + } + LoadArr(global, id, _, ty) + | DimArr(global, id, _, ty) + | CommonArr(global, id, _, ty) + | RedimArr(global, id, _, ty) => { + if *global { + *id = globals[*id as usize]; + } + remap_type(ty, &types); + } + EraseSlot(true, id) => *id = globals[*id as usize], + GetPut(_, _, 7, id) | PushUdtId(id) => *id = types[*id as usize], + Call(id, _) => *id = proc_maps[module_id][*id as usize], + Restore(id) => *id += data_offset, + OnErrorGoto(pc) => *pc = main_pc(*pc), + Jump(pc) + | JumpIfFalse(pc) + | JumpIfTrue(pc) + | Gosub(pc) + | RetGosubTo(pc) + | OnErrorLocal(pc) + | ResumeLabel(pc) + | TrapDefine(_, pc) + if proc_id == 0 => + { + *pc = main_pc(*pc) + } + OnJump(id, _) => { + if proc_id == 0 { + for target in &mut part.jump_tables[*id as usize] { + *target = main_pc(*target); + } + } + *id += jump_offset as u16; + } + _ => {} + } + } + } + initializers.extend_from_slice(&part.procs[0].code[..splits[module_id]]); + bodies.extend_from_slice( + &part.procs[0].code[splits[module_id]..part.procs[0].code.len() - 1], + ); + result.procs.extend(part.procs.iter().skip(1).cloned()); + for mut e in part.event_procs.drain(..) { + e.proc = proc_maps[module_id][e.proc as usize]; + result.event_procs.push(e); + } + result.jump_tables.append(&mut part.jump_tables); + } + initializers.extend(bodies); + initializers.push(Instr::End); + main.code = initializers; + result.procs.insert(0, main); + // Vollständige Signaturen zwischen DECLARE-Platzhalter und Ziel vergleichen. + for (module, part) in parts.iter().enumerate() { + for (id, proc) in part.procs.iter().enumerate().skip(1) { + let target = &result.procs[proc_maps[module][id] as usize]; + if proc.ret_ty != target.ret_ty + || proc.kind != target.kind + || proc.params.len() != target.params.len() + || proc + .params + .iter() + .zip(&target.params) + .any(|(a, b)| a.ty != b.ty || a.array != b.array || a.by_ref != b.by_ref) + { + return Err(at( + proc_pos(&ast[module], proc.name.rsplit('!').next().unwrap()), + format!("Parameter type mismatch: {}", proc.name), + )); + } + } + } + Ok(result) +} diff --git a/crates/tb-vm/tests/project.rs b/crates/tb-vm/tests/project.rs new file mode 100644 index 0000000..2d682af --- /dev/null +++ b/crates/tb-vm/tests/project.rs @@ -0,0 +1,299 @@ +use tb_frontend::{ + forms::FormCatalog, + source::{SourceSegment, SourceUnit}, +}; +use tb_runtime::{host::CaptureHost, value::Value}; +use tb_ui::frm::{self, FormFile}; +use tb_vm::{ + bytecode::CompiledModule, + compile_project, + interp::{RunEvent, Vm}, +}; + +fn compile(units: &[SourceUnit], forms: &[FormFile]) -> CompiledModule { + let mut catalog = FormCatalog::default(); + for form in forms { + catalog.append(&form.catalog()); + } + compile_project("APP", units, &catalog, forms).unwrap_or_else(|d| panic!("{d:#?}")) +} + +#[test] +fn module_includes_erl_breakpoints_und_inspektion_behalten_ihren_ursprung() { + let a = SourceUnit::new("MAIN", "main.bas", "x%=1\nCALL Fehler\nEND\n"); + let b = SourceUnit { + name: "LIB".into(), + segments: vec![ + SourceSegment { + file: "lib.bas".into(), + first_line: 1, + text: "SUB Fehler\n".into(), + }, + SourceSegment { + file: "nested.bi".into(), + first_line: 2, + text: "200 ERROR 6\n".into(), + }, + SourceSegment { + file: "lib.bas".into(), + first_line: 3, + text: "END SUB\n".into(), + }, + ], + }; + let module = compile(&[a, b], &[]); + let bytes = module.to_tbc(); + let loaded = CompiledModule::from_tbc(&bytes).unwrap(); + assert_eq!(loaded.to_tbc(), bytes); + let mut vm = Vm::new(loaded); + let mut host = CaptureHost::default(); + vm.add_module_breakpoint(1, 2); + assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 2 }); + assert_eq!(vm.current_module(), 1); + assert_eq!(vm.current_file(), "nested.bi"); + assert_eq!(vm.current_source_pos().column, 1); + assert!(matches!(vm.inspect("MAIN!x"), Some(Value::Int(1)))); + vm.remove_module_breakpoint(1, 2); + vm.set_step(true); + assert_eq!(vm.run(&mut host), RunEvent::Stepped { line: 2 }); + assert_eq!(vm.current_file(), "nested.bi"); + assert_eq!(vm.current_source_pos().column, 5); + vm.set_step(false); + assert!(matches!( + vm.run(&mut host), + RunEvent::Error { + code: 6, + line: 2, + .. + } + )); + let mut erl_vm = Vm::new(compile( + &[SourceUnit::new( + "NUM", + "num.bas", + "ON ERROR RESUME NEXT\n200 ERROR 6\nn%=ERL\nSTOP", + )], + &[], + )); + assert!(matches!(erl_vm.run(&mut host), RunEvent::Stopped { .. })); + assert!(matches!(erl_vm.inspect("n"), Some(Value::Int(200)))); + assert_eq!(vm.current_file(), "nested.bi"); +} + +#[test] +fn diagnose_im_include_und_echte_duplikate_bleiben_sichtbar() { + let source = SourceUnit { + name: "LIB".into(), + segments: vec![SourceSegment { + file: "nested.bi".into(), + first_line: 2, + text: "Text$ = 42\n".into(), + }], + }; + let errors = compile_project("APP", &[source], &FormCatalog::default(), &[]).unwrap_err(); + assert!(errors + .iter() + .any(|e| e.file.as_deref() == Some("nested.bi") && e.pos.line == 2 && e.pos.column == 1)); + for text in [ + "SUB X\nEND SUB\nSUB X\nEND SUB", + "CONST N=1\nCONST N=2", + "TYPE T\nx AS INTEGER\nEND TYPE\nTYPE T\nx AS INTEGER\nEND TYPE", + ] { + let errors = compile_project( + "APP", + &[SourceUnit::new("M", "m.bas", text)], + &FormCatalog::default(), + &[], + ) + .unwrap_err(); + assert!( + errors + .iter() + .any(|e| e.message.contains("Duplicate definition")), + "{errors:?}" + ); + } +} + +#[test] +fn initialisierungen_def_fn_und_modulhandler_haben_eigene_quellorte() { + for (source, expected_line) in [ + ("' Bibliothek\nDIM a%(1 TO 0)", 2), + ( + "' Bibliothek\nDEF FNkaputt(x)=1/x\nSUB Fehler\nPRINT FNkaputt(0)\nEND SUB", + 2, + ), + ] { + let module = compile( + &[ + SourceUnit::new("A", "a.bas", "CALL Fehler\nEND"), + SourceUnit::new( + "B", + "b.bas", + &format!( + "{source}\n{}", + if source.contains("SUB") { + "" + } else { + "SUB Fehler\nEND SUB" + } + ), + ), + ], + &[], + ); + let mut vm = Vm::new(CompiledModule::from_tbc(&module.to_tbc()).unwrap()); + let event = vm.run(&mut CaptureHost::default()); + assert!( + matches!(event, RunEvent::Error { line, .. } if line == expected_line), + "{event:?}" + ); + assert_eq!(vm.current_file(), "b.bas"); + } + let units = [ + SourceUnit::new( + "A", + "a.bas", + "ON ERROR GOTO H\nCALL Fehler\nEND\nH:\nPRINT ERR;ERL\nEND", + ), + SourceUnit::new( + "B", + "b.bas", + "SUB Fehler\nON ERROR GOTO 0\n200 ERROR 6\nEND SUB", + ), + ]; + let mut vm = Vm::new(compile(&units, &[])); + assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended); + assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), " 6 200 \n"); +} + +#[test] +fn lokale_prozeduren_shared_common_und_wiederholte_includes_bleiben_getrennt() { + let a = SourceUnit::new("A", "a.bas", "CONST N=1\nDIM SHARED x%\nCOMMON SHARED c%\nx%=10\nCALL Privat\nCALL Zweites\nPRINT x%;c%\nEND\nSUB Privat\nx%=x%+N\nc%=c%+1\nEND SUB"); + let b = SourceUnit::new("B", "b.bas", "CONST N=2\nDIM SHARED x%\nCOMMON SHARED c%\nSUB Zweites\nx%=20\nCALL Privat\nPRINT x%\nEND SUB\nSUB Privat\nx%=x%+N\nc%=c%+2\nEND SUB"); + let mut vm = Vm::new(compile(&[a, b], &[])); + assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended); + assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), " 22 \n 11 3 \n"); + let include = SourceSegment { + file: "shared.bi".into(), + first_line: 1, + text: "CONST N=7\n".into(), + }; + let units: Vec<_> = ["A", "B"] + .iter() + .map(|name| SourceUnit { + name: (*name).into(), + segments: vec![ + include.clone(), + SourceSegment { + file: format!("{name}.bas"), + first_line: 2, + text: "PRINT N\n".into(), + }, + ], + }) + .collect(); + let mut vm = Vm::new(compile(&units, &[])); + assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended); + assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), " 7 \n 7 \n"); +} + +#[test] +fn typreferenzen_signaturen_und_option_base_ueber_modulgrenzen() { + use tb_frontend::hir::{HProcKind, HTy, NumTy}; + use tb_vm::bytecode::Instr; + let units = [ + SourceUnit::new("A", "a.bas", "DECLARE SUB CmnDlgRegister(ok AS INTEGER)\nTYPE Klein\nx AS INTEGER\nEND TYPE\nDIM s AS Gross\nDIM a%(2)\ns.x=4\na%(1)=3\nCmnDlgRegister ok%\nPRINT ok%\nPRINT Summe%(a%(), s, 2)\nPRINT s.x\nCALL Basis\nEND"), + SourceUnit::new("B", "b.bas", "OPTION BASE 1\nTYPE Gross\nx AS LONG\ny AS STRING * 8\nEND TYPE\nFUNCTION Summe%(a%(), s AS Gross, n%)\nSumme%=a%(1)+s.x+n%\ns.x=s.x+1\nEND FUNCTION\nSUB Basis\nb%(1)=1\nPRINT LBOUND(b%)\nEND SUB\nSUB Datei\nOPEN \"unbenutzt.isam\" FOR ISAM Gross \"T\" AS #1\nCLOSE #1\nEND SUB"), + ]; + let module = compile(&units, &[]); + let bytes = module.to_tbc(); + let loaded = CompiledModule::from_tbc(&bytes).unwrap(); + assert_eq!(bytes, loaded.to_tbc()); + let signature = loaded.procs.iter().find(|p| p.name == "B!SUMME").unwrap(); + assert_eq!(signature.kind, HProcKind::Function); + assert_eq!(signature.ret_ty, Some(HTy::Num(NumTy::Int))); + assert!(signature.params[0].array && !signature.params[0].by_ref); + assert!(signature.params[2].by_ref); + assert!(signature.params[1].by_ref); + let HTy::Udt(udt) = signature.params[1].ty else { + panic!("UDT-Parameter fehlt") + }; + assert_eq!(loaded.udts[udt as usize].name, "GROSS"); + let file = loaded.procs.iter().find(|p| p.name == "B!DATEI").unwrap(); + assert!(file.code.contains(&Instr::PushUdtId(udt))); + let mut vm = Vm::new(loaded); + assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended); + assert_eq!( + tb_runtime::snapshot::text(&vm.rt.screen), + "-1 \n 9 \n 5 \n 1 \n" + ); +} + +#[test] +fn beschaedigte_container_werden_vor_der_ausfuehrung_abgewiesen() { + use tb_vm::bytecode::Instr; + let form = frm::read_text("f.frm", "VERSION 1.00\nBEGIN Form F\n BEGIN TextBox Text1\n Index = 2\n Text = \"hello\"\n END\nEND\n").unwrap(); + let bytes = compile(&[SourceUnit::new("F", "f.frm", "END")], &[form]).to_tbc(); + for length in 0..bytes.len() { + assert!( + CompiledModule::from_tbc(&bytes[..length]).is_err(), + "Länge {length}" + ); + } + for (offset, value) in [(6, 1u32), (8, u32::MAX), (16, 0), (20, u32::MAX)] { + let mut bad = bytes.clone(); + bad[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + assert!(CompiledModule::from_tbc(&bad).is_err(), "Offset {offset}"); + } + let mut duplicate = bytes.clone(); + duplicate[24..28].copy_from_slice(b"MODN"); + assert!(CompiledModule::from_tbc(&duplicate).is_err()); + for mutation in 0..8 { + let mut bad = CompiledModule::from_tbc(&bytes).unwrap(); + match mutation { + 0 => bad.sources[0].module = u16::MAX, + 1 => bad.procs[0].module = u16::MAX, + 2 => bad.procs[0].code.push(Instr::Call(u16::MAX, 0)), + 3 => bad.procs[0].n_params = 1, + 4 => bad.objects[1].parent = Some(1), + 5 => bad.form_initial[1].index = 3, + 6 => { + bad.form_initial[1].properties.insert( + 0, + tb_ui::forms::PropertyValue::Object(Some((u16::MAX, None))), + ); + } + 7 => bad.procs[0].code.push(Instr::Source(u32::MAX, 1)), + _ => unreachable!(), + } + assert!( + CompiledModule::from_tbc(&bad.to_tbc()).is_err(), + "Mutation {mutation}" + ); + } +} + +#[test] +fn zwei_formulare_mit_gleichen_controls_und_arrays_laufen_aus_dem_kompilat() { + let form = |name: &str, text: &str| { + frm::read_text(&format!("{name}.frm"), &format!( + "VERSION 1.00\nBEGIN Form {name}\n Width = 30\n Height = 10\n BEGIN Frame Frame1\n BEGIN TextBox Text1\n Text = \"{text}\"\n END\n END\n BEGIN TextBox Feld\n Index = 0\n Text = \"null\"\n END\n BEGIN TextBox Feld\n Index = 2\n Text = \"zwei\"\n END\nEND\n\nSUB Form_Load\nText1.Text = Text1.Text + \"!\"\nEND SUB\n" + )).unwrap() + }; + let forms = [form("Form1", "a"), form("Form2", "b")]; + let mut units = vec![SourceUnit::new("MAIN", "main.bas", "Form2.Show\na$=Form1!Text1.Text\nb$=Form2!Text1.Text\nc$=Form2!Feld(2).Text\nForm1.Hide\nForm2.Hide\nCLS\nPRINT a$\nPRINT b$\nPRINT c$\nEND")]; + units.extend( + forms + .iter() + .map(|f| SourceUnit::new(&f.root.name, &format!("{}.frm", f.root.name), &f.code)), + ); + let module = compile(&units, &forms); + assert_eq!(module.event_procs.len(), 2); + let bytes = module.to_tbc(); + let loaded = CompiledModule::from_tbc(&bytes).unwrap(); + assert_eq!(loaded.to_tbc(), bytes); + let mut vm = Vm::new(loaded); + assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended); + assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), "a!\nb!\nzwei\n"); +} diff --git a/crates/tb-vm/tests/project_regressions.rs b/crates/tb-vm/tests/project_regressions.rs new file mode 100644 index 0000000..059f4a1 --- /dev/null +++ b/crates/tb-vm/tests/project_regressions.rs @@ -0,0 +1,548 @@ +//! Unveränderte ursprüngliche Reviewproben und ergänzende Regressionen des Projektkompilats. +use tb_frontend::{ + forms::FormCatalog, + source::{SourceSegment, SourceUnit}, +}; +use tb_runtime::{host::CaptureHost, value::Value}; +use tb_vm::{ + bytecode::CompiledModule, + compile_project, + interp::{RunEvent, Vm}, +}; + +fn units(files: &[(&str, &str)]) -> Vec { + files + .iter() + .map(|(name, code)| SourceUnit::new(name, &format!("{name}.bas"), code)) + .collect() +} +fn compile(files: &[(&str, &str)]) -> Result> { + compile_project("APP", &units(files), &FormCatalog::default(), &[]) +} +fn run(module: CompiledModule) -> (RunEvent, String) { + let bytes = module.to_tbc(); + let loaded = CompiledModule::from_tbc(&bytes).unwrap(); + assert_eq!(bytes, loaded.to_tbc()); + let mut vm = Vm::new(loaded); + let event = vm.run(&mut CaptureHost::default()); + (event, tb_runtime::snapshot::text(&vm.rt.screen)) +} + +#[test] +fn v1_zweige_und_schleifen_behalten_eigene_quellorte() { + let mut failures = vec![]; + for (name, code, line) in [ + ( + "ELSEIF", + "IF 0 THEN\nPRINT 1\nELSEIF 1/0 THEN\nPRINT 2\nEND IF", + 3, + ), + ("CASE", "SELECT CASE 1\nCASE 1/0\nPRINT 2\nEND SELECT", 2), + ("NEXT", "FOR i%=32767 TO 32767\nPRINT 1\nNEXT", 3), + ("LOOP", "DO\nPRINT 1\nLOOP UNTIL 1/0", 3), + ] { + let (event, _) = run(compile(&[("MAIN", code)]).unwrap()); + if !matches!(event, RunEvent::Error { line: found, .. } if found == line) { + failures.push(format!("{name}: erwartet Zeile {line}, erhalten {event:?}")); + } + } + let source = SourceUnit { + name: "MAIN".into(), + segments: vec![ + SourceSegment { + file: "main.bas".into(), + first_line: 1, + text: "IF 0 THEN\nPRINT 1\n".into(), + }, + SourceSegment { + file: "cond.bi".into(), + first_line: 1, + text: "ELSEIF 1/0 THEN\nPRINT 2\n".into(), + }, + SourceSegment { + file: "main.bas".into(), + first_line: 4, + text: "END IF\n".into(), + }, + ], + }; + let mut vm = Vm::new(compile_project("APP", &[source], &FormCatalog::default(), &[]).unwrap()); + let event = vm.run(&mut CaptureHost::default()); + if vm.current_file() != "cond.bi" { + failures.push(format!( + "Include: {event:?} aus {} statt cond.bi", + vm.current_file() + )); + } + // Das gleiche Problem darf nicht nur durch Umbenennen der Fehlermeldung behoben werden. + let mut vm = Vm::new( + compile(&[("MAIN", "IF 0 THEN\nPRINT 1\nELSEIF 1 THEN\nPRINT 2\nEND IF")]).unwrap(), + ); + vm.add_module_breakpoint(0, 3); + let event = vm.run(&mut CaptureHost::default()); + if event != (RunEvent::Breakpoint { line: 3 }) { + failures.push(format!("Breakpoint ELSEIF: {event:?}")); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn v2_importierte_deklarationen_behalten_kontext_und_abhaengigkeiten() { + let cases = [ + ( + "DEFINT", + "PRINT Doppelt(3)\nEND", + "DEFINT A-Z\nFUNCTION Doppelt(x)\nDoppelt=x*2\nEND FUNCTION", + " 6 \n", + ), + ( + "CONST", + "CONST A=3\nPRINT B\nEND", + "CONST A=3\nCONST B=A+1", + " 4 \n", + ), + ( + "TYPE", + "TYPE Inner\nx AS INTEGER\nEND TYPE\nDIM a AS Outer\na.i.x=3\nPRINT a.i.x\nEND", + "TYPE Inner\nx AS INTEGER\nEND TYPE\nTYPE Outer\ni AS Inner\nEND TYPE", + " 3 \n", + ), + ]; + let mut failures = vec![]; + for (name, main, lib, output) in cases { + match compile(&[("MAIN", main), ("LIB", lib)]) { + Err(e) => failures.push(format!("{name}: {e:?}")), + Ok(module) => { + let actual = run(module); + if actual != (RunEvent::Ended, output.into()) { + failures.push(format!("{name}: {actual:?}")); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn v3_form_metabefehle_erzeugen_getrennte_formularobjekte() { + let module = compile(&[ + ( + "A", + "'$FORM\nCaption=\"a\"\nCALL SetupB\nPRINT Caption\nEND", + ), + ("B", "'$FORM\nSUB SetupB\nCaption=\"b\"\nEND SUB"), + ]) + .unwrap(); + let names: Vec<_> = module.objects.iter().map(|o| o.name.clone()).collect(); + let actual = run(module); + assert_eq!( + actual, + (RunEvent::Ended, "a\n".into()), + "Objekte: {names:?}" + ); +} + +#[test] +fn v4_common_behaelt_typidentitaet_und_initialisiert_arrays_einmal() { + let mut failures = vec![]; + for (name, files, want) in [ + ( + "Suffixe", + vec![( + "MAIN", + "COMMON SHARED c%, c$\nc%=7\nc$=\"ok\"\nPRINT c%;c$\nEND", + )], + " 7 ok\n", + ), + ( + "Array", + vec![ + ("MAIN", "COMMON SHARED a%(2)\na%(1)=7\nCALL F\nEND"), + ("LIB", "COMMON SHARED a%(2)\nSUB F\nPRINT a%(1)\nEND SUB"), + ], + " 7 \n", + ), + ] { + match compile(&files) { + Err(e) => failures.push(format!("{name}: {e:?}")), + Ok(module) => { + let actual = run(module); + if actual != (RunEvent::Ended, want.into()) { + failures.push(format!("{name}: {actual:?}")); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn v5_load_und_unload_loesen_den_expliziten_container_auf() { + use tb_frontend::forms::ObjectClass; + let mut catalog = FormCatalog::default(); + for name in ["Form1", "Form2"] { + catalog.add(name, ObjectClass::Form, None, false); + catalog.add("Text1", ObjectClass::TextBox, Some(name), true); + } + let source = units(&[("MAIN", "LOAD Form2!Text1(3)\nForm2!Text1(3).Text=\"b\"\nPRINT Form2!Text1(3).Text\nUNLOAD Form2!Text1(3)\nEND")]); + let module = compile_project("APP", &source, &catalog, &[]).unwrap(); + assert_eq!(run(module), (RunEvent::Ended, "b\n".into())); +} + +#[test] +fn v6_linkerdiagnose_nennt_den_urspruenglichen_dateiort() { + let errors = compile(&[ + ("MAIN", "DECLARE SUB F(x%)\nCALL F(1)\nEND"), + ("LIB", "SUB F(x$)\nEND SUB"), + ]) + .unwrap_err(); + assert!( + errors + .iter() + .any(|d| d.message.contains("Parameter type mismatch")), + "{errors:?}" + ); + assert!( + errors + .iter() + .all(|d| d.file.is_some() && d.pos.line > 0 && d.pos.column > 0), + "{errors:?}" + ); +} + +#[test] +fn modulbreakpoint_rundlauf_inspektion_und_numerische_erl_bleiben_getrennt() { + let main = format!( + "DIM a%(2)\nTYPE T\nx AS INTEGER\nEND TYPE\nDIM r AS T\na%(1)=7\nr.x=8\n{}CALL F\nEND", + "\n\nn%=1\n" + ); + let lib = format!("SUB F\n{}200 ERROR 6\nEND SUB", "\n".repeat(8)); + let module = compile(&[("MAIN", &main), ("LIB", &lib)]).unwrap(); + let bytes = module.to_tbc(); + let loaded = CompiledModule::from_tbc(&bytes).unwrap(); + assert_eq!(bytes, loaded.to_tbc()); + let mut vm = Vm::new(loaded); + vm.add_module_breakpoint(1, 10); + let mut host = CaptureHost::default(); + assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 10 }); + assert_eq!(vm.current_module(), 1); + assert_eq!(vm.current_file(), "LIB.bas"); + assert!(matches!( + vm.inspect_element("MAIN!a%", &[1]), + Some(Value::Int(7)) + )); + assert!(matches!( + vm.inspect_field("MAIN!r", &[0]), + Some(Value::Int(8)) + )); + vm.remove_module_breakpoint(1, 10); + vm.set_step(true); + assert_eq!(vm.run(&mut host), RunEvent::Stepped { line: 10 }); + assert_eq!(vm.current_source_pos().column, 5); + vm.set_step(false); + assert!(matches!( + vm.run(&mut host), + RunEvent::Error { + code: 6, + line: 10, + .. + } + )); + let source = format!("{}ERROR 6", "\n".repeat(41)); + assert!(matches!( + run(compile(&[("MAIN", &source)]).unwrap()).0, + RunEvent::Error { + code: 6, + line: 42, + .. + } + )); + let module = compile(&[( + "MAIN", + "ON ERROR RESUME NEXT\n200 ERROR 6\nPRINT ERR;ERL\nEND", + )]) + .unwrap(); + assert_eq!(run(module), (RunEvent::Ended, " 6 200 \n".into())); +} + +#[test] +fn importkontext_bleibt_auch_bei_abweichenden_lokalen_definitionen_erhalten() { + for (files, expected) in [ + (vec![("MAIN", "CONST A=99\nPRINT B\nEND"), ("LIB", "CONST A=3\nCONST B=A+1")], " 4 \n"), + (vec![("MAIN", "PRINT C\nEND"), ("LIB", "CONST C=B+1"), ("BASE", "CONST B=3")], " 4 \n"), + (vec![("MAIN", "TYPE Inner\nx AS STRING * 3\nEND TYPE\nDIM a AS Outer\na.i.x=3\nPRINT a.i.x\nEND"), ("LIB", "TYPE Inner\nx AS INTEGER\nEND TYPE\nTYPE Outer\ni AS Inner\nEND TYPE")], " 3 \n"), + (vec![("MAIN", "DEFSTR A-Z\nx%=3\nCALL Twice(x%)\nPRINT x%\nPRINT Doppelt(3)\nEND"), ("LIB", "DEFINT A-Z\nSUB Twice(x)\nx=x*2\nEND SUB\nFUNCTION Doppelt(x)\nDoppelt=x*2\nEND FUNCTION")], " 6 \n 6 \n"), + ] { + let actual = run(compile(&files).unwrap_or_else(|e| panic!("{files:?}: {e:?}"))); + assert_eq!(actual, (RunEvent::Ended, expected.into()), "{files:?}"); + } +} + +#[test] +fn common_vertraege_und_linkfehler_haben_echte_quellorte() { + for (left, right) in [ + ("COMMON SHARED a%(2)", "COMMON SHARED a%(3)"), + ("COMMON SHARED a%(1 TO 2)", "COMMON SHARED a%(0 TO 2)"), + ("COMMON SHARED a%(2)", "COMMON SHARED a%(2,2)"), + ("COMMON SHARED a AS INTEGER", "COMMON SHARED a AS STRING"), + ("COMMON SHARED a%(2)", "COMMON SHARED a%"), + ] { + let units = [ + SourceUnit::new("MAIN", "main.bas", &format!("{left}\nEND")), + SourceUnit { + name: "LIB".into(), + segments: vec![SourceSegment { + file: "shared.bi".into(), + first_line: 42, + text: format!(" {right}\n"), + }], + }, + ]; + let errors = compile_project("APP", &units, &FormCatalog::default(), &[]).unwrap_err(); + assert!( + errors.iter().any(|e| e.file.as_deref() == Some("shared.bi") + && e.pos.line == 42 + && e.pos.column > 1 + && e.message.contains("COMMON")), + "{errors:?}" + ); + } + let files = [ + ( + "MAIN", + "COMMON SHARED a%(2),a$(2)\na%(1)=7\na$(1)=\"ok\"\nCALL F\nEND", + ), + ( + "LIB", + "COMMON SHARED a%(2),a$(2)\nSUB F\nPRINT a%(1);a$(1)\nEND SUB", + ), + ]; + assert_eq!( + run(compile(&files).unwrap()), + (RunEvent::Ended, " 7 ok\n".into()) + ); + let errors = + compile(&[("MAIN", "' header\nDECLARE SUB F(x$)\nSUB F(x%)\nEND SUB")]).unwrap_err(); + assert!( + errors.iter().any(|e| e.pos.line == 3 + && e.file.as_deref() == Some("MAIN.bas") + && e.message.contains("Parameter")), + "{errors:?}" + ); +} + +#[test] +fn schleifengrenzen_tragen_breakpoint_include_und_resume() { + for (source, line) in [ + ("FOR i%=1 TO 2\nPRINT i%\nNEXT\nEND", 3), + ("DO\nPRINT 1\nLOOP UNTIL 1\nEND", 3), + ("i%=1\nWHILE i%\ni%=0\nWEND\nEND", 4), + ( + "SELECT CASE 2\nCASE 1\nPRINT 1\nCASE 2\nPRINT 2\nEND SELECT", + 4, + ), + ] { + let module = compile(&[("MAIN", source)]).unwrap(); + let mut vm = Vm::new(CompiledModule::from_tbc(&module.to_tbc()).unwrap()); + vm.add_module_breakpoint(0, line); + assert_eq!( + vm.run(&mut CaptureHost::default()), + RunEvent::Breakpoint { line } + ); + assert_eq!(vm.current_file(), "MAIN.bas"); + assert_eq!(vm.current_source_pos().column, 1); + vm.remove_module_breakpoint(0, line); + assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended); + } + let actual = run(compile(&[("MAIN", "x%=1\nWHILE 1/x%\nx%=0\nWEND")]).unwrap()); + assert!( + matches!( + actual.0, + RunEvent::Error { + code: 11, + line: 2, + .. + } + ), + "{actual:?}" + ); + let actual = run(compile(&[( + "MAIN", + "ON ERROR GOTO H\nDO\nn%=n%+1\nLOOP UNTIL 1/x%\nPRINT n%\nEND\nH:\nx%=1\nRESUME", + )]) + .unwrap()); + assert_eq!(actual, (RunEvent::Ended, " 1 \n".into())); + let actual = run(compile(&[( + "MAIN", + "ON ERROR RESUME NEXT\nFOR i%=32767 TO 32767\nNEXT\nPRINT ERR\nEND", + )]) + .unwrap()); + assert_eq!(actual, (RunEvent::Ended, " 6 \n".into())); + let unit = SourceUnit { + name: "MAIN".into(), + segments: vec![ + SourceSegment { + file: "main.bas".into(), + first_line: 1, + text: "DO\nPRINT 1\n".into(), + }, + SourceSegment { + file: "end.bi".into(), + first_line: 42, + text: " LOOP UNTIL 1/0\n".into(), + }, + ], + }; + let module = compile_project("APP", &[unit], &FormCatalog::default(), &[]).unwrap(); + let mut vm = Vm::new(CompiledModule::from_tbc(&module.to_tbc()).unwrap()); + assert!(matches!( + vm.run(&mut CaptureHost::default()), + RunEvent::Error { + code: 11, + line: 42, + .. + } + )); + assert_eq!(vm.current_file(), "end.bi"); + assert_eq!(vm.current_source_pos().column, 3); +} + +#[test] +fn debugger_unterscheidet_typisierte_scalar_und_arraynamen() { + let mut vm = Vm::new( + compile(&[( + "MAIN", + "COMMON SHARED c%,c$,a%(2),a$(2)\nc%=7\nc$=\"ok\"\na%(1)=8\na$(1)=\"array\"\nSTOP", + )]) + .unwrap(), + ); + assert!(matches!( + vm.run(&mut CaptureHost::default()), + RunEvent::Stopped { .. } + )); + assert!(matches!(vm.inspect("c%"), Some(Value::Int(7)))); + assert!(matches!(vm.inspect("c$"),Some(Value::Str(s)) if s.as_ref()=="ok")); + assert!(matches!( + vm.inspect_element("a%", &[1]), + Some(Value::Int(8)) + )); + assert!(matches!(vm.inspect_element("a$",&[1]),Some(Value::Str(s)) if s.as_ref()=="array")); +} + +#[test] +fn common_arrays_behalten_dynamische_grenzen_und_kompatible_offene_deklarationen() { + for (left, right) in [ + ("COMMON SHARED a%(2)", "COMMON SHARED a%()"), + ("COMMON SHARED a%(2.4)", "COMMON SHARED a%(2)"), + ("COMMON SHARED a%(n%+2)", "COMMON SHARED a%(n%+2)"), + ] { + let files = [ + ("MAIN", format!("{left}\na%(1)=7\nCALL F\nEND")), + ("LIB", format!("{right}\nSUB F\nPRINT a%(1)\nEND SUB")), + ]; + let files: Vec<_> = files.iter().map(|(n, s)| (*n, s.as_str())).collect(); + assert_eq!( + run(compile(&files).unwrap()), + (RunEvent::Ended, " 7 \n".into()), + "{files:?}" + ); + } + let module = compile(&[ + ("MAIN", "COMMON SHARED a%(n%+2)\nEND"), + ("LIB", "COMMON SHARED a%(n%+3)"), + ]) + .unwrap(); + let mut vm = Vm::new(CompiledModule::from_tbc(&module.to_tbc()).unwrap()); + assert!(matches!( + vm.run(&mut CaptureHost::default()), + RunEvent::Error { + code: 13, + line: 1, + .. + } + )); + assert_eq!(vm.current_file(), "LIB.bas"); +} + +#[test] +fn prozedurlokaler_deftype_veraendert_keine_nachfolgende_signatur() { + let files=[("MAIN","PRINT F(3)\nEND"),("LIB","DEFINT A-Z\nSUB Setup\nDEFSTR A-Z\nx=\"local\"\nEND SUB\nFUNCTION F(x)\nF=x*2\nEND FUNCTION")]; + assert_eq!( + run(compile(&files).unwrap()), + (RunEvent::Ended, " 6 \n".into()) + ); +} + +#[test] +fn gemischte_frm_und_form_module_haben_getrennte_ereignisbindungen() { + let form=tb_ui::frm::read_text("A.frm","VERSION 1.00\nBEGIN Form A\n Caption = \"design\"\nEND\nSUB Form_Load\nCaption=\"a\"\nEND SUB").unwrap(); + let sources = units(&[ + ( + "MAIN", + "B.Show\na$=A.Caption\nb$=B.Caption\nA.Hide\nB.Hide\nCLS\nPRINT a$;b$\nEND", + ), + ("A", &form.code), + ("B", "'$FORM\nSUB Form_Load\nCaption=\"b\"\nEND SUB"), + ]); + let module = compile_project("APP", &sources, &form.catalog(), &[form]).unwrap(); + assert_eq!(module.event_procs.len(), 2); + assert_ne!(module.event_procs[0].object, module.event_procs[1].object); + assert_eq!(run(module), (RunEvent::Ended, "ab\n".into())); +} + +#[test] +fn diagnostik_bei_literalen_im_include_hat_die_exakte_spalte() { + let unit = SourceUnit { + name: "MAIN".into(), + segments: vec![ + SourceSegment { + file: "main.bas".into(), + first_line: 1, + text: "DECLARE SUB F(x%)\n".into(), + }, + SourceSegment { + file: "args.bi".into(), + first_line: 42, + text: " CALL F(\"wrong\")\n".into(), + }, + ], + }; + let errors = compile_project("APP", &[unit], &FormCatalog::default(), &[]).unwrap_err(); + assert!( + errors.iter().any(|e| e.file.as_deref() == Some("args.bi") + && e.pos.line == 42 + && e.pos.column == 10 + && e.message.contains("Parameter type mismatch")), + "{errors:?}" + ); +} + +#[test] +fn spaetere_common_konflikte_und_doppelte_module_werden_am_ursprung_gemeldet() { + let errors = compile(&[ + ("MAIN", "COMMON a%()\nEND"), + ("LIB", "COMMON a%(2)"), + ("THIRD", "' head\nCOMMON a%(3)"), + ]) + .unwrap_err(); + assert!( + errors.iter().any(|e| e.file.as_deref() == Some("THIRD.bas") + && e.pos.line == 2 + && e.message.contains("COMMON")), + "{errors:?}" + ); + let units = [ + SourceUnit::new("SAME", "first.bas", "END"), + SourceUnit::new("SAME", "second.bas", "' head\n END"), + ]; + let errors = compile_project("APP", &units, &FormCatalog::default(), &[]).unwrap_err(); + assert!( + errors + .iter() + .any(|e| e.file.as_deref() == Some("second.bas") + && e.pos.line == 2 + && e.pos.column == 3 + && e.message.contains("Duplicate")), + "{errors:?}" + ); +} diff --git a/docs/dateiformate.md b/docs/dateiformate.md index 9feabb1..3424fa3 100644 --- a/docs/dateiformate.md +++ b/docs/dateiformate.md @@ -253,6 +253,10 @@ Das Dateiformat bleibt Version 1. ## Kompilat: `.tbc` (neu, eigenes Format) -Container für TBVM-Bytecode, Entwurf in [tbvm-design.md](tbvm-design.md). -`tbc build` erzeugt wahlweise `.tbc` oder ein eigenständiges Executable -(Runner + eingebettetes `.tbc`). +Version 4 ist ein eigenständig ausführbarer Bytecode-Container; das vollständige +Format steht in [tbvm-design.md](tbvm-design.md). `tbc build app.mak` erzeugt +`app.tbc`, `tbc run app.tbc` führt es ohne BAS-/FRM-/MAK-/Include-Quellen aus. +Quellorte, Prozedursignaturen und Formular-Anfangswerte einschließlich +Designzeit-Control-Arrays bleiben erhalten. Ältere Versionen 1–3 und unbekannte +Versionen werden mit Versionsangabe abgewiesen. Native Executable-Verpackung +ist noch nicht implementiert. diff --git a/docs/reviews/2026-09-05-openspec-code-review.md b/docs/reviews/2026-09-05-openspec-code-review.md index 891a0a9..53f3d1b 100644 --- a/docs/reviews/2026-09-05-openspec-code-review.md +++ b/docs/reviews/2026-09-05-openspec-code-review.md @@ -31,6 +31,21 @@ einschließlich breiter Zeichen und Bildschirmränder. Die erneute Verifikation hat keine offenen Befunde im Change: 428 Workspace-Tests und alle vier Review-Proben bestehen; eine Bildschirmmatrix deckt 3.360 Kombinationen ab. +## Umsetzungsstand: Projektmodule und Kompilat + +F09, F10, F11 und F24 sind im archivierten Change +[projektmodule-und-kompilat](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md) +umgesetzt. Die zunächst sechs offenen Reviewbefunde und alle bei der +Nachprüfung zusätzlich reproduzierten Abweichungen sind behoben. +Die [abschließende Verifikation](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review.md) +hat keine offenen Befunde im Change: 453 Workspace-Tests, alle sieben +unveränderten Reviewproben, Clippy, Format-/Spec-Prüfung und das echte +Mehrmodul-Compile-Budget bestehen. Alle zwölf Tasks sind abgeschlossen; +Die Hauptspecs wurden synchronisiert und der Change am 05.09.2026 +archiviert. Details und +Szenariozuordnung: [Implementierungsnachweis](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/verification.md). +Die folgenden Befundtexte bleiben die historische Bestandsaufnahme. + ## Prüfmethode und Grenzen - Alle Requirements/Szenarien gelesen und gegen Einstiegspunkte, Implementierung und vorhandene Tests abgeglichen. Die Matrix unten enthält jeden Requirement-Titel einmal. @@ -113,7 +128,7 @@ Zwei Formulare mit je Text1 lassen sich laden, aber Form2!Text1.Text erzeugt Con Beleg: [crates/tb-frontend/src/sema.rs:949](../../crates/tb-frontend/src/sema.rs#L949). Vertrag: [openspec/specs/forms-objektmodell/spec.md:14](../../openspec/specs/forms-objektmodell/spec.md#L14), [openspec/specs/forms-objektmodell/spec.md:36](../../openspec/specs/forms-objektmodell/spec.md#L36), [openspec/specs/sprach-frontend/spec.md:251](../../openspec/specs/sprach-frontend/spec.md#L251). -Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md). +Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md). ### F10 — Quellorte und Debuggerziele verlieren die Modulidentität (hoch) @@ -121,7 +136,7 @@ Ein Overflow in lib.bas:2 wird über app.mak als Overflow in line 5 ohne Modulda Beleg: [crates/tb-cli/src/main.rs:184](../../crates/tb-cli/src/main.rs#L184). Vertrag: [openspec/specs/bytecode-kompilat/spec.md:46](../../openspec/specs/bytecode-kompilat/spec.md#L46), [openspec/specs/sprach-frontend/spec.md:96](../../openspec/specs/sprach-frontend/spec.md#L96), [openspec/specs/vm-ausfuehrung/spec.md:89](../../openspec/specs/vm-ausfuehrung/spec.md#L89), [openspec/specs/vm-fehlerbehandlung/spec.md:11](../../openspec/specs/vm-fehlerbehandlung/spec.md#L11). -Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md). +Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md). ### F11 — Formularwerte fehlen im serialisierten Kompilat (hoch) @@ -129,7 +144,7 @@ Mit FormFile.apply liest die VM den initialen Text hello; nach Serialisierung un Beleg: [crates/tb-vm/src/bytecode.rs:484](../../crates/tb-vm/src/bytecode.rs#L484). Vertrag: [openspec/specs/bytecode-kompilat/spec.md:28](../../openspec/specs/bytecode-kompilat/spec.md#L28). -Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md). +Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md). ### F12 — ListIndex = -1 wird abgewiesen (mittel) @@ -233,7 +248,7 @@ Die Hauptspec verlangt separate CODE/LINES-Abschnitte und vollständige Prozedur Beleg: [docs/tbvm-design.md:69](../../docs/tbvm-design.md#L69). Vertrag: [openspec/specs/bytecode-kompilat/spec.md:12](../../openspec/specs/bytecode-kompilat/spec.md#L12), [openspec/specs/bytecode-kompilat/spec.md:28](../../openspec/specs/bytecode-kompilat/spec.md#L28). -Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md). +Umsetzung: [projektmodule-und-kompilat](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md). ### F25 — FRM-Schreibvertrag enthält zwei unvereinbare Universalregeln (Klärung) @@ -250,7 +265,7 @@ Umsetzung: [spezifikationsabgleich-und-regressionsnachweise](../../openspec/chan | 1 | [isam-transaktionen-und-dateinummern](../../openspec/changes/archive/2026-09-05-isam-transaktionen-und-dateinummern/proposal.md) | F01, F02, F03 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 2 | [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md) | F04, F05, F06, F07, F08, F18, F20 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 3 | [forms-zustand-und-bedienung](../../openspec/changes/archive/2026-09-05-forms-zustand-und-bedienung/proposal.md) | F12, F13 | Nach Ereigniszustellung | -| 4 | [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md) | F09, F10, F11, F24 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | +| 4 | [projektmodule-und-kompilat](../../openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md) | F09, F10, F11, F24 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 5 | [laufzeit-eingabe-und-systemstatus](../../openspec/changes/laufzeit-eingabe-und-systemstatus/proposal.md) | F14, F15, F16, F17, F19 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 6 | [spezifikationsabgleich-und-regressionsnachweise](../../openspec/changes/spezifikationsabgleich-und-regressionsnachweise/proposal.md) | F21, F22, F23, F25 | Abschließende Gesamtabnahme nach den fünf Korrektur-Changes | diff --git a/docs/tbvm-design.md b/docs/tbvm-design.md index 6a0fcbb..34f6a24 100644 --- a/docs/tbvm-design.md +++ b/docs/tbvm-design.md @@ -126,33 +126,100 @@ Der erste Befehl lädt ein FRM-Korpusprogramm und prüft dessen `KM`-Ausgabe mit CaptureHost. Der zweite Baum darf crossterm, ratatui und signal-hook nicht enthalten. -## `.tbc`-Container (Stand Phase 2, Formatversion 1) +## `.tbc`-Container (Formatversion 4, 2026-09-05) -``` -Magic "TBC\0" · Formatversion u16 · Flags u16 · Abschnittsanzahl u32 -Abschnittstabelle: [ (Kennung 4 Byte, Offset u32, Länge u32) ] -Abschnitte: - MODN Modulname + OPTION BASE - CONS deduplizierter Stringpool - TYPS TYPE-Layouts (Feld-Initialisierungstypen) - GLOB globale Slots (Initialisierungstyp + Name für Debugger) - PROC Prozedurtabelle: Name, Parameteranzahl, Frame-Slots, Code - (Zeileninfo liegt inline im Code: Stmt-/SetErl-Instruktionen — - ersetzt den früher geplanten separaten LINES-Abschnitt) - DATA DATA-Konstanten (Rohtext + Quellzeile) - JMPT Sprungtabellen für ON n GOTO/GOSUB - FORMS (ab Phase 4: serialisierte .FRM-Beschreibungen) -``` +`tbc build app.mak` erzeugt `app.tbc`; `tbc run app.tbc` benötigt die +BAS-/FRM-/MAK-/Include-Quellen nicht mehr. Dieselbe VM führt Quellprogramme +und geladene Kompilate aus. Versionen 1–3 und unbekannte Versionen werden +mit Angabe der gefundenen und unterstützten Version abgewiesen. -Serialisierung mit einfachem eigenem Writer/Reader (kein serde nötig, -Format bleibt stabil und dokumentiert); unbekannte Formatversionen -werden mit Meldung abgewiesen. Instruktions-Encoding: 1 Opcode-Byte + -Operanden little-endian, Opcode-Bytes gruppenweise mit Lücken vergeben -(`crates/tb-vm/src/bytecode.rs` ist die normative Liste). +Alle Zahlen sind little-endian, Ganzzahlen mit Vorzeichen im Zweierkomplement, +`f32`/`f64` nach IEEE 754. `bool` ist genau ein Byte (0/1). `string` bedeutet +`u32` UTF-8-Bytelänge, gefolgt von genau diesen Bytes ohne Abschlussnull. +Listen beginnen mit `u32` Elementanzahl; `[...]` bezeichnet die folgenden +wiederholten Elemente. Tabellen-IDs sind nullbasiert. + +Der Header besteht aus `TBC\0` (4 Byte), Version `u16 = 4`, Flags `u16 = 0`, +Abschnittsanzahl `u32 = 9`. Darauf folgen neun Einträge mit Kennung (4 ASCII-Bytes), +absolutem Datei-Offset `u32` und Bytelänge `u32`. Der Writer schreibt die +folgenden Abschnitte in dieser Reihenfolge; der Reader findet sie über die Tabelle. + +| Kennung | Payload in Reihenfolge | +|---|---| +| `MODN` | Projektname `string`, Vorgabe-OPTION-BASE `u8` (wie Modul 0) | +| `SRCS` | Modulanzahl `u32`, `[Name string, OPTION BASE u8 (0/1)]`; Quellenanzahl `u32`, `[Modul-ID u16, Dateipfad string]` | +| `CONS` | Anzahl `u32`, `[string]`; Stringkonstantenpool | +| `TYPS` | Anzahl `u32`, `[TYPE-Name string, Feldanzahl u32, [TypeInit]]` | +| `GLOB` | Slotanzahl `u32`, `[TypeInit, Debuggername string]` | +| `PROC` | Prozeduranzahl `u32`, `[Prozedurbeschreibung gemäß unten]` | +| `DATA` | Anzahl `u32`, `[Rohtext string, physische Zeile u32]` | +| `JMPT` | Tabellenanzahl `u32`, `[Zielanzahl u32, [Instruktionsindex u32]]` | +| `OBJS` | Objekt-, Ereignis- und Anfangsdaten gemäß unten | + +Eine `PROC`-Beschreibung enthält: Name `string`, Modul-ID `u16`, Art `u8` +(0 Hauptprogramm, 1 SUB, 2 FUNCTION, 3 DEF FN), Parameteranzahl `u32`, +`[Parametername string, HTy, Array bool, BYREF bool]`, Rückgabetyp-vorhanden +`bool`, gegebenenfalls `HTy`, Aufrufparameteranzahl `u16`, Local-Anzahl `u32`, +`[TypeInit, Local-Name string]`, Instruktionsanzahl `u32`, Code-Bytelänge `u32`, +Code-Bytes. Die beiden Parameteranzahlen müssen übereinstimmen. Prozedur 0 +enthält die zusammengeführten Modulrümpfe; weitere Prozeduren behalten ihre +Modul-ID. In Mehrmodulprojekten sind Prozedur-/globale Debuggernamen +`Modul!Name` mit dem aufgelösten Typ-Suffix bei implizit/suffixdeklarierten +Variablen. AS-deklarierte Namen bleiben suffixlos. Die Inspektion unterscheidet +explizite Suffixe; ohne Suffix muss der Basisname eindeutig sein. Aufrufe und +Variablenzugriffe verwenden ausschließlich IDs. + +`TypeInit` belegt immer 5 Byte: Tag `u8` und Zusatz `u32`. Tags: 0 INTEGER, +1 LONG, 2 SINGLE, 3 DOUBLE, 4 CURRENCY, 5 STRING, 6 fester STRING (Zusatz: +Länge), 7 UDT (Zusatz: TYPE-ID, maximal 65535), 8 leerer Slot. Sonst ist der +Zusatz 0. UDT-Felder dürfen nur frühere TYPE-IDs referenzieren. +`HTy` ist ein `u8`-Tag mit denselben skalaren Tags 0–7; nur bei Tag 6 folgen +`u32` Länge und bei Tag 7 `u16` TYPE-ID. Tag 8 bezeichnet FORM, Tag 9 CONTROL. +Arrays werden separat im Parametersatz markiert; ihr Local-Slot beginnt leer. + +`OBJS` enthält Objektanzahl `u32`, `[Name string, Klasse u8, Elternname string +(leer bei Wurzel), Array bool, Eltern-ID u16 (FFFF = keine)]`, dann +Ereignisanzahl `u32`, `[Objekt-ID u16, Ereignisname string, Prozedur-ID u16]`, +Startformular-ID `u16` (FFFF = keines), Anzahl Anfangsinstanzen `u32`, +`[Objekt-ID u16, Designindex i32, Eigenschaftsanzahl u32, +[Eigenschafts-ID u16, PropertyValue]]`. Eltern-IDs zeigen auf frühere Objekte; +gleichnamige Controls anderer Formulare bleiben dadurch getrennt. Auch +Designinstanzen mit Index ungleich 0 und sämtliche FRM-Anfangswerte sind enthalten. +Eigenschaften stehen in aufsteigender ID-Reihenfolge. + +`PropertyValue` beginnt mit Tag `u8`: 0 Integer (`i32`), 1 Single (`f32`), +2 String (`string`), 3 Boolean (`bool`), 4 Objekt (`bool` vorhanden, falls ja +`u16` Objekt-ID und `bool` Index-vorhanden, gegebenenfalls `i32` Index), +5 Integer-Array (`u32` Anzahl, `[i32]`). Klassen-IDs 0–18 entsprechen in +Reihenfolge Form, CheckBox, ComboBox, CommandButton, DirListBox, DriveListBox, +FileListBox, Frame, HScrollBar, Label, ListBox, Menu, OptionButton, PictureBox, +TextBox, Timer, VScrollBar, Screen, Spin. Eigenschafts-/Methoden-IDs sind +Indizes der jeweiligen Klassentabellen in `tb-frontend/src/forms.rs`. + +Code und Quellorte liegen inline in `PROC`; es gibt keine `CODE`-/`LINES`- +oder `FORMS`-Abschnitte. `Source(Datei-ID, Spalte)` steht unmittelbar vor +`Stmt(physische Zeile)`. Die VM liest diese Zuordnung auch bei einem Sprung +direkt auf `Stmt`. Globale DIM-Anweisungen tragen `InitStmt(physische Zeile)`: Fehlerorte und +Debugger bleiben aktiv, Ereignisse werden bis nach den Initialisierungen +zurückgestellt. Zeile 0 ist eine synthetische Initialisierungsgrenze. +`SetErl(BASIC-Label)` aktualisiert getrennt davon die numerische Zeilennummer +für ERL. Include-Dateien behalten ihren Pfad und physischen Quellort sowie +die ID ihres einbindenden Moduls. `add_module_breakpoint(module, line)` und +`set_step(true)` halten an diesen Grenzen; `current_module`, `current_file` +und `current_source_pos` liefern den Halt-/Fehlerort. `inspect` akzeptiert +lokale, im aktuellen Modul sichtbare und explizit qualifizierte Variablennamen. + +Der Reader prüft Version, Flags, eindeutige bekannte Abschnitte, +Längen/Überlappungen, vollständige Payloads, Typ-/Objekt-/Prozedur-/Quell-IDs, +Sprungziele und Anfangseigenschaften vor der Ausführung. Beschädigte Daten +liefern einen Ladefehler. Das Format ist kein Sandbox-Format für fremden +Programmcode; ein vollständiger Stack-/Kontrollflussverifizierer ist nicht Teil +von TBC. Ein gültiges vom Writer erzeugtes Kompilat lässt sich bytegleich +laden und erneut serialisieren. ## Eigenständige Executables -`tbc build --exe` kopiert den vorkompilierten Runner (dieselbe +Geplant, noch nicht implementiert: `tbc build --exe` kopiert den vorkompilierten Runner (dieselbe tb-vm/tb-runtime/tb-ui-Bibliothek wie die IDE) und hängt das `.tbc` als Ressource an (Anhängen ans Binary + Fußzeile mit Offset/Magic; portabel für alle drei Plattformen). Alternative — `include_bytes!` + Cargo-Build beim @@ -165,9 +232,19 @@ Nutzer — verworfen: erfordert Rust-Toolchain beim Anwender. Vorwärtsreferenzen (Prozeduren, Labels) über Fixup-Listen statt zweitem Pass. Die Sprache ist dafür gemacht — das Vorbild kompilierte auf 1992er-Hardware gefühlt sofort. -- Modulweise inkrementell: `.BAS`/`.FRM` werden unabhängig zu Bytecode- - Einheiten übersetzt und beim Build nur zusammengebunden; die IDE - recompiliert nur geänderte Module (Hash über Quelltext). +- `.BAS`/`.FRM` werden getrennt geparst, semantisch aufgelöst und zu Bytecode- + Einheiten übersetzt. Der Projektlink versetzt Slots, Prozeduren, Typen, + Strings, DATA und Sprungtabellen. Modulrümpfe laufen in Projektreihenfolge; + ihre globalen Initialisierungen liegen vor dem ersten Ereignis. Eindeutige + externe Prozeduren werden mit dem DEFtype-Kontext ihres Ursprungs importiert; + prozedurlokale DEFtype-Angaben beeinflussen keine anderen Prozeduren. + Importierte Konstanten werden im Ursprung gefaltet, auch bei transitiven + Modulabhängigkeiten. TYPE-Abhängigkeiten behalten ihr ursprüngliches Layout; + interne qualifizierte Typnamen verhindern Konflikte mit abweichenden lokalen + Definitionen. Echte lokale Duplikate werden diagnostiziert. SHARED + bleibt modullokal. COMMON-Variablen desselben Blocks und derselben vollständigen + Variablenidentität teilen einen Slot; Elementtyp, Rang und bekannte Grenzen + müssen kompatibel sein. Ein inkrementeller IDE-Cache ist weiterhin geplant. - Keine Optimierungspasses. Erlaubt sind nur Gratis-Optimierungen im Codegen (Konstantenfaltung im Ausdruck, Peephole beim Emit). - Budget als Test verankern (Phase 2): Benchmark-Projekt (~50k Zeilen) @@ -292,7 +369,7 @@ via `CINT`/`CLNG`/`CSNG`/`CDBL`/`CCUR` — identische Semantik): `-` voran und hängt stets ein Leerzeichen an; `STR$` nur das führende Leerzeichen/`-`, kein nachgestelltes. -## Opcode-Satz (Feindesign, 2026-09-02) +## Opcode-Satz (Formatversion 4, 2026-09-05) In-Memory führt die VM dekodierte Instruktionen (`Vec`, ein Rust- Enum mit eingebetteten Operanden — Wort-Dispatch, keine Byte-Dekodierung @@ -300,26 +377,205 @@ im heißen Pfad); die `.tbc`-Serialisierung bildet jede Instruktion auf 1 Opcode-Byte + Operanden (little-endian) ab. Typkürzel: `I2`=INTEGER, `I4`=LONG, `R4`=SINGLE, `R8`=DOUBLE, `CY`=CURRENCY, `STR`=STRING. -| Gruppe | Instruktionen | Bemerkung | -|---|---|---| -| Anweisungsgrenze | `Stmt(line:u32)` · `SetErl(n:u32)` | `Stmt` prüft das Tick-Flag-Wort (Breakpoint/Einzelschritt/Abbruch), aktualisiert Zeile und Resume-Punkt; `SetErl` bei numerischen Zeilennummern | -| Konstanten/Stack | `PushInt(i16)` `PushLng(i32)` `PushSng(f32)` `PushDbl(f64)` `PushCur(i64)` `PushStr(pool:u16)` · `Dup` `Pop` | Stringpool dedupliziert | -| Variablen | `LoadGlobal/StoreGlobal(u16)` · `LoadLocal/StoreLocal(u16)` · `MakeRefGlobal/MakeRefLocal(u16)` · `LoadRef/StoreRef(u16)` | Slots statisch aufgelöst; `*Ref` bedienen BYREF-Parameter (Referenz-Werte) | -| Arrays | `DimGlobal/DimLocal{slot,dims,elem}` `RedimGlobal/RedimLocal{…}` `EraseGlobal/EraseLocal(u16)` · `LoadElem/StoreElem{dims:u8}` · `MakeRefElem{dims:u8}` | Grenzen auf dem Stack (lo/hi je Dimension als LONG); Indexprüfung → Fehler 9 | -| UDT | `LoadField/StoreField(u16)` · `MakeRefField(u16)` | Feldindex aus Typtabelle; verschachtelt durch Verkettung | -| Arithmetik | `Add/Sub/Mul{I2,I4,R4,R8,CY}` · `Neg{…}` · `Div{R4,R8}` · `IDiv/Mod{I2,I4}` · `PowR8` (`^` rechnet in DOUBLE, SINGLE-Ergebnis per Conv) | monomorph; Ganzzahl-/CY-Überlauf → Fehler 6, Division durch 0 → Fehler 11 | -| Konvertierung | `Conv__` für alle 20 geordneten Paare aus {I2,I4,R4,R8,CY} | Semantik exakt nach Matrix (Rundung/Überlauf) | -| Logik | `Not/And/Or/Xor/Eqv/Imp{I2,I4}` | bitweise | -| Vergleich | `Cmp{Eq,Ne,Lt,Le,Gt,Ge}{I2,I4,R4,R8,CY,STR}` | Ergebnis INTEGER −1/0 | -| Strings | `Concat` | `Rc`, Kopien sind Pointer-Kopien | -| Kontrollfluss | `Jump(u32)` `JumpIfFalse(u32)` `JumpIfTrue(u32)` | Ziele absolut (Instruktionsindex) innerhalb der Code-Einheit; Fixups beim Emit | -| GOSUB | `Gosub(u32)` `RetGosub` `RetGosubTo(u32)` | GOSUB-Stack pro Frame; leer → Fehler 3 | -| Berechnete Sprünge | `OnJump{table:u16,gosub:bool}` | Sprungtabellen im Modul; 0/zu groß: kein Sprung; negativ/>255 → Fehler 5 | -| Prozeduren | `Call(proc:u16)` `RetProc` `RetFn` `CallBuiltin{id:u16,argc:u8}` | Argumente links→rechts auf dem Stack (Werte oder Referenzen); `RetFn` transportiert den Funktionswert über den Frame-Abbau | -| Fehler | `OnErrorGoto(u32)` `OnErrorLocal(u32)` `OnErrorDisable` `OnErrorLocalDisable` · `Resume0` `ResumeNext` `ResumeLabel(u32)` · `RaiseError` | Scoping siehe unten | -| DATA | `ReadData(art)` `Restore(u32)` | art 0 = String, 1 = Zahl (DOUBLE, dann Conv nach Matrix); Ende → Fehler 4, unkonvertierbar → Fehler 13 | -| E/A | `Input{…}` sowie Builtins (`PRINT`-Familie über `CallBuiltin`) | Konsolenwirkung ausschließlich über das `Host`-Trait | -| Ende | `End` `StopInstr` `SystemInstr` | Verhalten siehe Runner | +Die folgende Tabelle führt jeden serialisierbaren Opcode einzeln auf; +Gesamtbytes schließen das Opcode-Byte ein. `bool`, `TypeInit` und primitive +Breiten sind im Containerabschnitt definiert. `CmpOp` belegt ein Byte: +0 gleich, 1 ungleich, 2 kleiner, 3 kleiner/gleich, 4 größer, 5 größer/gleich. +Nicht aufgeführte Opcode-Bytes sind ungültig. + +| Byte | Instruktion | Operanden in Reihenfolge | Gesamtbytes | +|---|---|---|---| +| `0x00` | `Stmt` | line: u32 | 5 | +| `0x01` | `SetErl` | label: u32 | 5 | +| `0x02` | `End` | — | 1 | +| `0x03` | `StopInstr` | — | 1 | +| `0x04` | `SystemInstr` | — | 1 | +| `0x05` | `Unsupported` | name: u16 | 3 | +| `0x06` | `Source` | source: u32, column: u32 | 9 | +| `0x07` | `InitStmt` | line: u32 | 5 | +| `0x10` | `PushInt` | value: i16 | 3 | +| `0x11` | `PushLng` | value: i32 | 5 | +| `0x12` | `PushSng` | value: f32 | 5 | +| `0x13` | `PushDbl` | value: f64 | 9 | +| `0x14` | `PushCur` | value: i64 | 9 | +| `0x15` | `PushStr` | pool: u16 | 3 | +| `0x16` | `Dup` | — | 1 | +| `0x17` | `Pop` | — | 1 | +| `0x18` | `PushUdtId` | type: u16 | 3 | +| `0x20` | `LoadGlobal` | slot: u16 | 3 | +| `0x21` | `StoreGlobal` | slot: u16 | 3 | +| `0x22` | `LoadLocal` | slot: u16 | 3 | +| `0x23` | `StoreLocal` | slot: u16 | 3 | +| `0x24` | `LoadRef` | slot: u16 | 3 | +| `0x25` | `StoreRef` | slot: u16 | 3 | +| `0x26` | `MakeRefGlobal` | slot: u16 | 3 | +| `0x27` | `MakeRefLocal` | slot: u16 | 3 | +| `0x28` | `MakeRefElem` | dimensions: u8 | 2 | +| `0x29` | `MakeRefField` | field: u16 | 3 | +| `0x30` | `LoadArr` | global: bool, slot: u16, dimensions: u8, element: TypeInit | 10 | +| `0x31` | `LoadElem` | dimensions: u8 | 2 | +| `0x32` | `StoreElem` | dimensions: u8 | 2 | +| `0x33` | `DimArr` | global: bool, slot: u16, dimensions: u8, element: TypeInit | 10 | +| `0x34` | `RedimArr` | global: bool, slot: u16, dimensions: u8, element: TypeInit | 10 | +| `0x35` | `EraseSlot` | global: bool, slot: u16 | 4 | +| `0x36` | `LoadField` | field: u16 | 3 | +| `0x37` | `StoreField` | field: u16 | 3 | +| `0x38` | `CopyRec` | — | 1 | +| `0x39` | `ArrBound` | lower: bool | 2 | +| `0x3A` | `FixStr` | length: u32 | 5 | +| `0x3B` | `CommonArr` | global: bool, slot: u16, dimensions: u8, element: TypeInit | 10 | +| `0x40` | `AddI2` | — | 1 | +| `0x41` | `AddI4` | — | 1 | +| `0x42` | `AddR4` | — | 1 | +| `0x43` | `AddR8` | — | 1 | +| `0x44` | `AddCy` | — | 1 | +| `0x45` | `SubI2` | — | 1 | +| `0x46` | `SubI4` | — | 1 | +| `0x47` | `SubR4` | — | 1 | +| `0x48` | `SubR8` | — | 1 | +| `0x49` | `SubCy` | — | 1 | +| `0x4A` | `MulI2` | — | 1 | +| `0x4B` | `MulI4` | — | 1 | +| `0x4C` | `MulR4` | — | 1 | +| `0x4D` | `MulR8` | — | 1 | +| `0x4E` | `MulCy` | — | 1 | +| `0x4F` | `NegI2` | — | 1 | +| `0x50` | `NegI4` | — | 1 | +| `0x51` | `NegR4` | — | 1 | +| `0x52` | `NegR8` | — | 1 | +| `0x53` | `NegCy` | — | 1 | +| `0x54` | `DivR4` | — | 1 | +| `0x55` | `DivR8` | — | 1 | +| `0x56` | `IDivI2` | — | 1 | +| `0x57` | `IDivI4` | — | 1 | +| `0x58` | `ModI2` | — | 1 | +| `0x59` | `ModI4` | — | 1 | +| `0x5A` | `PowR8` | — | 1 | +| `0x5B` | `Concat` | — | 1 | +| `0x60` | `ConvI2I4` | — | 1 | +| `0x61` | `ConvI2R4` | — | 1 | +| `0x62` | `ConvI2R8` | — | 1 | +| `0x63` | `ConvI2Cy` | — | 1 | +| `0x64` | `ConvI4I2` | — | 1 | +| `0x65` | `ConvI4R4` | — | 1 | +| `0x66` | `ConvI4R8` | — | 1 | +| `0x67` | `ConvI4Cy` | — | 1 | +| `0x68` | `ConvR4I2` | — | 1 | +| `0x69` | `ConvR4I4` | — | 1 | +| `0x6A` | `ConvR4R8` | — | 1 | +| `0x6B` | `ConvR4Cy` | — | 1 | +| `0x6C` | `ConvR8I2` | — | 1 | +| `0x6D` | `ConvR8I4` | — | 1 | +| `0x6E` | `ConvR8R4` | — | 1 | +| `0x6F` | `ConvR8Cy` | — | 1 | +| `0x70` | `ConvCyI2` | — | 1 | +| `0x71` | `ConvCyI4` | — | 1 | +| `0x72` | `ConvCyR4` | — | 1 | +| `0x73` | `ConvCyR8` | — | 1 | +| `0x80` | `NotI2` | — | 1 | +| `0x81` | `NotI4` | — | 1 | +| `0x82` | `AndI2` | — | 1 | +| `0x83` | `AndI4` | — | 1 | +| `0x84` | `OrI2` | — | 1 | +| `0x85` | `OrI4` | — | 1 | +| `0x86` | `XorI2` | — | 1 | +| `0x87` | `XorI4` | — | 1 | +| `0x88` | `EqvI2` | — | 1 | +| `0x89` | `EqvI4` | — | 1 | +| `0x8A` | `ImpI2` | — | 1 | +| `0x8B` | `ImpI4` | — | 1 | +| `0x90` | `CmpI2` | compare: CmpOp | 2 | +| `0x91` | `CmpI4` | compare: CmpOp | 2 | +| `0x92` | `CmpR4` | compare: CmpOp | 2 | +| `0x93` | `CmpR8` | compare: CmpOp | 2 | +| `0x94` | `CmpCy` | compare: CmpOp | 2 | +| `0x95` | `CmpStr` | compare: CmpOp | 2 | +| `0xA0` | `Jump` | target: u32 | 5 | +| `0xA1` | `JumpIfFalse` | target: u32 | 5 | +| `0xA2` | `JumpIfTrue` | target: u32 | 5 | +| `0xA3` | `Gosub` | target: u32 | 5 | +| `0xA4` | `RetGosub` | — | 1 | +| `0xA5` | `RetGosubTo` | target: u32 | 5 | +| `0xA6` | `OnJump` | table: u16, gosub: bool | 4 | +| `0xA7` | `Run` | kind: u8 | 2 | +| `0xB0` | `Call` | proc: u16, argc: u8 | 4 | +| `0xB1` | `RetProc` | — | 1 | +| `0xB2` | `RetFn` | — | 1 | +| `0xB3` | `CallBuiltin` | builtin: u16, argc: u8 | 4 | +| `0xB4` | `LoadObjectProperty` | object: u16, property: u16, indexed: bool | 6 | +| `0xB5` | `StoreObjectProperty` | object: u16, property: u16, indexed: bool | 6 | +| `0xB6` | `PushObject` | object: u16, indexed: bool | 4 | +| `0xB7` | `TypeOf` | class: u8 | 2 | +| `0xB8` | `ObjectMethod` | object: u16, method: u16, argc_flags: u8 | 6 | +| `0xB9` | `ObjectLoad` | object: u16, unload: bool, indexed: bool | 5 | +| `0xBA` | `LoadDynamicObjectProperty` | name: u16 | 3 | +| `0xBB` | `StoreDynamicObjectProperty` | name: u16 | 3 | +| `0xBC` | `LoadObjectIndexedProperty` | object: u16, property_flags: u16 | 5 | +| `0xBD` | `ObjectMethodFn` | object: u16, method: u16, argc_flags: u8 | 6 | +| `0xBE` | `StoreObjectIndexedProperty` | object: u16, property_flags: u16 | 5 | +| `0xC0` | `OnErrorGoto` | target: u32 | 5 | +| `0xC1` | `OnErrorLocal` | target: u32 | 5 | +| `0xC2` | `OnErrorDisable` | — | 1 | +| `0xC3` | `OnErrorLocalDisable` | — | 1 | +| `0xC4` | `OnErrorResumeNext` | local: bool | 2 | +| `0xC5` | `Resume0` | — | 1 | +| `0xC6` | `ResumeNext` | — | 1 | +| `0xC7` | `ResumeLabel` | target: u32 | 5 | +| `0xC8` | `RaiseError` | — | 1 | +| `0xC9` | `LoadErr` | — | 1 | +| `0xCA` | `LoadErl` | — | 1 | +| `0xCB` | `SetErr` | — | 1 | +| `0xD0` | `ReadData` | kind: u8 | 2 | +| `0xD1` | `Restore` | data: u32 | 5 | +| `0xD2` | `Input` | argc: u8, line_mode: bool, prompt: u16, question: bool | 6 | +| `0xD3` | `InputFile` | argc: u8, line_mode: bool | 3 | +| `0xD4` | `GetPut` | put: bool, has_record: bool, kind: u8, extra: u16 | 6 | +| `0xD5` | `Field` | count: u8 | 2 | +| `0xD6` | `LsetRset` | right: bool | 2 | +| `0xE0` | `TrapDefine` | kind: u8, target: u32 | 6 | +| `0xE1` | `TrapDisable` | kind: u8 | 2 | +| `0xE2` | `TrapSet` | kind: u8, state: u8 | 3 | +| `0xE3` | `EventSwitch` | enabled: bool | 2 | +| `0xE4` | `Doevents` | — | 1 | +| `0xE5` | `Sleep` | has_seconds: bool | 2 | + +`target` bezeichnet einen Instruktionsindex innerhalb der aktuellen Prozedur; +`OnErrorGoto` zeigt in Prozedur 0. `table` referenziert `JMPT`, `proc` die +Prozedurtabelle, `pool`/`name`/`prompt` den Stringpool. `global=true` wählt +`GLOB`, sonst einen Local-Slot. Arrayindizes und Dimensionsgrenzen (je lo/hi) +liegen auf dem Stack. `ArrBound(lower)` unterscheidet LBOUND/UBOUND; +`MakeRef*` erzeugt Referenzen, `CopyRec` eine UDT-Wertkopie. `PushUdtId` legt +eine beim Linken versetzte TYPE-ID als LONG für ISAM auf den Stack. + +`Call`/`CallBuiltin` konsumieren `argc` Argumente in Quellreihenfolge. +`builtin` ist die feste ID aus `tb-runtime/src/builtins.rs::ids`; `RetFn` +transportiert den Funktionswert. `ObjectMethod`/`ObjectMethodFn` verwenden +klassenlokale Methodenindizes, keine Stringpool-IDs. Das höchste Bit von +`argc_flags` kennzeichnet einen zusätzlichen Control-Arrayindex vor den +Argumenten; die unteren 7 Bit enthalten die Argumentanzahl. Bei indizierten +Eigenschaften markiert Bit 15 von `property_flags` den zusätzlichen +Control-Arrayindex; die unteren 15 Bit sind die Eigenschafts-ID. Der +Eigenschaftsindex liegt anschließend auf dem Stack. `indexed` bedeutet +ansonsten ebenfalls Control-Arrayindex auf dem Stack. Dynamische +Eigenschaftszugriffe verwenden den Namen im Stringpool und einen Objektwert +vom Stack. `TypeOf(class)` verwendet die oben genannten Klassen-IDs. + +`TrapDefine`/`TrapDisable`/`TrapSet`: `kind` ist 0 KEY, 1 TIMER, 2 UEVENT, +3 SIGNAL; die Kennung bzw. TIMER-Dauer kommt vom Stack. `state` ist 0 ON, +1 OFF, 2 STOP. `Doevents` legt 0 ab; `Sleep(true)` nimmt Sekunden vom Stack. +`OnErrorResumeNext(local)` wählt Frame- oder Modulhandler. `RaiseError` und +`SetErr` lesen einen Fehlercode vom Stack; `LoadErr`/`LoadErl` legen ihren Wert ab. +`Run(kind)` ist 0 Neustart, 1 numerische Startzeile, 2 Dateiname; bei 1/2 +liegt das Ziel auf dem Stack. `ReadData(kind)` ist 0 STRING, 1 DOUBLE. + +`Input`/`InputFile` lesen `argc` Referenzen; bei Dateiinput liegt die +Dateinummer darunter. `line_mode` wählt LINE INPUT, `prompt=FFFF` bedeutet +keinen Prompt, `question` ergänzt das Fragezeichen. `GetPut` unterscheidet +GET/PUT und eine optionale Recordnummer auf dem Stack. `kind` ist 0 ohne +Variable, 1 INTEGER, 2 LONG, 3 SINGLE, 4 DOUBLE, 5 CURRENCY, 6 fester STRING, +7 UDT, 8 variabler STRING; `extra` enthält bei 6 die Länge, bei 7 die TYPE-ID, +sonst 0. `Field` konsumiert Dateinummer und `count` Paare (Länge, Referenz). +`LsetRset(right)` konsumiert Referenz und Wert. FOR/NEXT wird ohne Spezial-Opcodes kompiliert: Grenz-/Schrittwert einmal in versteckte Slots ausgewertet; bei konstantem STEP wählt der Codegen @@ -334,6 +590,8 @@ als Vergleichs-/Sprungkette abgesenkt. - `ON ERROR GOTO x` setzt den **modulweiten** Handler — auch aus einer Prozedur heraus. `ON LOCAL ERROR GOTO x` setzt einen **Frame-lokalen** Handler, der den modulweiten für die Dauer des Prozedurlaufs verdeckt. + Jedes Quellmodul besitzt einen eigenen Modulhandler; `GOTO 0` in einer + Bibliothek deaktiviert daher keinen Handler des aufrufenden Moduls. `… GOTO 0` deaktiviert den jeweiligen Handler. - Fehlerfall: erst die Frame-Kette von innen nach außen nach LOCAL-Handlern absuchen, sonst modulweiter Handler; Stack wird bis zum @@ -365,3 +623,8 @@ denen die Original-Hilfe schweigt, sind am 2026-09-04 als `tests/compat/konvertierung.bas` ausführbar verankert. Sollte je eine belastbare Fundstelle auftauchen, die ihnen widerspricht, gilt sie — die Entscheidungen sind Lückenfüller, keine Setzungen gegen das Vorbild. + +`CommonArr` dimensioniert einen leeren COMMON-Array-Slot einmal. Bei bereits +vorhandenem Array prüft es Elementtyp und die ausgewerteten Grenzen, ohne +Werte oder Handle zu ersetzen (Konflikt: Fehler 13). Statisch erkennbare +COMMON-Typ-/Grenzenkonflikte werden bereits beim Linken diagnostiziert. diff --git a/openspec/changes/projektmodule-und-kompilat/.openspec.yaml b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/.openspec.yaml similarity index 100% rename from openspec/changes/projektmodule-und-kompilat/.openspec.yaml rename to openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/.openspec.yaml diff --git a/openspec/changes/projektmodule-und-kompilat/design.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/design.md similarity index 100% rename from openspec/changes/projektmodule-und-kompilat/design.md rename to openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/design.md diff --git a/openspec/changes/projektmodule-und-kompilat/proposal.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md similarity index 96% rename from openspec/changes/projektmodule-und-kompilat/proposal.md rename to openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md index 4891f4a..4e7f61e 100644 --- a/openspec/changes/projektmodule-und-kompilat/proposal.md +++ b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/proposal.md @@ -1,6 +1,6 @@ ## Why -Mehrere Module/Formulare werden zu einem Quelltext und globalen Objektraum zusammengelegt. Dadurch scheitern formularlokale Namen, Fehlerorte stimmen nicht und FRM-Anfangswerte fehlen nach dem Kompilat-Rundlauf (F09–F11); Containerdokumentation und Spec sind außerdem überholt (F24). Siehe [Review vom 05.09.2026](../../../docs/reviews/2026-09-05-openspec-code-review.md). +Mehrere Module/Formulare werden zu einem Quelltext und globalen Objektraum zusammengelegt. Dadurch scheitern formularlokale Namen, Fehlerorte stimmen nicht und FRM-Anfangswerte fehlen nach dem Kompilat-Rundlauf (F09–F11); Containerdokumentation und Spec sind außerdem überholt (F24). Siehe [Review vom 05.09.2026](../../../../docs/reviews/2026-09-05-openspec-code-review.md). ## What Changes diff --git a/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review-initial.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review-initial.md new file mode 100644 index 0000000..f2096e1 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review-initial.md @@ -0,0 +1,223 @@ +# Verifikation: projektmodule-und-kompilat + +Geprüft am 05.09.2026: Arbeitsbaum auf Basis von `58b1f62`, alle sieben +Delta-Requirements mit 21 Szenarien, Tasks, D1–D4, Implementierung und +Abnahmenachweis. **Sechs offene Befunde; der Change ist nicht abnahmebereit.** +Die vorherige Aussage vollständiger Umsetzung wird durch diese Verifikation +korrigiert. Der Produktcode wurde bei diesem Review nicht verändert. + +| Dimension | Ergebnis | +| --- | --- | +| Vollständigkeit | 7/7 Tasks sind angehakt und alle sieben Requirements haben Implementierungspfade. Die Befunde widerlegen jedoch die vollständige Erfüllung insbesondere von 1.1, 1.2 und 2.2; Checkboxen allein sind kein Abnahmenachweis. | +| Korrektheit | 437 bestehende Workspace-Tests bestanden; zusätzliche unabhängige Review-Proben: 1 bestanden, 6 fehlgeschlagen. Vier Kernfunktionslücken (CRITICAL), zwei weitere Vertragsabweichungen (WARNING). | +| Kohärenz | D4 und die FRM-Grundpfade von D3 sind umgesetzt. Quellorte und Modulkontexte gehen in weiteren Absenkungs-/Importpfaden verloren; der gemeinsame Objektraum berücksichtigt `$FORM` nicht vollständig. | + +## CRITICAL — vor Archivierung beheben + +### V1 — Quellorte und Breakpoints fehlen an Zweig- und Schleifengrenzen + +**Priorität: hoch.** Delta „Zeilentabelle für Fehlerortung“ und +„Unterbrechbare step()-Ausführung“, D1, Tasks 1.1/2.2. + +Die Fehlerortung funktioniert bei einfachen Anweisungen, nicht jedoch bei +weiteren ausführbaren Teilen zusammengesetzter Anweisungen: + +| Probe | Erwartet | Beobachtet | +| --- | --- | --- | +| `IF 0 THEN` / `PRINT 1` / `ELSEIF 1/0 THEN` | Fehler 11 in Zeile 3 | Zeile 1 | +| `SELECT CASE 1` / `CASE 1/0` | Fehler 11 in Zeile 2 | Zeile 1 | +| `FOR i%=32767 TO 32767` / `PRINT 1` / `NEXT` | Überlauf beim NEXT in Zeile 3 | Zeile 2 | +| `DO` / `PRINT 1` / `LOOP UNTIL 1/0` | Fehler 11 in Zeile 3 | Zeile 2 | +| ELSEIF-Bedingung aus `cond.bi:1` | Include-Datei `cond.bi` | `main.bas:1:1` | +| Breakpoint auf ELSEIF in Zeile 3 | Halt vor Bedingung | Programm endet ohne Halt | + +**Ursache:** [ELSEIF-Absenkung](../../../../crates/tb-frontend/src/sema.rs#L1937) +und [CASE-Absenkung](../../../../crates/tb-frontend/src/sema.rs#L2914) verwenden +den Quellort des äußeren Statements. NEXT-/LOOP-Positionen werden nicht +vollständig durch AST/HIR bis zur auszuführenden Grenze getragen. Der +[FOR-Codegenerator](../../../../crates/tb-vm/src/codegen.rs#L737) dokumentiert +selbst die bisherige Verschiebung einer genauen NEXT-Zuordnung auf Phase 5; +das widerspricht der hier übernommenen vollständigen Quellortanforderung. + +**Korrektur:** Physische Positionen aller ausführbaren Zweig-/Schleifenteile +bereits beim Parsen erhalten und passende Source-/Stmt-Grenzen ausgeben. +Nicht nur Fehlermeldungen umbenennen: Breakpoints, Einzelschritt und RESUME +müssen dieselbe richtige Zuordnung verwenden. Numerische ERL-Labels separat +lassen. Probe `v1_zweige_und_schleifen_behalten_eigene_quellorte`. + +### V2 — Deklarationsimport verändert Typkontext und Abhängigkeitsreihenfolge + +**Priorität: hoch.** Projektpipeline und vollständige Prozedursignaturen, +D2, Tasks 1.2/2.1. + +Drei zulässige Mehrmodulkonstellationen werden abgewiesen: + +- MAIN ruft `Doppelt(3)` auf; LIB enthält `DEFINT A-Z` und + `FUNCTION Doppelt(x)`. Erwartete Ausgabe 6, tatsächlich + `Parameter type mismatch: MAIN!DOPPELT`. +- MAIN enthält `CONST A=3` und verwendet B; LIB enthält `CONST A=3` und + `CONST B=A+1`. Erwartet B=4, tatsächlich `LIB.bas:2:1: Invalid constant`. +- Beide Module enthalten denselben TYPE Inner. LIB definiert anschließend + TYPE Outer mit einem Feld `i AS Inner`; MAIN verwendet Outer. + Erwartet korrektes Layout und Feldausgabe 3, tatsächlich `Type not defined` + an der gültigen Outer-Definition sowie Folgefehler für Feldzugriffe. + +**Ursache:** [import_declarations](../../../../crates/tb-vm/src/project.rs#L103) +kopiert rohe Signatur-ASTs ohne den DEFtype-Kontext des definierenden Moduls. +Fremde CONST-/TYPE-Knoten werden [vor sämtliche lokalen Deklarationen +gehängt](../../../../crates/tb-vm/src/project.rs#L158). Dadurch fehlen ihnen zu +diesem Zeitpunkt lokal vorhandene Abhängigkeiten. Die Fehlerposition in LIB +ist hier irreführend: Die dortige Originalreihenfolge ist gültig. + +**Korrektur:** Exportierte Signaturen und Konstanten im Ursprungskontext +auflösen; abhängige Typdefinitionen mit erhaltenen Identitäten und gültiger +Abhängigkeitsreihenfolge verbinden. Lokale Definitionen und echte Duplikate +nicht wieder durch Text-Deduplizierung verstecken. Probe +`v2_importierte_deklarationen_behalten_kontext_und_abhaengigkeiten`. + +### V3 — `$FORM`-Module teilen versehentlich dasselbe Formularobjekt + +**Priorität: hoch.** Delta „Objektzugriffe in Grammatik und Semantik“ +einschließlich `$FORM`, getrennte Formulare, D2/D3, Task 1.2. + +A.bas und B.bas enthalten jeweils `'$FORM`. A setzt `Caption="a"`, ruft eine +SUB aus B auf, die dort `Caption="b"` setzt, und liest anschließend die +eigene Caption. **Erwartet: a. Beobachtet: b.** Das verknüpfte Objektverzeichnis +enthält nur SCREEN und A. Auch `B.Caption` aus A wird als unbekanntes Objekt +abgewiesen. Der Fehler besteht nach TBC-Rundlauf fort. + +**Ursache:** [lower_with_forms](../../../../crates/tb-frontend/src/sema.rs#L404) +erweitert pro Modul eine lokale Katalogkopie um das jeweilige `$FORM`-Objekt. +Der Linker übernimmt [nur die Objekte des ersten Moduls](../../../../crates/tb-vm/src/project.rs#L188) +und versetzt die Objektoperanden weiterer Module nicht. Deren Form-ID zeigt +anschließend auf A. + +**Korrektur:** Sämtliche FRM- und `$FORM`-Formulare vor der modulweisen +Semantik in einen gemeinsamen Katalog mit stabilen IDs aufnehmen oder die +Objekt-/Ereignisreferenzen beim Linken vollständig versetzen. Zugriffe, +Form_Load-Bindungen und gegenseitige Formreferenzen für beide Quellarten +prüfen. Probe `v3_form_metabefehle_erzeugen_getrennte_formularobjekte`. + +### V4 — COMMON verliert Variablenidentität und initialisiert Arrays mehrfach + +**Priorität: hoch.** Designrisiko SHARED/COMMON, D2, Task 1.2 und Erhalt der +bestehenden COMMON-Sprachsemantik. + +- Bereits ein Modul mit `COMMON SHARED c%, c$` wird mit + `COMMON type mismatch: C` abgewiesen. Die beiden durch Typ-Suffixe + verschiedenen Variablen dürfen nebeneinander existieren. +- MAIN und LIB deklarieren dasselbe `COMMON SHARED a%(2)`. MAIN soll + `a%(1)=7` setzen und LIB den gemeinsamen Wert 7 ausgeben. Stattdessen + entsteht vor dem Nutzcode `LIB.bas:1:1: Runtime error 10: Duplicate definition`. + +**Ursache:** Die [COMMON-Zuordnung](../../../../crates/tb-vm/src/project.rs#L270) +verwendet nur Block und untypisierten Basisnamen. Arrays haben zusätzlich +nur `TypeInit::Empty` als Slotvorbelegung; Elementtyp und Form gehen in der +Vergleichsprüfung verloren. Der Slot wird zwar zusammengeführt, aber +[beide DIM-Initialisierungen](../../../../crates/tb-vm/src/project.rs#L381) +werden ausgeführt. + +**Korrektur:** Vollständige Variablenidentität und Arrayvertrag beim Binden +berücksichtigen. Kompatible gemeinsame Arrays einmal initialisieren; +inkompatible Deklarationen vor der Ausführung am ursprünglichen Quellort +ablehnen. Probe `v4_common_behaelt_typidentitaet_und_initialisiert_arrays_einmal`. + +## WARNING — weitere Vertragsabweichungen + +### V5 — LOAD/UNLOAD umgehen die explizite Containerauflösung + +**Priorität: mittel.** Formularnamensraum und expliziter Containerzugriff, +D2, Task 1.2. + +Bei zwei Formularen mit jeweils Text1 als Control-Array werden +`LOAD Form2!Text1(3)` und `UNLOAD Form2!Text1(3)` mit +`Unknown object 'FORM2!TEXT1'` abgewiesen. Property-Zugriffe und die Übergabe +desselben qualifizierten Controls an einen CONTROL-Parameter funktionieren. + +**Ursache:** Der [LOAD-/UNLOAD-Pfad](../../../../crates/tb-frontend/src/sema.rs#L3370) +übergibt den gesamten Bang-Pfad an `find_object`; die bereits vorhandene +containerbezogene Auflösung wird hier nicht verwendet. + +**Korrektur:** LOAD/UNLOAD über dieselbe Objekt-/Containerauflösung wie +Property- und Referenzzugriffe führen; gleichnamige Arrays beider Formulare +und dynamische Instanzen gemeinsam prüfen. Probe +`v5_load_und_unload_loesen_den_expliziten_container_auf`. + +### V6 — Linkerdiagnosen verlieren Datei, Zeile und Spalte + +**Priorität: mittel.** Delta „Diagnostik mit Vorbild-Meldungen“, D1, Task 1.1. + +MAIN deklariert `SUB F(x%)`, LIB implementiert `SUB F(x$)`. Die Ablehnung ist +richtig, die Meldung jedoch `0:0: Parameter type mismatch: MAIN!F` ohne Datei. +Das ist eine quellbezogene Diagnose, kein Fehler ohne zuordenbare Quelle. + +**Ursache:** [diagnostic](../../../../crates/tb-vm/src/project.rs#L17) erzeugt +`file=None` und `SourcePos::default()`. [Linkfehler](../../../../crates/tb-vm/src/project.rs#L66) +werden erst nach `locate_diagnostics` in dieses Format umgewandelt. Dasselbe +Problem betrifft COMMON-Konflikte und weitere symbolbezogene Linkfehler. + +**Korrektur:** Ursprung der betroffenen Deklaration bzw. Aufrufstelle mit +in die Linker-Symbolinformationen übernehmen und strukturierte Diagnosen +mit tatsächlicher SourcePos zurückgeben. Keine pauschale Ersatzposition +1:1 eintragen. Probe `v6_linkerdiagnose_nennt_den_urspruenglichen_dateiort`. + +## Szenarioabdeckung und positive Nachweise + +Alle 21 vorhandenen Delta-Szenarien wurden abgeglichen. Die einfachen +Originalbeispiele besitzen Implementierungs- und Testnachweise; die oben +reproduzierten Randpfade widersprechen den umfassenderen Requirements. + +| Requirement / Szenarien | Ergebnis | +| --- | --- | +| Monomorpher Opcode-Satz: gemischter Ausdruck, aufgelöste Namen | Vorhandene Codegen-/HIR-Tests bestanden. Alle 152 Opcodes samt Operandentypen und Encodingbreiten stimmen mit der Dokumentation überein. | +| TBC: Roundtrip, unbekannte Version, Formularwerte | CLI-/VM-Projekttests bestanden: bytegleicher Rundlauf, hello/world aus Designarrays 0/2 nach Entfernen sämtlicher BAS-/FRM-/MAK-/BI-Quellen, Ablehnung alter/höherer Versionen. V2/V3 begrenzen dennoch das vollständige Projektmodell. | +| Quellorte: Fehlerzeile, Bibliotheksfehler | `lib.bas:2` und verschachtelte Include-Fehler bestehen. Unabhängig zusätzlich Fehler in physischer Zeile 42 geprüft. V1 zeigt fehlende Zweig-/Schleifenorte. | +| Benannte Objekte: Lesen/Schreiben, keine Standardeigenschaft, Wertebereich, getrennte Controls | Bestehende Semantik-/VM-/Modelltests und neuer Zwei-FRM-Test bestanden; Check1.Value=3 bleibt Fehler 5. V3 betrifft weitere Formularquellen, V5 die weiteren qualifizierten Zugriffspfade. | +| Diagnosen: Position, Include-Diagnose | Lexer-/Parser-/Semantikpfad trägt Originalpositionen. V6 betrifft die anschließende Linkerphase. | +| Objektgrammatik/-semantik: unbekanntes Objekt/Eigenschaft, Typkonflikt, TYPEOF, gleiche Controls | Bestehende Tests bestanden. V3/V5 verletzen den allgemeinen Modul-/Containervertrag trotz grüner Property-Beispiele. | +| Unterbrechung: Breakpoint, Inspektion, gleiche Zeile in zwei Modulen | Unabhängige Probe: MAIN passiert Zeile 10, LIB hält in Zeile 10; qualifizierte Array-/UDT-Inspektion liefert 7/8; Einzelschritt meldet Spalte 5; ERR/ERL bleiben 6/200. V1 lässt Breakpoints an ELSEIF aus. | + +Erneut ausgeführt: + +- `cargo test --workspace`: **437 bestanden, 0 fehlgeschlagen, 1 ignoriert**. + Golden-Generator nicht ausgeführt, keine Sollausgaben geändert. +- [Review-Proben](review-probes.rs): **1 bestanden, 6 fehlgeschlagen**. + Jede fehlgeschlagene Probe entspricht einem der sechs Befunde; mehrere + verwandte Reproduktionen werden innerhalb einer Probe gesammelt. +- Zusätzlich zeitlich begrenzte CLI-Proben für die Befunde; temporäre + Quellverzeichnisse anschließend entfernt. +- `cargo bench -p tb-vm --bench compile`: 508 Zeilen **0,54 ms**, 49.760 Zeilen + in 20 verbundenen Modulen **67 ms**; beide Budgets erfüllt. +- Dokumentation: **152/152 Opcodes**, Operanden-/Gesamtbreiten und **9/9 + Containerabschnitte** gegen den aktuellen Code abgeglichen. +- `cargo fmt --all -- --check`, `openspec validate projektmodule-und-kompilat --strict` + und `git diff --check`: bestanden. +- Clippy nicht nochmals ausgeführt: keine Produktcodeänderung seit dem + erfolgreichen Clippy-Lauf der Implementierungsabnahme. Kein neuer + interaktiver Originalsystemvergleich und keine Windows-/Linux-Testmatrix. + +## Review-Proben reproduzieren + +Im Repository-Wurzelverzeichnis ausführen. Der temporäre Treiber hält die +noch roten Vertragsproben außerhalb der normalen Workspace-Suite; ihre +Erwartungen beschreiben das Sollverhalten. Exit-Code ist bis zur Behebung 101. + +```sh +python3 - <<'PY' +from pathlib import Path +import json, subprocess, tempfile, os +root = Path.cwd() +with tempfile.TemporaryDirectory(prefix='tb-project-review-') as folder: + p = Path(folder) + manifest = '[package]\nname="tb-project-review"\nversion="0.0.0"\nedition="2021"\n[workspace]\n' + manifest += '[[test]]\nname="probes"\npath=' + json.dumps(str(root / 'openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review-probes.rs')) + '\n[dependencies]\n' + for name in ['tb-ui', 'tb-vm', 'tb-runtime', 'tb-frontend']: + manifest += name + ' = {path=' + json.dumps(str(root / 'crates' / name)) + '}\n' + (p / 'Cargo.toml').write_text(manifest) + result = subprocess.run(['cargo', 'test', '--manifest-path', str(p / 'Cargo.toml'), '--offline', '--test', 'probes', '--', '--nocapture'], env={**os.environ, 'CARGO_TARGET_DIR': str(root / 'target/review-projektmodule')}, timeout=120) + raise SystemExit(result.returncode) +PY +``` + +**Abnahmeentscheidung:** V1–V4 vor Archivierung beheben; V5/V6 ebenfalls in +die vollständige Umsetzung aufnehmen. Keine zusätzlichen Stilvorschläge. diff --git a/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review-probes.rs b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review-probes.rs new file mode 100644 index 0000000..8b851b2 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review-probes.rs @@ -0,0 +1,266 @@ +//! Unabhängige Vertragsproben; Ausführung mit dem temporären Treiber aus review.md. +use tb_frontend::{ + forms::FormCatalog, + source::{SourceSegment, SourceUnit}, +}; +use tb_runtime::{host::CaptureHost, value::Value}; +use tb_vm::{ + bytecode::CompiledModule, + compile_project, + interp::{RunEvent, Vm}, +}; + +fn units(files: &[(&str, &str)]) -> Vec { + files + .iter() + .map(|(name, code)| SourceUnit::new(name, &format!("{name}.bas"), code)) + .collect() +} +fn compile(files: &[(&str, &str)]) -> Result> { + compile_project("APP", &units(files), &FormCatalog::default(), &[]) +} +fn run(module: CompiledModule) -> (RunEvent, String) { + let bytes = module.to_tbc(); + let loaded = CompiledModule::from_tbc(&bytes).unwrap(); + assert_eq!(bytes, loaded.to_tbc()); + let mut vm = Vm::new(loaded); + let event = vm.run(&mut CaptureHost::default()); + (event, tb_runtime::snapshot::text(&vm.rt.screen)) +} + +#[test] +fn v1_zweige_und_schleifen_behalten_eigene_quellorte() { + let mut failures = vec![]; + for (name, code, line) in [ + ( + "ELSEIF", + "IF 0 THEN\nPRINT 1\nELSEIF 1/0 THEN\nPRINT 2\nEND IF", + 3, + ), + ("CASE", "SELECT CASE 1\nCASE 1/0\nPRINT 2\nEND SELECT", 2), + ("NEXT", "FOR i%=32767 TO 32767\nPRINT 1\nNEXT", 3), + ("LOOP", "DO\nPRINT 1\nLOOP UNTIL 1/0", 3), + ] { + let (event, _) = run(compile(&[("MAIN", code)]).unwrap()); + if !matches!(event, RunEvent::Error { line: found, .. } if found == line) { + failures.push(format!("{name}: erwartet Zeile {line}, erhalten {event:?}")); + } + } + let source = SourceUnit { + name: "MAIN".into(), + segments: vec![ + SourceSegment { + file: "main.bas".into(), + first_line: 1, + text: "IF 0 THEN\nPRINT 1\n".into(), + }, + SourceSegment { + file: "cond.bi".into(), + first_line: 1, + text: "ELSEIF 1/0 THEN\nPRINT 2\n".into(), + }, + SourceSegment { + file: "main.bas".into(), + first_line: 4, + text: "END IF\n".into(), + }, + ], + }; + let mut vm = Vm::new(compile_project("APP", &[source], &FormCatalog::default(), &[]).unwrap()); + let event = vm.run(&mut CaptureHost::default()); + if vm.current_file() != "cond.bi" { + failures.push(format!( + "Include: {event:?} aus {} statt cond.bi", + vm.current_file() + )); + } + // Das gleiche Problem darf nicht nur durch Umbenennen der Fehlermeldung behoben werden. + let mut vm = Vm::new( + compile(&[("MAIN", "IF 0 THEN\nPRINT 1\nELSEIF 1 THEN\nPRINT 2\nEND IF")]).unwrap(), + ); + vm.add_module_breakpoint(0, 3); + let event = vm.run(&mut CaptureHost::default()); + if event != (RunEvent::Breakpoint { line: 3 }) { + failures.push(format!("Breakpoint ELSEIF: {event:?}")); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn v2_importierte_deklarationen_behalten_kontext_und_abhaengigkeiten() { + let cases = [ + ( + "DEFINT", + "PRINT Doppelt(3)\nEND", + "DEFINT A-Z\nFUNCTION Doppelt(x)\nDoppelt=x*2\nEND FUNCTION", + " 6 \n", + ), + ( + "CONST", + "CONST A=3\nPRINT B\nEND", + "CONST A=3\nCONST B=A+1", + " 4 \n", + ), + ( + "TYPE", + "TYPE Inner\nx AS INTEGER\nEND TYPE\nDIM a AS Outer\na.i.x=3\nPRINT a.i.x\nEND", + "TYPE Inner\nx AS INTEGER\nEND TYPE\nTYPE Outer\ni AS Inner\nEND TYPE", + " 3 \n", + ), + ]; + let mut failures = vec![]; + for (name, main, lib, output) in cases { + match compile(&[("MAIN", main), ("LIB", lib)]) { + Err(e) => failures.push(format!("{name}: {e:?}")), + Ok(module) => { + let actual = run(module); + if actual != (RunEvent::Ended, output.into()) { + failures.push(format!("{name}: {actual:?}")); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn v3_form_metabefehle_erzeugen_getrennte_formularobjekte() { + let module = compile(&[ + ( + "A", + "'$FORM\nCaption=\"a\"\nCALL SetupB\nPRINT Caption\nEND", + ), + ("B", "'$FORM\nSUB SetupB\nCaption=\"b\"\nEND SUB"), + ]) + .unwrap(); + let names: Vec<_> = module.objects.iter().map(|o| o.name.clone()).collect(); + let actual = run(module); + assert_eq!( + actual, + (RunEvent::Ended, "a\n".into()), + "Objekte: {names:?}" + ); +} + +#[test] +fn v4_common_behaelt_typidentitaet_und_initialisiert_arrays_einmal() { + let mut failures = vec![]; + for (name, files, want) in [ + ( + "Suffixe", + vec![( + "MAIN", + "COMMON SHARED c%, c$\nc%=7\nc$=\"ok\"\nPRINT c%;c$\nEND", + )], + " 7 ok\n", + ), + ( + "Array", + vec![ + ("MAIN", "COMMON SHARED a%(2)\na%(1)=7\nCALL F\nEND"), + ("LIB", "COMMON SHARED a%(2)\nSUB F\nPRINT a%(1)\nEND SUB"), + ], + " 7 \n", + ), + ] { + match compile(&files) { + Err(e) => failures.push(format!("{name}: {e:?}")), + Ok(module) => { + let actual = run(module); + if actual != (RunEvent::Ended, want.into()) { + failures.push(format!("{name}: {actual:?}")); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn v5_load_und_unload_loesen_den_expliziten_container_auf() { + use tb_frontend::forms::ObjectClass; + let mut catalog = FormCatalog::default(); + for name in ["Form1", "Form2"] { + catalog.add(name, ObjectClass::Form, None, false); + catalog.add("Text1", ObjectClass::TextBox, Some(name), true); + } + let source = units(&[("MAIN", "LOAD Form2!Text1(3)\nForm2!Text1(3).Text=\"b\"\nPRINT Form2!Text1(3).Text\nUNLOAD Form2!Text1(3)\nEND")]); + let module = compile_project("APP", &source, &catalog, &[]).unwrap(); + assert_eq!(run(module), (RunEvent::Ended, "b\n".into())); +} + +#[test] +fn v6_linkerdiagnose_nennt_den_urspruenglichen_dateiort() { + let errors = compile(&[ + ("MAIN", "DECLARE SUB F(x%)\nCALL F(1)\nEND"), + ("LIB", "SUB F(x$)\nEND SUB"), + ]) + .unwrap_err(); + assert!( + errors + .iter() + .any(|d| d.message.contains("Parameter type mismatch")), + "{errors:?}" + ); + assert!( + errors + .iter() + .all(|d| d.file.is_some() && d.pos.line > 0 && d.pos.column > 0), + "{errors:?}" + ); +} + +#[test] +fn modulbreakpoint_rundlauf_inspektion_und_numerische_erl_bleiben_getrennt() { + let main = format!( + "DIM a%(2)\nTYPE T\nx AS INTEGER\nEND TYPE\nDIM r AS T\na%(1)=7\nr.x=8\n{}CALL F\nEND", + "\n\nn%=1\n" + ); + let lib = format!("SUB F\n{}200 ERROR 6\nEND SUB", "\n".repeat(8)); + let module = compile(&[("MAIN", &main), ("LIB", &lib)]).unwrap(); + let bytes = module.to_tbc(); + let loaded = CompiledModule::from_tbc(&bytes).unwrap(); + assert_eq!(bytes, loaded.to_tbc()); + let mut vm = Vm::new(loaded); + vm.add_module_breakpoint(1, 10); + let mut host = CaptureHost::default(); + assert_eq!(vm.run(&mut host), RunEvent::Breakpoint { line: 10 }); + assert_eq!(vm.current_module(), 1); + assert_eq!(vm.current_file(), "LIB.bas"); + assert!(matches!( + vm.inspect_element("MAIN!a%", &[1]), + Some(Value::Int(7)) + )); + assert!(matches!( + vm.inspect_field("MAIN!r", &[0]), + Some(Value::Int(8)) + )); + vm.remove_module_breakpoint(1, 10); + vm.set_step(true); + assert_eq!(vm.run(&mut host), RunEvent::Stepped { line: 10 }); + assert_eq!(vm.current_source_pos().column, 5); + vm.set_step(false); + assert!(matches!( + vm.run(&mut host), + RunEvent::Error { + code: 6, + line: 10, + .. + } + )); + let source = format!("{}ERROR 6", "\n".repeat(41)); + assert!(matches!( + run(compile(&[("MAIN", &source)]).unwrap()).0, + RunEvent::Error { + code: 6, + line: 42, + .. + } + )); + let module = compile(&[( + "MAIN", + "ON ERROR RESUME NEXT\n200 ERROR 6\nPRINT ERR;ERL\nEND", + )]) + .unwrap(); + assert_eq!(run(module), (RunEvent::Ended, " 6 200 \n".into())); +} diff --git a/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review.md new file mode 100644 index 0000000..8276308 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/review.md @@ -0,0 +1,79 @@ +# Verifikation: projektmodule-und-kompilat + +Stand: 05.09.2026, Arbeitsbaum auf Basis von `58b1f62`. +**Keine offenen Befunde: 0 CRITICAL, 0 WARNING, 0 SUGGESTION.** + +Die sechs Befunde des [ersten Reviews](review-initial.md) sind behoben. +Weitere bei der Nachprüfung reproduzierte Abweichungen wurden ebenfalls +korrigiert und mit Regressionen abgesichert. Der Change ist abnahmebereit. + +| Dimension | Ergebnis | +| --- | --- | +| Vollständigkeit | 12/12 Tasks umgesetzt; alle 7 Requirements und 21 Delta-Szenarien abgeglichen. | +| Korrektheit | 453 Workspace-Tests bestanden, 0 fehlgeschlagen, 1 vorhandener Golden-Generator ignoriert. Die 7 unveränderten ursprünglichen Review-Proben bestehen auch im unabhängigen temporären Treiber. | +| Kohärenz | D1–D4 erfüllt: physische Quellorte, getrennte Scopes, selbstständiges TBC 4 und dokumentierte Inline-Instruktionen. | + +## Geschlossene Befunde + +| Befund | Korrektur und Nachweis | +| --- | --- | +| V1: Zweig-/Schleifenorte | Parser und HIR erhalten ELSEIF-, CASE-, NEXT-, LOOP- und WEND-Positionen. Codegen emittiert die tatsächlichen Anweisungsgrenzen. Fehler, Breakpoints, WHILE-Rücksprung, LOOP-RESUME, NEXT-RESUME NEXT und Include-Enden in Zeile 42/Spalte 3 sind geprüft. Numerische ERL-Labels bleiben getrennt. | +| V2: Importkontext | Exportierte Signaturen erhalten die DEFtype-Typen ihres Ursprungs. Konstanten werden dort gefaltet, einschließlich transitiver Abhängigkeiten über drei Module. TYPE-Abhängigkeiten werden geordnet und bei abweichenden lokalen Layouts intern qualifiziert. Echte lokale Duplikate bleiben Fehler. | +| V3: `$FORM`-Identitäten | FRM- und `$FORM`-Objekte stehen vor jeder Modulübersetzung in einem gemeinsamen Katalog. Getrennte Captions, gegenseitige Referenzen und unterschiedliche Form_Load-Bindungen sind auch für gemischte FRM/BAS-Projekte nach TBC-Rundlauf geprüft. | +| V4: COMMON | HIR übergibt Block, vollständigen Variablenschlüssel, Elementtyp und Arraygrenzen an den Linker. Typen, Ränge und bekannte Grenzen werden geprüft. `CommonArr` initialisiert einmal und prüft spätere dynamische Grenzen ohne Zurücksetzen des Handles oder der Werte. Offene Arraydeklarationen und gerundete Grenzen bleiben unterstützt. | +| V5: LOAD/UNLOAD | Beide Anweisungen verwenden dieselbe explizite Containerauflösung und Indexprüfung wie andere Objektzugriffe. `Form2!Text1(3)` wird geladen, beschrieben, gelesen und entladen, auch wenn Form1 ebenfalls Text1 besitzt. | +| V6: Diagnosen | Der Linker gibt strukturierte Diagnosen an den ursprünglichen Deklarationen zurück; Source-ID, Datei, Zeile und Spalte bleiben erhalten. Signaturkonflikte, COMMON-Konflikte im Include sowie doppelte Module sind geprüft. | + +Die erweiterten Proben fanden zusätzlich folgende inzwischen behobene +Abweichungen: transitive Konstantenimports, fremde TYPE-Layouts bei lokal +abweichenden Definitionen, prozedurübergreifendes Durchsickern von DEFtype, +Verwechslung typisierter Debuggernamen, Verwechslung von Formnamen mit +suffixdeklarierten Variablen und fehlende Literalpositionen in Diagnosen. +Letzteres ist durch `CALL F("wrong")` in `args.bi:42:10` abgesichert. +Auch Konflikte erst in der dritten COMMON-Deklaration werden erkannt. + +Die [16 dauerhaften Regressionstests](../../../../crates/tb-vm/tests/project_regressions.rs) +enthalten die sieben ursprünglichen Proben mit unveränderten Erwartungen +sowie neun ergänzende Tests mit mehreren verwandten Fällen. Die ursprüngliche +[Probe-Datei](review-probes.rs) wurde nicht geändert. Es wurden keine Goldens +neu erzeugt und keine Fehlererwartungen abgeschwächt. + +## Abgleich aller Requirements und Szenarien + +| Requirement | Szenarien / Nachweise | +| --- | --- | +| Monomorpher Opcode-Satz | Gemischter Ausdruck und aufgelöste Namen: vorhandene HIR-/Codegentests; numerische Projektverknüpfung. Alle 153 Opcode-Kennungen, Operandenfolgen und Encodingbreiten gegen die Dokumentation geprüft. | +| TBC-Container | Bytegleicher Roundtrip; klare Ablehnung alter/unbekannter Versionen; vollständige Formularwerte und Designarrays. CLI-Projekttests entfernen sämtliche BAS-/BI-/FRM-/MAK-Quellen und führen das TBC selbstständig aus. | +| Quellorte | Fehler in physischer Zeile 42; Fehler in lib.bas:2; verschachtelte Includes; zusätzliche ausführbare Zweig-/Schleifengrenzen und Initialisierungen. | +| Benannte Objekte | Eigenschaften lesen/schreiben; keine Standardeigenschaft; Check1.Value=3 erzeugt Fehler 5; voneinander unabhängige Werte gleichnamiger Controls. Zwei FRM- und gemischte FRM/`$FORM`-Projekte geprüft. | +| Diagnostik | 1-basierte Datei-/Zeilen-/Spaltenangaben in Lexer, Parser, Semantik und Linker; Include-Fehler einschließlich Literalargumenten. | +| Objektgrammatik/-semantik | Unbekanntes Objekt und unbekannte Eigenschaft benennen; Eigenschaftstyp prüfen; TYPEOF; expliziter Form1/Form2-Container bei gleichnamigen Controls. Qualifiziertes LOAD/UNLOAD nutzt dieselbe Auflösung. | +| Unterbrechung/Inspektion | Modulbezogener Breakpoint; scalar-, array- und UDT-Inspektion; Fortsetzen und Einzelschritt. Gleiche physische Zeile in MAIN und LIB bleibt getrennt, explizite Typ-Suffixe unterscheiden Variablen. ERR/ERL bleiben 6/200. | + +Die konkreten ursprünglichen Szenario-/Testnamen und weitere Containerproben +stehen im [Implementierungsnachweis](verification.md), den +[VM-Projekttests](../../../../crates/tb-vm/tests/project.rs) und den +[CLI-Projekttests](../../../../crates/tb-cli/tests/project.rs). + +## Abschließende Prüfläufe + +- `cargo test --workspace`: **453 bestanden, 0 fehlgeschlagen, 1 ignoriert**. +- Unabhängiger Treiber aus [review-initial.md](review-initial.md): **7 bestanden**. +- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: bestanden. +- `cargo fmt --all -- --check`: bestanden. +- `cargo bench -p tb-vm --bench compile`: 508 Zeilen **0,56 ms** (Budget 50 ms); + 49.760 Zeilen in 20 verbundenen Modulen **77 ms** (Budget 1000 ms). +- Formatabgleich: **153/153 Opcodes**, Operandentypen und Gesamtbreiten; + **9/9 Abschnittskennungen** stimmen zwischen Writer, Reader und Dokumentation überein. +- `openspec validate projektmodule-und-kompilat --strict`: bestanden. +- `git diff --check`: bestanden. + +Die Verifikation prüft den aktuellen Code und die gesamten Change-Artefakte +lokal und headless auf macOS. Ein neuer interaktiver Originalsystemvergleich +oder eine Windows-/Linux-Testmatrix war nicht Teil dieses Prüflaufs. +Die vier betroffenen Hauptspecs wurden am 05.09.2026 synchronisiert und +mit `openspec validate --specs --strict` validiert (15/15 bestanden). +Alle sieben Delta-Requirements stimmen vollständig mit den Hauptspecs +überein; alle zuvor vorhandenen Szenarien und unberührten Requirements +bleiben erhalten. Der Change ist unter +`openspec/changes/archive/2026-09-05-projektmodule-und-kompilat` archiviert. diff --git a/openspec/changes/projektmodule-und-kompilat/specs/bytecode-kompilat/spec.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/bytecode-kompilat/spec.md similarity index 100% rename from openspec/changes/projektmodule-und-kompilat/specs/bytecode-kompilat/spec.md rename to openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/bytecode-kompilat/spec.md diff --git a/openspec/changes/projektmodule-und-kompilat/specs/forms-objektmodell/spec.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/forms-objektmodell/spec.md similarity index 100% rename from openspec/changes/projektmodule-und-kompilat/specs/forms-objektmodell/spec.md rename to openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/forms-objektmodell/spec.md diff --git a/openspec/changes/projektmodule-und-kompilat/specs/sprach-frontend/spec.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/sprach-frontend/spec.md similarity index 100% rename from openspec/changes/projektmodule-und-kompilat/specs/sprach-frontend/spec.md rename to openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/sprach-frontend/spec.md diff --git a/openspec/changes/projektmodule-und-kompilat/specs/vm-ausfuehrung/spec.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/vm-ausfuehrung/spec.md similarity index 100% rename from openspec/changes/projektmodule-und-kompilat/specs/vm-ausfuehrung/spec.md rename to openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/specs/vm-ausfuehrung/spec.md diff --git a/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/tasks.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/tasks.md new file mode 100644 index 0000000..bc69a3e --- /dev/null +++ b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/tasks.md @@ -0,0 +1,23 @@ +## 1. Quellen und Scopes + +- [x] 1.1 Quell-/Modulidentitäten durch Include-Expansion und Frontend erhalten; verifizieren mit Compile- und Runtimefehlern aus lib.bas:2 und verschachtelten Includes. +- [x] 1.2 Form-/Prozedurscopes korrekt auflösen; verifizieren mit zwei Form_Load-Prozeduren, zwei Text1-Controls und unterschiedlichen Form1!Text1/Form2!Text1-Werten sowie echten Duplikaten im selben Scope. + +## 2. Kompilat und Debugger + +- [x] 2.1 Vollständige Forms-Anfangsdaten, Quelltabelle und Prozedursignaturen serialisieren; verifizieren durch bytegleichen Kompilat-Rundlauf und gleiche Ausführung inklusive Designzeit-Control-Arrays. +- [x] 2.2 Modulbezogene Breakpoints/Quellortmeldungen ergänzen; verifizieren mit identischen physischen Zeilennummern in zwei Modulen, Include-Fehlern, Einzelschritt und unverändertem ERR/ERL. +- [x] 2.3 CLI-run für TBC anschließen und Versionsgrenze anheben; verifizieren mit build, Entfernen aller Quellen, run des Kompilats und klarer Ablehnung inkompatibler Versionen. + +## 3. Abnahme + +- [x] 3.1 Container und vollständige Opcode-/Operandentabelle dokumentieren; verifizieren mit Format-/Opcode-Abgleich, cargo test --workspace und dem Compile-Budget. +- [x] 3.2 Delta-Specs vollständig gegen die erhaltenen Szenarien prüfen; verifizieren mit openspec validate projektmodule-und-kompilat --strict und git diff --check. + +## 4. Reviewbefunde vollständig beheben + +- [x] 4.1 V1: Physische Quellpositionen aller Zweige und Schleifenenden erhalten; Fehler, Breakpoints und RESUME prüfen. +- [x] 4.2 V2/V6: Importierte Deklarationen im Ursprungskontext auflösen und Linkdiagnosen an Originalstellen melden. +- [x] 4.3 V3/V5: Gemeinsame Formidentitäten und qualifiziertes LOAD/UNLOAD korrigieren. +- [x] 4.4 V4: Vollständige COMMON-Identitäten und kompatible einmalige Arrayinitialisierung sicherstellen. +- [x] 4.5 Unveränderte Reviewproben, ergänzende Nachbarfälle und sämtliche Change-Szenarien erneut prüfen; alle weiteren Befunde beheben. diff --git a/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/verification.md b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/verification.md new file mode 100644 index 0000000..6a58268 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-projektmodule-und-kompilat/verification.md @@ -0,0 +1,106 @@ +# Implementierungsnachweis — 05.09.2026 + +Change: `projektmodule-und-kompilat`, Ausgangscommit `58b1f62`. + +**Abschließende Verifikation:** [review.md](review.md) bestätigt die Behebung +aller sechs ursprünglichen und aller zusätzlich reproduzierten Befunde. +Der [erste Review](review-initial.md) bleibt als historischer Nachweis erhalten. + +Alle zwölf Tasks (sieben Implementierungs- und fünf Review-Aufgaben) sind +umgesetzt. F09, F10, F11 und F24 sind im Arbeitsbaum bearbeitet; die +Hauptspec-Synchronisierung und Archivierung sind am 05.09.2026 abgeschlossen. Die Abnahme +umfasst alle sieben Delta-Requirements mit ihren 21 Szenarien. + +## Umsetzung + +| Task / Befund | Umsetzung und Nachweis | +| --- | --- | +| 1.1 / F10 | `SourceUnit`/`SourceSegment` erhalten Modul, Datei und physische Zeile vor dem Parsen. Lexer- und Parserpositionen werden vor der Semantik den Originalquellen zugeordnet. CLI-Tests prüfen Compile-/Runtimefehler aus `lib.bas:2`, verschachtelten Includes und dem Codeabschnitt eines entfernten FRM. Auch leere Quellen funktionieren. | +| 1.2 / F09 | Module werden getrennt aufgelöst und danach numerisch verbunden. Lokale Prozeduren und Controls haben Vorrang, `Form!Control` sucht direkt im angegebenen Formular. Eltern-IDs ersetzen Namensvergleiche auch bei Frames, Menüs, Optionsgruppen und Geometrie. Zwei Form_Load-Prozeduren initialisieren ihre jeweiligen Text1-Controls unabhängig; echte SUB-/CONST-/TYPE-Duplikate im selben Modul bleiben Fehler. | +| 2.1 / F11 | TBC 4 enthält Module/Quellen, vollständige Parameter- und Rückgabetypen, Locals, Forms-Instanzen mit Eigenschaften, Designindizes und Eltern-IDs sowie das Startformular. `Vm::new` stellt diesen Anfangszustand her. Bytegleicher Rundlauf und Ausführung mit zwei Formularen und Designarrays 0/2 sind geprüft. | +| 2.2 / F10 | `Source` plus `Stmt` liefern Datei/Spalte/physische Zeile. `InitStmt` erhält dieselben Informationen für globale Initialisierungen, ohne vorzeitig Ereignisse zuzustellen. Breakpoints verwenden Modul-ID und Zeile. Einzelschritt, Variableninspektion und Fortsetzen bleiben möglich. Numerische Labels führen separat `SetErl`; DEF FN erhält den tatsächlichen Quellort. | +| 2.3 / F11 | `tbc run` lädt TBC direkt. Der CLI-Test baut ein Projekt, entfernt alle BAS-/BI-/FRM-/MAK-Dateien und vergleicht die Ausgabe des Kompilats mit der Quellausführung (`hello`, `world`, `7`). Ein zweiter Fall erhält nach Entfernen des FRM den Fehlerort `error.frm:6:1`. Versionen 1, 2, 3, 5 und 32767 werden ausdrücklich benannt und abgewiesen. | +| 3.1 / F24 | [TBVM-Format](../../../../docs/tbvm-design.md) dokumentiert alle neun Abschnitte und alle 153 Opcodes einzeln mit Operanden, Encodingbreiten und Kennungen. Der Compile-Benchmark übersetzt und verbindet nun tatsächlich ein Projekt mit 20 Modulen. | +| 3.2 | Alle Delta-Requirement-Blöcke behalten ihre bisherigen Szenarien. Neue Szenarien und die Implementierungsentscheidungen D1–D4 wurden gegen die unten genannten Tests und Codepfade abgeglichen. OpenSpec- und Diff-Prüfung bestanden. | + +## Projektgrenzen und ergänzende Regressionen + +Der Projektlink versetzt Prozedur-, Slot-, TYPE-, String-, DATA- und +Sprungtabellenreferenzen. Hauptprogramminitialisierungen aller Mitglieder +laufen vor der ersten Ereigniszustellung; die übrigen Modulrümpfe folgen der +Projektreihenfolge. Eindeutige externe Prozeduren werden als Deklarationen +importiert, lokale Namen bleiben erhalten. Eindeutige fremde Konstanten und +TYPEs bleiben als Kompatibilitätsregel sichtbar. Es wird kein Quelltext zur +Unterdrückung von Duplikaten gelöscht. + +Geprüft sind wiederholt eingebundene Konstanten, gleichnamige lokale SUBs, +modullokales SHARED, gemeinsame COMMON-Skalare und unterschiedliches OPTION +BASE. Modulhandler werden getrennt gespeichert: ON ERROR GOTO 0 in der +Bibliothek deaktiviert den Handler des Aufrufers nicht. Bestehende ERR-/ERL- +und RESUME-Korpustests bestehen unverändert. + +Der Signaturtest übergibt ein INTEGER-Array, einen UDT und einen Skalar an +eine Funktion in einem anderen Modul. Er prüft Rückgabetyp, Array-/BYREF- +Merkmale, Ausgabe und Rückwirkung einer UDT-Feldänderung auf den Aufrufer. +Die dabei gefundene Inkonsistenz zwischen UDT-Argument und Parameter wurde +im gemeinsamen Semantikpfad behoben. Bekannte externe Dialogdeklarationen +funktionieren auch mit qualifizierten Prozedurnamen. ISAM-OPEN verwendet mit +`PushUdtId` eine explizit versetzbare TYPE-Referenz; der Test vergleicht sie +nach dem Rundlauf mit dem tatsächlichen UDT-Index. + +Der Loader weist abgeschnittene Dateien an jedem Byteoffset, ungültige +Flags/Abschnittsgrößen, doppelte Abschnitte sowie ungültige Quell-, Modul-, +Prozedur- und Elternreferenzen ab. Inkonsistente Signaturen, doppelte +Anfangseigenschaften, falsche Property-Typen und abweichende Designindizes +werden vor dem VM-Start geprüft. Das ersetzt keinen vollständigen +Stackverifizierer für beliebigen fremden Bytecode. + +## Requirement- und Szenarioabgleich + +Die neuen ausführbaren Nachweise liegen in +[VM-Projekttests](../../../../crates/tb-vm/tests/project.rs) und +[CLI-Projekttests](../../../../crates/tb-cli/tests/project.rs). +Die folgende Tabelle führt auch die erhaltenen Szenarien auf; bestehende +Tests werden durch neue Mehrmodul- und Include-Proben ergänzt. + +| Requirement | Szenarien und Belege | +| --- | --- | +| Monomorpher Opcode-Satz | Gemischter Ausdruck: `codegen::tests::ausdruck_monomorph_mit_conv`; aufgelöste Namen: `sema::tests::hir_slots_aufgeloest` und die numerisch verbundenen Projektaufrufe. Vollständigkeit der Dokumentation zusätzlich gegen das `instrs!`-Makro geprüft. | +| .tbc-Containerformat | Roundtrip: mehrere VM-Projekttests vergleichen die gesamten Bytes; unbekannte Version: CLI-Versionstests; Formularwerte: CLI-Ausgabe `hello` und `world` nach Entfernen sämtlicher Quellen. | +| Zeilentabelle für Fehlerortung | Physische Fehlerzeile: Stmt-/Source-Zuordnung und CLI-Fehlerproben; Projektbibliothek: `lib.bas:2:5`, Include: `inner.bi:2:5`; zusätzlich InitStmt-, DEF-FN- und FRM-Codepositionen. | +| Formulare und Steuerelemente als benannte Objekte | Lesen/Schreiben: bestehender VM-Test `objektzugriff_laeuft_ueber_objekt_und_eigenschaftsindex`; keine Standardeigenschaft: `sema::tests::eigenschaftstyp_schreibbarkeit_bang_und_hir_index`; Wertebereich: `forms::tests::defaults_bereich_und_implizites_laden` prüft Check1.Value = 3 → Fehler 5; unabhängige Werte: zwei Formulare mit Text1 und eigenem Form_Load im neuen Projekttest. | +| Diagnostik mit Vorbild-Meldungen | Exakte Position: SourcePos bleibt 1-basiert; neue Tests prüfen Datei, Zeile und Spalte. Include-Diagnose: sowohl direktes SourceSegment als auch verschachtelte CLI-Expansion liefern die ursprüngliche Position des Typfehlers. | +| Objektzugriffe in Grammatik und Semantik | Unbekanntes Objekt/Eigenschaft und Eigenschaftstyp: bestehende Semantiktests für Text9.Text, Text1.Farbe und Text1.Text = 5; TYPEOF: `typeof_form_control_und_form_metabefehl`; gleichnamige Controls: Form1!Text1/Form2!Text1 sowie lokale Text1-Zugriffe beider Form_Load-Rümpfe. | +| Unterbrechbare step()-Ausführung | Breakpoint und Inspektion: vorhandene VM-Tests plus neuer Modulbreakpoint; gleiche Zeile in zwei Modulen: Modul A passiert Zeile 2, Modul B hält dort. Fortsetzen im Einzelschritt erhält `nested.bi:2`, physische Spalte 5 und getrenntes ERL = 200. | + +## Ausgeführte Abnahme + +- `cargo test --workspace`: **453 bestanden, 0 fehlgeschlagen, 1 ignoriert**. + Der ignorierte Golden-Generator wurde nicht ausgeführt; Sollausgaben blieben unverändert. +- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: bestanden. +- `cargo fmt --all -- --check`: bestanden. +- `cargo bench -p tb-vm --bench compile`: 508 Zeilen **0,56 ms** (Budget 50 ms); + 49.760 Zeilen in 20 verbundenen Modulen **77 ms** (Budget 1000 ms), + 335.421 Instruktionen. Release-Messung auf diesem Rechner. +- Formatabgleich: **153/153 Opcodezeilen** einschließlich Operandentypen und + Bytebreiten sowie **9/9 Abschnittskennungen** gegen Writer/Reader abgeglichen. +- `openspec validate projektmodule-und-kompilat --strict`: bestanden. +- `git diff --check`: bestanden. + +Die Abnahme ist eine lokale Code-/Testprüfung auf macOS; sie ist kein neuer +interaktiver Originalsystemvergleich und keine Windows-/Linux-Testmatrix. + +## Reviewkorrekturen + +Die ursprünglichen sieben Reviewproben bestehen unverändert, sowohl im +separaten Treiber als auch in der Workspace-Suite. Die dauerhafte Datei +`crates/tb-vm/tests/project_regressions.rs` ergänzt sie um neun Tests mit +verwandten Fällen. [review.md](review.md) ordnet die Korrekturen V1–V6 und +alle zusätzlich reproduzierten Abweichungen den Nachweisen zu. + +`CommonArr` (`0x3B`) initialisiert gemeinsame Arrays einmal und prüft spätere +Laufzeitgrenzen auf Kompatibilität; vorab bekannte Konflikte meldet der +Linker an der ursprünglichen COMMON-Deklaration. Die neue Instruktion ist +in TBC 4 serialisiert, validiert und vollständig dokumentiert. Typ-Suffixe +bleiben in Debugger-Slotnamen erhalten. AST-Literale und sämtliche ausführbaren +Zweig-/Schleifenteile tragen ihre tatsächlichen Quellpositionen. diff --git a/openspec/changes/projektmodule-und-kompilat/tasks.md b/openspec/changes/projektmodule-und-kompilat/tasks.md deleted file mode 100644 index 2aff899..0000000 --- a/openspec/changes/projektmodule-und-kompilat/tasks.md +++ /dev/null @@ -1,15 +0,0 @@ -## 1. Quellen und Scopes - -- [ ] 1.1 Quell-/Modulidentitäten durch Include-Expansion und Frontend erhalten; verifizieren mit Compile- und Runtimefehlern aus lib.bas:2 und verschachtelten Includes. -- [ ] 1.2 Form-/Prozedurscopes korrekt auflösen; verifizieren mit zwei Form_Load-Prozeduren, zwei Text1-Controls und unterschiedlichen Form1!Text1/Form2!Text1-Werten sowie echten Duplikaten im selben Scope. - -## 2. Kompilat und Debugger - -- [ ] 2.1 Vollständige Forms-Anfangsdaten, Quelltabelle und Prozedursignaturen serialisieren; verifizieren durch bytegleichen Kompilat-Rundlauf und gleiche Ausführung inklusive Designzeit-Control-Arrays. -- [ ] 2.2 Modulbezogene Breakpoints/Quellortmeldungen ergänzen; verifizieren mit identischen physischen Zeilennummern in zwei Modulen, Include-Fehlern, Einzelschritt und unverändertem ERR/ERL. -- [ ] 2.3 CLI-run für TBC anschließen und Versionsgrenze anheben; verifizieren mit build, Entfernen aller Quellen, run des Kompilats und klarer Ablehnung inkompatibler Versionen. - -## 3. Abnahme - -- [ ] 3.1 Container und vollständige Opcode-/Operandentabelle dokumentieren; verifizieren mit Format-/Opcode-Abgleich, cargo test --workspace und dem Compile-Budget. -- [ ] 3.2 Delta-Specs vollständig gegen die erhaltenen Szenarien prüfen; verifizieren mit openspec validate projektmodule-und-kompilat --strict und git diff --check. diff --git a/openspec/specs/bytecode-kompilat/spec.md b/openspec/specs/bytecode-kompilat/spec.md index f31e398..ab11d47 100644 --- a/openspec/specs/bytecode-kompilat/spec.md +++ b/openspec/specs/bytecode-kompilat/spec.md @@ -17,6 +17,8 @@ implizite Konvertierungen erscheinen als explizite Konvertierungs-Opcodes an den vom HIR bestimmten Stellen. Der Opcode-Satz SHALL in docs/tbvm-design.md vollständig dokumentiert sein. +Die Dokumentation SHALL sämtliche tatsächlich serialisierbaren Instruktionen einschließlich Forms, Ereignissteuerung und Dateioperationen mit Operanden und Encoding führen. + #### Scenario: Gemischter Ausdruck wird monomorph - **WHEN** `d# = i% + 1.5#` übersetzt wird (INTEGER-Variable, DOUBLE-Ziel) - **THEN** enthält der Bytecode einen Konvertierungs-Opcode INTEGER→DOUBLE und eine DOUBLE-Addition, keinen generischen Additions-Opcode @@ -27,14 +29,17 @@ docs/tbvm-design.md vollständig dokumentiert sein. ### Requirement: `.tbc`-Containerformat Das Kompilat SHALL als `.tbc`-Datei serialisierbar und wieder ladbar sein: -Magic `TBC\0`, Formatversion, Abschnittstabelle mit den Abschnitten -CONSTS (deduplizierter Konstantenpool), TYPES (TYPE-Layouts), PROCS -(modul-qualifizierte Prozedurtabelle mit Signatur, Locals-Anzahl, -Code-Offset), CODE, DATA (`READ`/`RESTORE`-Segment) und LINES -(Zeilentabelle). Laden und erneutes Serialisieren MUST verlustfrei sein; +Magic `TBC\0`, Formatversion und dokumentierte Abschnittstabelle. +Der Container SHALL Quellen und Module, Konstantenpool, TYPE-Layouts, +modulqualifizierte Prozeduren mit vollständiger Signatur, Locals und Code, +DATA sowie die vollständigen Forms-Anfangsbeschreibungen führen. +Code und Quellortinformationen SHALL inline in den Prozeduren liegen +dürfen; eigenständige CODE-/LINES-Abschnitte sind nicht erforderlich. Laden und erneutes Serialisieren MUST verlustfrei sein; eine unbekannte Formatversion MUST mit einer klaren Fehlermeldung abgewiesen werden. +Ein aus FRM- oder Projektquellen gebautes Kompilat SHALL ohne erneuten Zugriff auf diese Quellen ausführbar sein und ihre Anfangseigenschaften und Designzeit-Control-Arrays bewahren. + #### Scenario: Roundtrip - **WHEN** ein kompiliertes Modul als `.tbc` geschrieben und wieder geladen wird - **THEN** ist das geladene Kompilat funktional identisch (gleiche Ausführung, gleiche Zeilenzuordnung) @@ -43,15 +48,25 @@ abgewiesen werden. - **WHEN** eine `.tbc`-Datei mit höherer Formatversion geladen wird - **THEN** wird das Laden mit einer Meldung abgelehnt, die die Version nennt +#### Scenario: Formularwerte nach Kompilat-Rundlauf +- **WHEN** ein Formular mit Text1.Text = "hello" kompiliert, gespeichert und ohne Quelldatei geladen wird +- **THEN** liefert der erste Zugriff auf Text1.Text weiterhin hello + ### Requirement: Zeilentabelle für Fehlerortung Der Bytecode SHALL jede Anweisung ihrem Ursprung (Moduldatei, Zeile) zuordnen, sodass Laufzeitfehlermeldungen, `ERL`, Breakpoints und Einzelschritt die Quellzeile exakt benennen können. +Die Zuordnung SHALL auch nach Include-Expansion und bei mehreren Projektmitgliedern die ursprüngliche Datei und physische Zeile bewahren. Numerische BASIC-Zeilenlabels für ERL SHALL davon getrennt bleiben. + #### Scenario: Fehler nennt Zeile - **WHEN** in Zeile 42 eines Programms ein Laufzeitfehler ohne Handler auftritt - **THEN** nennt die Fehlermeldung Zeile 42 +#### Scenario: Fehler aus Projektbibliothek +- **WHEN** lib.bas in physischer Zeile 2 einen Fehler auslöst und über app.mak aufgerufen wird +- **THEN** nennt die Meldung lib.bas und Zeile 2 statt einer verketteten Projektzeile + ### Requirement: Instant-Compile-Budget Die Übersetzung (Lexen bis Bytecode) SHALL ohne globale Analysepasses auskommen; Vorwärtsreferenzen werden über Fixups aufgelöst. Ein diff --git a/openspec/specs/forms-objektmodell/spec.md b/openspec/specs/forms-objektmodell/spec.md index 0146ec9..0b36d04 100644 --- a/openspec/specs/forms-objektmodell/spec.md +++ b/openspec/specs/forms-objektmodell/spec.md @@ -33,6 +33,10 @@ Zuweisung einen Fehler auslösen. - **WHEN** `Check1.Value = 3` ausgeführt wird - **THEN** tritt Laufzeitfehler 5 auf +#### Scenario: Getrennte Werte gleichnamiger Controls +- **WHEN** Form1!Text1.Text auf a und Form2!Text1.Text auf b gesetzt werden +- **THEN** liefern die beiden Zugriffe unabhängig a und b + ### Requirement: Container-Zugriff über den Bang-Operator `form!steuerelement.eigenschaft` SHALL ein Steuerelement eines benannten Formulars ansprechen. Die Schreibweise MUST NOT mit dem Typsuffix `!` diff --git a/openspec/specs/sprach-frontend/spec.md b/openspec/specs/sprach-frontend/spec.md index b28fe49..bb4c6f8 100644 --- a/openspec/specs/sprach-frontend/spec.md +++ b/openspec/specs/sprach-frontend/spec.md @@ -99,10 +99,16 @@ tragen; die Meldungstexte SHALL dem Katalog des Vorbilds folgen, wo eine Entsprechung existiert (z. B. „Type mismatch", „Duplicate definition", „Label not defined", „Subprogram not defined", „Overflow"). +Bei Projekten und Includes SHALL jede Diagnose den ursprünglichen Dateinamen mit dessen physischer Zeile und Spalte tragen. + #### Scenario: Position in der Diagnose - **WHEN** in Zeile 3 ein Typkonflikt auftritt - **THEN** nennt die Diagnose Zeile 3 und die Spalte des Ausdrucks +#### Scenario: Include-Diagnose +- **WHEN** ein eingebundenes Include einen Typfehler enthält +- **THEN** nennt die Diagnose die Include-Datei und ihre eigene Quellposition + ### Requirement: Typisiertes HIR als Sema-Ausgabe Die semantische Analyse SHALL neben den Diagnosen einen typisierten, abgesenkten Zwischenbaum (HIR) liefern, der die Codegen-Eingabe bildet: @@ -259,6 +265,8 @@ Basis weder ein benutzerdefinierter Typ noch ein bekanntes Objekt ist, MUST eine Diagnose erzeugen, die Basis und Eigenschaft namentlich nennt; er MUST NOT zu einer impliziten Variablen mit Punkt im Namen werden. +Gleichnamige Steuerelemente unterschiedlicher Formulare SHALL im jeweiligen Formularnamensraum aufgelöst werden; die explizite Containerangabe SHALL vor der lokalen Namenssuche wirken. + #### Scenario: Unbekanntes Objekt wird benannt - **WHEN** ein Modul `Text9.Text = "a"` ohne Steuerelement `Text9` enthält - **THEN** nennt die Diagnose `Text9` als unbekanntes Objekt statt „Type mismatch" zu melden @@ -275,6 +283,10 @@ er MUST NOT zu einer impliziten Variablen mit Punkt im Namen werden. - **WHEN** ein Modul `IF TYPEOF Ziel IS CommandButton THEN` enthält - **THEN** wird es übersetzt und die Klasse gegen die bekannten Steuerelementklassen geprüft +#### Scenario: Gleichnamige Controls verschiedener Formulare +- **WHEN** Form1 und Form2 jeweils Text1 enthalten und Form2!Text1.Text gelesen wird +- **THEN** wird Text1 in Form2 aufgelöst und das Programm übersetzt diagnosefrei + ### Requirement: Bang-Operator neben dem SINGLE-Suffix Der Lexer SHALL `!` als Container-Operator erkennen, wenn ihm ein Bezeichner folgt, und andernfalls als Typsuffix für SINGLE. Beide Formen diff --git a/openspec/specs/vm-ausfuehrung/spec.md b/openspec/specs/vm-ausfuehrung/spec.md index d23e669..ea6f22f 100644 --- a/openspec/specs/vm-ausfuehrung/spec.md +++ b/openspec/specs/vm-ausfuehrung/spec.md @@ -95,6 +95,8 @@ Arrayelemente, UDT-Felder) inspizieren und die Ausführung fortsetzen können. Die Tick-Prüfung erfolgt nur an Anweisungsgrenzen, nicht pro Opcode. +Ein Breakpoint SHALL durch Modulidentität und physische Quellzeile bestimmt sein. Gleiche Zeilennummern anderer Module MUST NOT denselben Breakpoint auslösen. + #### Scenario: Breakpoint hält an - **WHEN** ein Breakpoint auf Zeile 10 gesetzt ist und die Ausführung Zeile 10 erreicht - **THEN** hält die VM vor der Anweisung an und meldet Modul und Zeile @@ -103,6 +105,10 @@ Opcode. - **WHEN** die VM an einem Breakpoint steht - **THEN** liefert die Inspektions-API den aktuellen Wert einer benannten Variablen +#### Scenario: Gleiche Zeile in zwei Modulen +- **WHEN** ein Breakpoint auf Modul A, Zeile 10 gesetzt ist und Modul B Zeile 10 ausführt +- **THEN** läuft B weiter; erst A Zeile 10 hält an + ### Requirement: Programmende im Runner Außerhalb der IDE SHALL `END` (und das Erreichen des Programmendes) den Prozess mit Exit-Code 0 beenden; `STOP` SHALL mit der Meldung