Phase 6: P-Code-Bibliotheken und Linker abschließen, Cross-Buildplan festhalten

This commit is contained in:
2026-09-07 16:06:23 +02:00
parent 60d37ec79d
commit 25176947ba
33 changed files with 2416 additions and 203 deletions

View File

@@ -0,0 +1,375 @@
use tb_frontend::{forms::FormCatalog, source::SourceUnit};
use tb_runtime::host::CaptureHost;
use tb_vm::{
bytecode::CompiledModule,
interp::{RunEvent, Vm},
library::Library,
project::ProjectCompiler,
};
fn unit(name: &str, text: &str) -> SourceUnit {
SourceUnit::new(name, &format!("{name}.bas"), text)
}
fn library(units: &[SourceUnit], libs: &[Library]) -> Library {
let l = ProjectCompiler::default()
.compile_library(units, &FormCatalog::default(), &[], libs)
.unwrap();
let bytes = l.to_tbl().unwrap();
let l = Library::from_tbl(&bytes).unwrap();
assert_eq!(bytes, l.to_tbl().unwrap());
l
}
fn link(main: &str, libs: &[Library]) -> Result<CompiledModule, String> {
let mut c = ProjectCompiler::default();
c.compile_library(&[unit("APP", main)], &FormCatalog::default(), &[], libs)
.and_then(|l| c.link_library("APP", l, None))
.map_err(|ds| {
ds.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
})
}
fn output(code: CompiledModule) -> String {
let mut vm = Vm::new(code);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
tb_runtime::snapshot::text(&vm.rt.screen)
}
#[test]
fn two_source_free_libraries_with_typed_open_imports_and_diagnostics() {
let a = library(
&[unit(
"A",
"DECLARE SUB Second(n AS INTEGER)\nSUB First(n AS INTEGER)\nSecond n\nEND SUB",
)],
&[],
);
assert!(link("CALL First(n%)\nEND", std::slice::from_ref(&a))
.unwrap_err()
.contains("SECOND"));
let b = library(
&[unit("B", "SUB Second(n AS INTEGER)\nn=n+3\nEND SUB")],
&[],
);
assert_eq!(
output(
link(
"n%=2\nCALL First(n%)\nPRINT n%\nEND",
&[a.clone(), b.clone()]
)
.unwrap()
),
" 5 \n"
);
let incompatible = library(&[unit("B", "SUB Second(n AS LONG)\nEND SUB")], &[]);
assert!(link("CALL First(n%)\nEND", &[a.clone(), incompatible])
.unwrap_err()
.contains("Parameter type mismatch"));
let c = library(&[unit("C", "SUB Second(n AS INTEGER)\nEND SUB")], &[]);
let direct = link("CALL Second(n%)\nEND", &[b.clone(), c.clone()]).unwrap_err();
assert!(
direct.contains("Ambiguous subprogram: SECOND (B, C)"),
"{direct}"
);
let error = link("CALL First(n%)\nEND", &[a.clone(), b.clone(), c]).unwrap_err();
assert!(
error.contains("Ambiguous") && error.contains("B") && error.contains("C"),
"{error}"
);
assert!(link("END", &[b.clone(), b.clone()])
.unwrap_err()
.contains("Duplicate definition: module B"));
let combined = library(&[], &[a, b]);
assert_eq!(combined.modules.len(), 2);
assert_eq!(
output(link("CALL First(n%)\nPRINT n%\nEND", &[combined]).unwrap()),
" 3 \n"
);
}
#[test]
fn source_and_library_have_identical_types_constants_common_data_byref_and_init_order() {
let a=unit("A","CONST N=3\nTYPE Record\nx AS INTEGER\ns AS STRING * 4\nEND TYPE\nCOMMON SHARED shared%\nDIM SHARED ready%(N)\nDATA 7\nPRINT \"A\"\nDATA 7\nSUB Work(a%(), r AS Record, n%)\nready%(N)=1\nshared%=shared%+1\na%(1)=N\nr.x=r.x+n%\nn%=9\nRESTORE\nREAD d%\nPRINT d%\nEND SUB\n");
let b = unit("B", "PRINT \"B\"\n");
let main="COMMON SHARED shared%\nDIM a%(N)\nDIM r AS Record\nn%=2\nCALL Work(a%(),r,n%)\nPRINT a%(1);r.x;n%;shared%\nCALL Work(a%(),r,(n%))\nPRINT r.x;n%;shared%\n";
let source = ProjectCompiler::default()
.compile(
"APP",
&[unit("APP", main), a.clone(), b.clone()],
&FormCatalog::default(),
&[],
)
.unwrap();
let binary = link(main, &[library(&[a, b], &[])]).unwrap();
assert_eq!(output(source), output(binary));
}
#[test]
fn library_source_locations_and_run_restart_are_preserved() {
let l = library(&[unit("LIB", "SUB Fail\n200 ERROR 6\nEND SUB")], &[]);
let mut vm = Vm::new(link("CALL Fail\nEND", &[l]).unwrap());
assert!(matches!(
vm.run(&mut CaptureHost::default()),
RunEvent::Error {
code: 6,
line: 2,
..
}
));
assert_eq!(vm.current_file(), "LIB.bas");
let l = library(
&[unit(
"LIB",
"DIM SHARED n%\nSUB Increment\nn%=n%+1\nPRINT n%\nEND SUB",
)],
&[],
);
let code = link("CALL Increment\nEND", &[l]).unwrap();
for _ in 0..2 {
assert_eq!(output(code.clone()), " 1 \n");
}
}
#[test]
fn library_container_rejects_truncation_versions_lengths_and_references() {
let l = library(
&[unit("LIB", "CONST N=3\nSUB Work(n%)\nn%=N\nEND SUB")],
&[],
);
let bytes = l.to_tbl().unwrap();
let seal = |mut payload: Vec<u8>| {
payload.extend_from_slice(&tb_vm::bytecode::checksum(&payload).to_le_bytes());
payload
};
for end in 0..bytes.len() - 8 {
assert!(
Library::from_tbl(&bytes[..end]).is_err(),
"accepted prefix {end}"
);
assert!(
Library::from_tbl(&seal(bytes[..end].to_vec())).is_err(),
"accepted incomplete payload {end}"
);
}
for offset in [0, 4, 6, 8, 12, 16] {
let mut bad = bytes[..bytes.len() - 8].to_vec();
bad[offset] = 255;
assert!(
Library::from_tbl(&seal(bad)).is_err(),
"accepted offset {offset}"
);
}
let mut bad = bytes[..bytes.len() - 8].to_vec();
bad.push(0);
assert!(Library::from_tbl(&seal(bad)).is_err());
for offset in (0..bytes.len() - 8).step_by(7) {
let mut bad = bytes[..bytes.len() - 8].to_vec();
bad[offset] ^= 255;
let bad = seal(bad);
std::panic::catch_unwind(|| {
if let Ok(l) = Library::from_tbl(&bad) {
let _ = link("END", &[l]);
}
})
.expect("beschädigte TBL darf nicht paniken");
}
let mut bad = l.clone();
if let tb_frontend::ast::Stmt::ConstDecl { items, .. } = &mut bad.modules[0].metadata.exports[0]
{
items.clear();
}
assert!(bad.to_tbl().is_err());
let mut bad = l.clone();
bad.modules[0].metadata.defined.push("ABSENT".into());
assert!(bad.to_tbl().is_err());
let mut bad = l;
bad.modules[0].code.procs[0]
.code
.push(tb_vm::bytecode::Instr::Call(u16::MAX, 0));
assert!(bad.to_tbl().is_err());
}
#[test]
fn separate_form_libraries_remap_nested_objects_initials_arrays_and_events() {
let form = |name: &str, text: &str| {
tb_ui::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\nSUB Form_Load\nText1.Text=Text1.Text+\"!\"\nEND SUB\n")).unwrap()
};
let forms = [form("Form1", "a"), form("Form2", "b")];
let mut catalog = FormCatalog::default();
for f in &forms {
catalog.append(&f.catalog());
}
let units: Vec<_> = forms.iter().map(|f| unit(&f.root.name, &f.code)).collect();
let libs: Vec<_> = forms
.iter()
.zip(&units)
.map(|(f, u)| {
let l = ProjectCompiler::default()
.compile_library(
std::slice::from_ref(u),
&f.catalog(),
std::slice::from_ref(f),
&[],
)
.unwrap();
Library::from_tbl(&l.to_tbl().unwrap()).unwrap()
})
.collect();
let main=unit("APP","Form1.Show\nForm2.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");
let mut all = vec![main.clone()];
all.extend(units);
let mut c = ProjectCompiler::default();
let mut source = c.compile("APP", &all, &catalog, &forms).unwrap();
source.startup_form = None;
let l = c
.compile_library(&[main], &FormCatalog::default(), &[], &libs)
.unwrap();
let linked = c.link_library("APP", l, None).unwrap();
assert_eq!(linked.event_procs.len(), 2);
assert_eq!(output(source), output(linked));
}
#[test]
fn contracts_reject_result_common_bounds_and_invalid_declarations() {
let a = library(
&[unit(
"A",
"DECLARE FUNCTION Value%()\nSUB Work\nPRINT Value%\nEND SUB",
)],
&[],
);
let b = library(&[unit("B", "FUNCTION Value&\nValue&=4\nEND FUNCTION")], &[]);
assert!(link("CALL Work\nEND", &[a, b])
.unwrap_err()
.contains("Parameter type mismatch"));
let a = library(&[unit("A", "COMMON SHARED n%(1 TO 3)\n")], &[]);
let b = library(&[unit("B", "COMMON SHARED n%(1 TO 4)\n")], &[]);
assert!(link("END", &[a, b])
.unwrap_err()
.contains("COMMON type or bounds mismatch"));
for source in [
"DECLARE SUB Bad(x AS Missing)",
"DECLARE SUB Bad(x%)\nDECLARE SUB Bad(x&)",
"CONST N=Missing\n",
"TYPE X\nn AS Missing\nEND TYPE",
] {
assert!(
ProjectCompiler::default()
.compile_library(&[unit("BAD", source)], &FormCatalog::default(), &[], &[])
.is_err(),
"{source}"
);
}
}
#[test]
fn source_debug_context_keeps_module_ids_when_library_is_between_sources() {
let l = library(&[unit("LIB", "DIM n%\nSUB LibraryCall\nEND SUB")], &[]);
let mut compiler = ProjectCompiler::default();
compiler.debug_symbols = true;
let mut product = compiler
.compile_library(
&[unit("APP", "PRINT 0"), unit("LAST", "x%=7\nSTOP")],
&FormCatalog::default(),
&[],
&[l],
)
.unwrap();
product.modules.swap(1, 2);
let code = compiler.link_library("APP", product, None).unwrap();
let debug = compiler.debug_compiler();
assert!(debug.compile(1, "<main>", "x%", true).is_err());
let mut vm = Vm::new(code);
vm.debug.enabled = true;
vm.debug.compiler = Some(debug);
assert!(matches!(
vm.run(&mut CaptureHost::default()),
RunEvent::Stopped { .. }
));
let frame = vm.debug_location().unwrap().frame;
assert!(matches!(
vm.evaluate_watch(frame, "x%").unwrap(),
tb_runtime::value::Value::Int(7)
));
}
#[test]
fn unused_declare_is_not_an_import_but_references_in_any_body_must_resolve() {
let l = library(
&[unit(
"LIB",
"DECLARE FUNCTION Missing%(s$)\nSUB Work\nPRINT 7\nEND SUB",
)],
&[],
);
let code = link("CALL Work\nEND", &[l]).unwrap();
assert!(!code.procs.iter().any(|p| p.name.ends_with("MISSING")));
assert_eq!(output(code), " 7 \n");
let l = library(
&[unit(
"LIB",
"DECLARE SUB Missing\nSUB Unused\nCALL Missing\nEND SUB",
)],
&[],
);
assert!(link("END", &[l])
.unwrap_err()
.contains("Subprogram not defined: MISSING"));
let mut compiler = ProjectCompiler::default();
compiler.debug_symbols = true;
compiler
.compile(
"APP",
&[unit("APP", "DECLARE SUB Missing\nSTOP")],
&FormCatalog::default(),
&[],
)
.unwrap();
assert!(compiler
.debug_compiler()
.compile(0, "<main>", "CALL Missing", false)
.err()
.unwrap()
.contains("Subprogram not defined"));
}
#[test]
fn library_include_locations_keep_physical_lines_and_basic_erl() {
use tb_frontend::source::SourceSegment;
let source = SourceUnit {
name: "LIB".into(),
segments: vec![
SourceSegment {
file: "lib.bas".into(),
first_line: 1,
text: "SUB Fail\n".into(),
},
SourceSegment {
file: "nested.bi".into(),
first_line: 9,
text: "200 ERROR 6\n".into(),
},
SourceSegment {
file: "lib.bas".into(),
first_line: 3,
text: "END SUB\n".into(),
},
],
};
let l = library(&[source], &[]);
let mut vm = Vm::new(link("CALL Fail\nEND", std::slice::from_ref(&l)).unwrap());
assert!(matches!(
vm.run(&mut CaptureHost::default()),
RunEvent::Error {
code: 6,
line: 9,
..
}
));
assert_eq!(vm.current_file(), "nested.bi");
assert_eq!(
output(
link(
"ON ERROR GOTO Handler\nCALL Fail\nEND\nHandler: PRINT ERR;ERL\nEND",
&[l]
)
.unwrap()
),
" 6 200 \n"
);
}

