Phase 5: Editor und inkrementellen Compiler implementieren und archivieren

This commit is contained in:
2026-09-06 17:58:26 +02:00
parent df85a4b7b2
commit 9c85349a4d
36 changed files with 2993 additions and 247 deletions

View File

@@ -44,7 +44,59 @@ fn compile_all(sources: &[(String, String)]) -> usize {
project.procs.iter().map(|p| p.code.len()).sum()
}
fn incremental(sources: &[(String, String)], budget: f64) {
let mut units: Vec<_> = sources
.iter()
.map(|(n, s)| tb_frontend::source::SourceUnit::new(n, &format!("{n}.bas"), s))
.collect();
let mut compiler = tb_vm::project::ProjectCompiler::default();
compiler
.compile("BENCH", &units, &Default::default(), &[])
.unwrap();
let mut times = Vec::new();
for i in 0..7 {
// Private Rumpfänderung mit unverändertem öffentlichen Vertrag.
units[0].segments[0].text = sources[0]
.1
.replace("a% * 2 + b#", &format!("a% * {} + b#", 3 + i));
let start = Instant::now();
let code = compiler
.compile("BENCH", &units, &Default::default(), &[])
.unwrap();
std::hint::black_box(&code);
times.push(start.elapsed().as_secs_f64());
assert_eq!(compiler.stats.compiled, 1);
assert_eq!(compiler.stats.reused, units.len() - 1);
}
times.sort_by(f64::total_cmp);
let median = times[times.len() / 2];
println!(" Cacheänderung: {} Module, Median {:.2} ms (7 Läufe; Invalidierung + Link), {} neu / {} wiederverwendet; Budget {:.0} ms {}",units.len(),median*1000.0,compiler.stats.compiled,compiler.stats.reused,budget*1000.0,if median<budget {"OK"}else{"VERFEHLT"});
assert!(median < budget, "Cachebudget verfehlt");
}
fn main() {
println!(
"Hardware: {} / {} / {}",
std::env::consts::OS,
std::env::consts::ARCH,
std::process::Command::new(if cfg!(target_os = "macos") {
"sysctl"
} else {
"uname"
})
.args(if cfg!(target_os = "macos") {
vec!["-n", "machdep.cpu.brand_string"]
} else {
vec!["-m"]
})
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
.unwrap_or_default()
);
println!(
"Profil: Release / opt-level=3, eigener Harness; cargo bench -p tb-vm --bench compile"
);
// Einzelnes Modul: ~500 Zeilen (Budget < 50 ms).
let single = generate_module(50, 0);
let single_lines = single.lines().count();
@@ -90,4 +142,6 @@ fn main() {
println!(" Durchsatz: {:.0} Zeilen/s", lps);
assert!(best_single < 0.050, "Einzelmodul-Budget verfehlt");
assert!(project_secs < 1.0, "Projekt-Budget verfehlt");
incremental(&[("EINZEL".into(), single)], 0.050);
incremental(&modules, 1.0);
}

View File

@@ -510,7 +510,7 @@ pub struct DataItem {
}
/// Übersetztes Modul — Inhalt des `.tbc`-Containers.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct CompiledModule {
pub modules: Vec<(String, u8)>,
pub sources: Vec<SourceFile>,

View File

@@ -22,135 +22,237 @@ fn diagnostic(message: impl Into<String>) -> Diagnostic {
}
}
/// Prozesslokaler Cache: unverknüpfte Produkte, niemals ein bereits gelinktes Programm.
#[derive(Default)]
pub struct ProjectCompiler {
parsed: Vec<Parsed>,
products: Vec<Product>,
pub stats: CompileStats,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CompileStats {
pub parsed: usize,
pub compiled: usize,
pub reused: usize,
}
struct Parsed {
unit: SourceUnit,
prefix: Vec<tb_frontend::source::SourceFile>,
sources: Vec<tb_frontend::source::SourceFile>,
module: Module,
diagnostics: Vec<Diagnostic>,
names: HashSet<String>,
}
struct Product {
module: Module,
catalog: FormCatalog,
code: CompiledModule,
commons: Vec<tb_frontend::hir::HCommon>,
}
pub fn compile_project(
name: &str,
units: &[SourceUnit],
catalog: &FormCatalog,
forms: &[FormFile],
) -> Result<CompiledModule, Vec<Diagnostic>> {
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
ProjectCompiler::default().compile(name, units, catalog, forms)
}
impl ProjectCompiler {
pub fn compile(
&mut self,
name: &str,
units: &[SourceUnit],
catalog: &FormCatalog,
forms: &[FormFile],
) -> Result<CompiledModule, Vec<Diagnostic>> {
self.stats = CompileStats::default();
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()
.any(|s| matches!(s, Stmt::MetaForm { .. }))
&& catalog.find(&module.name).is_none()
{
.enumerate()
.map(|(id, unit)| {
let cached = self
.parsed
.get(id)
.filter(|p| p.unit == *unit && p.prefix == sources);
let (module, errors) = if let Some(p) = cached {
sources = p.sources.clone();
(p.module.clone(), p.diagnostics.clone())
} else {
let prefix = sources.clone();
let (module, errors) = unit.parse(id as u16, &mut sources);
let entry = Parsed {
unit: unit.clone(),
prefix,
sources: sources.clone(),
module: module.clone(),
diagnostics: errors.clone(),
names: unit
.segments
.iter()
.flat_map(|s| tb_frontend::lexer::lex(&s.text).tokens)
.filter_map(|t| match t.kind {
tb_frontend::lexer::TokenKind::Ident { name, .. } => Some(name),
_ => None,
})
.collect(),
};
if id < self.parsed.len() {
self.parsed[id] = entry;
} else {
self.parsed.push(entry);
}
self.stats.parsed += 1;
(module, errors)
};
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(
&module.name,
tb_frontend::forms::ObjectClass::Form,
"SCREEN",
tb_frontend::forms::ObjectClass::Screen,
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());
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 constants: Vec<_> = constants
.into_values()
.filter(|s| s.len() == 1)
.flatten()
.collect();
let next: Vec<_> = parsed
let mut exports: Vec<_> = parsed
.iter()
.map(|m| tb_frontend::sema::export_declarations(m, &constants))
.map(|m| tb_frontend::sema::export_declarations(m, &[]))
.collect();
if next == exports {
break;
// 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;
}
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);
let mut parts = Vec::new();
let mut commons = Vec::new();
let mut products = Vec::new();
for (index, module) in parsed.iter().enumerate() {
let mut key = module.clone();
import_declarations(&mut key, &parsed, &exports, Some(&self.parsed[index].names));
let mut module = module.clone();
import_declarations(&mut module, &parsed, &exports, None);
if let Some(product) = self
.products
.iter()
.find(|p| p.module == key && p.catalog == catalog)
{
parts.push(product.code.clone());
commons.push(product.commons.clone());
self.stats.reused += 1;
continue;
}
self.stats.compiled += 1;
let (hir, errors) = tb_frontend::sema::lower_with_forms(&module, &catalog);
diagnostics.extend(errors);
if let Some(hir) = hir {
let code = codegen::compile(&hir);
parts.push(code.clone());
commons.push(hir.commons.clone());
products.push(Product {
module: key,
catalog: catalog.clone(),
code,
commons: 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 {
locate_diagnostics(&mut diagnostics, &sources);
if !diagnostics.is_empty() {
return Err(diagnostics);
}
self.parsed.truncate(units.len());
self.products.retain(|p| {
parsed.iter().any(|m| m.name == p.module.name)
&& !products.iter().any(|n| n.module.name == p.module.name)
});
self.products.extend(products);
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
.form_initial
.extend(form.initial_values(&objects).map_err(|e| {
vec![diagnostic(format!(
"{}: ungültige Forms-Anfangsdaten ({e})",
form.root.name
))]
})?);
.validate()
.map_err(|e| vec![diagnostic(e.to_string())])?;
Ok(result)
}
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.
@@ -251,7 +353,12 @@ fn import_type(
alias
}
fn import_declarations(module: &mut Module, all: &[Module], exports: &[Vec<Stmt>]) {
fn import_declarations(
module: &mut Module,
all: &[Module],
exports: &[Vec<Stmt>],
signature: Option<&HashSet<String>>,
) {
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();
@@ -318,6 +425,32 @@ fn import_declarations(module: &mut Module, all: &[Module], exports: &[Vec<Stmt>
_ => {}
}
}
if let Some(names) = signature {
// Nicht verwendete importierte Konstanten erzeugen weder Slots noch Tabellen.
// TYPEs und Prozeduren dagegen gehören zu den unverketteten Tabellen.
imported
.retain(|s| !matches!(s, Stmt::ConstDecl {items,..} if !names.contains(&items[0].0)));
// Importverträge enthalten Namen/Typen/Werte, keine verschobenen Rumpfzeilen
// ihres Ursprungs. Eigene Quellorte bleiben Teil des Modulschlüssels.
for stmt in &mut imported {
match stmt {
Stmt::Declare { pos, .. } | Stmt::TypeDecl { pos, .. } => {
*pos = SourcePos::default()
}
Stmt::ConstDecl { items, pos } => {
*pos = SourcePos::default();
for (_, _, expr) in items {
match expr {
tb_frontend::ast::Expr::DoubleLit(_, p)
| tb_frontend::ast::Expr::StrLit(_, p) => *p = SourcePos::default(),
_ => {}
}
}
}
_ => {}
}
}
}
imported.append(&mut module.body);
let mut types = Vec::new();
imported.retain(|stmt| {

View File

@@ -0,0 +1,162 @@
use tb_frontend::{
forms::FormCatalog,
source::{SourceSegment, SourceUnit},
};
use tb_ui::frm::{self, FormFile};
use tb_vm::{compile_project, project::ProjectCompiler};
fn unit(name: &str, text: &str) -> SourceUnit {
SourceUnit::new(name, &format!("{name}.bas"), text)
}
fn compare(cache: &mut ProjectCompiler, units: &[SourceUnit], forms: &[FormFile]) -> bool {
let mut catalog = FormCatalog::default();
for f in forms {
catalog.append(&f.catalog());
}
let a = cache.compile("APP", units, &catalog, forms);
let b = compile_project("APP", units, &catalog, forms);
match (a, b) {
(Ok(a), Ok(b)) => {
assert_eq!(a.to_tbc(), b.to_tbc());
true
}
(Err(a), Err(b)) => {
assert_eq!(
a.iter().map(ToString::to_string).collect::<Vec<_>>(),
b.iter().map(ToString::to_string).collect::<Vec<_>>()
);
false
}
(a, b) => panic!("cache/full mismatch {a:?} / {b:?}"),
}
}
#[test]
fn cached_private_edits_parse_and_compile_only_changed_modules() {
let mut c = ProjectCompiler::default();
let mut units = vec![
unit("A", "CALL Work\nEND"),
unit("B", "SUB Work\nPRINT 1\nEND SUB"),
unit("C", "SUB Independent\nPRINT 3\nEND SUB"),
];
assert!(compare(&mut c, &units, &[]));
assert_eq!(c.stats.compiled, 3);
assert!(compare(&mut c, &units, &[]));
assert_eq!(
(c.stats.parsed, c.stats.compiled, c.stats.reused),
(0, 0, 3)
);
units[1].segments[0].text = units[1].segments[0].text.replace("PRINT 1", "PRINT 2");
assert!(compare(&mut c, &units, &[]));
assert_eq!(
(c.stats.parsed, c.stats.compiled, c.stats.reused),
(1, 1, 2)
);
units[1].segments[0].text = "SUB Work\nPRINT 2\nEND SUB\nSUB Second\nPRINT 4\nEND SUB".into();
assert!(compare(&mut c, &units, &[]));
units[1].segments[0].text = units[1].segments[0]
.text
.replace("PRINT 2", "PRINT 2\nPRINT 3");
assert!(compare(&mut c, &units, &[]));
assert_eq!(
(c.stats.compiled, c.stats.reused),
(1, 2),
"Verschobene fremde Prozedurzeilen ändern keinen Importvertrag"
);
units[1].segments[0].text = "SUB Work\nPRINT )\nEND SUB".into();
assert!(!compare(&mut c, &units, &[]));
units[1].segments[0].text = "SUB Work\nPRINT 5\nEND SUB".into();
assert!(compare(&mut c, &units, &[]));
}
#[test]
fn constants_types_common_removed_symbols_and_order_match_full_compiler() {
let mut c = ProjectCompiler::default();
let mut u = vec![
unit(
"A",
"CONST A=2\nTYPE T\nx AS INTEGER\nEND TYPE\nCOMMON SHARED N%\n",
),
unit("B", "CONST B=A+1\n"),
unit(
"C",
"DIM value AS T\nvalue.x=B\nCOMMON SHARED N%\nN%=B\nPRINT value.x\n",
),
];
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = u[0].segments[0].text.replace("A=2", "A=4");
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = u[0].segments[0].text.replace("INTEGER", "LONG");
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = u[0].segments[0].text.replace("N%", "N$");
compare(&mut c, &u, &[]);
u[0].segments[0].text = u[0].segments[0].text.replace("CONST A=4", "' removed");
assert!(!compare(&mut c, &u, &[]));
u.swap(0, 2);
assert!(!compare(&mut c, &u, &[]));
u.remove(0);
compare(&mut c, &u, &[]);
}
#[test]
fn includes_source_ids_data_and_forms_relink_without_patching_old_products() {
let mut c = ProjectCompiler::default();
let mut u = vec![
unit(
"A",
"DATA 3,\"Hi\"\nREAD a%, b$\nPRINT a%;b$\nCALL Work\nEND",
),
unit("B", "SUB Work\nPRINT 2\nEND SUB"),
];
u[0].segments.insert(
0,
SourceSegment {
file: "shared.bi".into(),
first_line: 7,
text: "CONST N=1\n".into(),
},
);
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = "CONST N=2\n".into();
assert!(compare(&mut c, &u, &[]));
u.swap(0, 1);
assert!(compare(&mut c, &u, &[]));
u[0].segments.insert(
0,
SourceSegment {
file: "extra.bi".into(),
first_line: 1,
text: "' extra source\n".into(),
},
);
assert!(compare(&mut c, &u, &[]));
for index in [None, Some(0), Some(1)] {
let property = index.map_or(String::new(), |index| format!(" Index = {index}\n"));
let text = format!(
"VERSION 1.00\nBEGIN Form Main\n BEGIN CommandButton Button\n{property} END\nEND\n"
);
let form = frm::read_text("Main.frm", &text).unwrap();
let units = [unit("Main", "'$FORM\n")];
assert!(compare(&mut c, &units, &[form]));
}
}
#[test]
fn unused_constants_do_not_invalidate_independent_units_and_declare_changes_do() {
let mut c = ProjectCompiler::default();
let mut u = vec![
unit("A", "CONST A=1\n"),
unit("B", "CONST B=A+1\n"),
unit("C", "PRINT B\n"),
unit("D", "PRINT 7\n"),
];
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = "CONST A=3\n".into();
assert!(compare(&mut c, &u, &[]));
assert_eq!((c.stats.compiled, c.stats.reused), (3, 1));
let mut u = vec![
unit("A", "DECLARE SUB Work(n%)\nCALL Work(1)\nEND"),
unit("B", "SUB Work(n%)\nPRINT n%\nEND SUB"),
];
assert!(compare(&mut c, &u, &[]));
u[1].segments[0].text = "SUB Work(n$)\nPRINT n$\nEND SUB".into();
assert!(!compare(&mut c, &u, &[]));
u[0].segments[0].text = "DECLARE SUB Work(n$)\nCALL Work(\"ok\")\nEND".into();
assert!(compare(&mut c, &u, &[]));
}