View File

@@ -191,3 +191,107 @@ fn case_insensitive_parent_lookup_works_from_relative_base_without_changing_cwd(
let found = tb_vm::project_io::relative_case_insensitive(&base, "../SIBLING.BAS").unwrap();
assert_eq!(identity(&found).unwrap(), identity(&sibling).unwrap());
}
#[test]
fn tbl_members_move_save_as_order_startup_and_same_size_cache_replacement() {
use tb_frontend::{forms::FormCatalog, source::SourceUnit};
use tb_vm::{
interp::{RunEvent, Vm},
project::ProjectCompiler,
};
let t = Temp::new();
let library = |n, value| {
ProjectCompiler::default()
.compile_library(
&[SourceUnit::new(
"LIB",
"removed.bas",
&format!("CONST N={n}\nSUB Work\nPRINT {value}\nEND SUB\nPRINT \"LIB\""),
)],
&FormCatalog::default(),
&[],
&[],
)
.unwrap()
.to_tbl()
.unwrap()
};
let first = library(1, 1);
let second = library(2, 2);
assert_eq!(first.len(), second.len());
t.write("old/main.bas", "PRINT N\nCALL Work\n");
t.write("old/last.bas", "PRINT \"LAST\"\n");
let lib = t.0.join("old/Mixed.tbl");
fs::write(&lib, &first).unwrap();
let mak = t.write(
"old/app.mak",
"mixed.TBL\nlast.bas\nmain.bas\n' $STARTUP: \"main.bas\"\n",
);
let loader = SourceLoader::default();
let input = loader.load(&mak).unwrap();
assert_eq!(input.units.len(), 2);
assert_eq!(input.libraries.len(), 1);
let run = |code| {
let mut vm = Vm::new(code);
assert_eq!(
vm.run(&mut tb_runtime::host::CaptureHost::default()),
RunEvent::Ended
);
tb_runtime::snapshot::text(&vm.rt.screen)
};
let mut c = ProjectCompiler::default();
assert_eq!(
run(input.compile(&mut c, "APP").unwrap()),
" 1 \n 1 \nLIB\nLAST\n"
);
fs::write(&lib, &second).unwrap();
assert_eq!(
run(input.compile(&mut c, "APP").unwrap()),
" 2 \n 2 \nLIB\nLAST\n"
);
let body_only = library(2, 3);
assert_eq!(second.len(), body_only.len());
fs::write(&lib, &body_only).unwrap();
assert_eq!(
run(input.compile(&mut c, "APP").unwrap()),
" 2 \n 3 \nLIB\nLAST\n"
);
assert_eq!(
c.stats.compiled, 0,
"Unveränderte Deklarationen dürfen Quellprodukte wiederverwenden"
);
fs::create_dir_all(t.0.join("save")).unwrap();
let saved = t.0.join("save/app.mak");
fs::write(&saved, input.manifest.text(&saved).unwrap()).unwrap();
assert_eq!(
identity(&loader.load(&saved).unwrap().libraries[0]).unwrap(),
identity(&input.libraries[0]).unwrap()
);
fs::rename(t.0.join("old"), t.0.join("moved")).unwrap();
assert_eq!(
run(loader
.load(&t.0.join("moved/app.mak"))
.unwrap()
.compile(&mut c, "APP")
.unwrap()),
" 2 \n 3 \nLIB\nLAST\n"
);
let moved = t.0.join("moved/app.mak");
for text in [
"Mixed.tbl\n' $STARTUP: \"Mixed.tbl\"",
"Mixed.tbl\nmixed.TBL\n",
"absent.tbl\n",
] {
assert!(Manifest::parse(&moved, text, &loader).is_err(), "{text}");
}
assert!(
tb_vm::project_io::read_document(&t.0.join("moved/Mixed.tbl"))
.unwrap_err()
.contains("kein Textdokument")
);
fs::remove_file(t.0.join("moved/Mixed.tbl")).unwrap();
let error = loader.load(&moved).unwrap_err();
assert!(
error.contains("app.mak") && error.contains("mixed.TBL"),
"{error}"
);
}