//! `tbc` — Standalone-Compiler von Terminal Basic. //! //! Unterbefehle (Phase 2): //! - `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 //! //! Exit-Codes von `run` (Entscheidung D6, docs/tbvm-design.md): //! 0 = END/SYSTEM/Programmende · 3 = STOP · 2 = Laufzeitfehler · //! 1 = Compile-Fehler/Bedienfehler. use std::path::{Path, PathBuf}; use std::process::ExitCode; use tb_runtime::host::{Ereignis, Host}; use tb_ui::host::TerminalHost; use tb_vm::interp::{RunEvent, Vm}; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("run") => cmd_run(&args[1..]), Some("build") => cmd_build(&args[1..]), Some("check") => cmd_check(&args[1..]), Some("convert-frm") => cmd_convert_frm(&args[1..]), _ => { eprintln!( "Aufruf: tbc run|build|check | tbc convert-frm " ); ExitCode::from(1) } } } fn cmd_convert_frm(args: &[String]) -> ExitCode { if args.len() != 2 { eprintln!("Aufruf: tbc convert-frm "); return ExitCode::from(1); } let input = Path::new(&args[0]); let output = Path::new(&args[1]); let bytes = match std::fs::read(input) { Ok(bytes) => bytes, Err(error) => { eprintln!("{}: {error}", input.display()); return ExitCode::from(1); } }; let converted = match tb_ui::frm::read_binary(&input.display().to_string(), &bytes) { Ok(converted) => converted, Err(error) => { eprintln!("{error}"); return ExitCode::from(1); } }; let text = tb_ui::frm::write_text(&converted.form); if let Err(error) = std::fs::write(output, text) { eprintln!("{}: {error}", output.display()); return ExitCode::from(1); } for warning in &converted.skipped { eprintln!( "{}: Byte 0x{:04x}: {} nicht übernommen", input.display(), warning.offset, warning.name ); } eprintln!( "{}: {} nicht übernommene Binärangaben", input.display(), converted.skipped.len() ); println!("{}", output.display()); ExitCode::SUCCESS } fn module_name(path: &Path) -> String { path.file_stem() .map(|s| s.to_string_lossy().to_uppercase()) .unwrap_or_else(|| "MODUL".into()) } fn relative_case_insensitive(base: &Path, relative: &str) -> std::io::Result { let direct = base.join(relative); if direct.exists() { return Ok(direct); } let wanted = Path::new(relative); let mut current = base.to_path_buf(); for component in wanted.components() { use std::path::Component; match component { Component::CurDir => {} Component::ParentDir => { current.pop(); } Component::Normal(name) => { let entry = std::fs::read_dir(¤t)?.find_map(|entry| { let entry = entry.ok()?; entry .file_name() .to_string_lossy() .eq_ignore_ascii_case(&name.to_string_lossy()) .then(|| entry.path()) }); current = entry.ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, format!("{} nicht gefunden", current.join(name).display()), ) })?; } Component::RootDir | Component::Prefix(_) => current.push(component.as_os_str()), } } Ok(current) } fn include_name(line: &str) -> Option { let trimmed = line.trim(); let upper = trimmed.to_ascii_uppercase(); let at = upper.find("$INCLUDE")?; let rest = trimmed[at + "$INCLUDE".len()..].trim_start(); let rest = rest.strip_prefix(':')?.trim_start(); let rest = rest.strip_prefix('\'')?; Some(rest.split('\'').next().unwrap_or_default().to_string()) } 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 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(), first_line + line_no as u32 )); } let included = relative_case_insensitive(path.parent().unwrap_or(Path::new(".")), &name) .map_err(|e| format!("{}: {e}", path.display()))?; let text = std::fs::read_to_string(&included) .map_err(|e| format!("{}: {e}", included.display()))?; segments.extend(expand_includes(&included, &text, 1, stack)?); } else { 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(segments) } fn read_form(path: &Path) -> Result { let bytes = std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?; 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 form = tb_ui::frm::read_text(&path.display().to_string(), source) .map_err(|error| error.to_string())?; Ok(form) } else { tb_ui::frm::read_binary(&path.display().to_string(), &bytes) .map(|read| read.form) .map_err(|error| error.to_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) .map_err(|error| format!("{}: {error}", path.display()))?; let base = path.parent().unwrap_or(Path::new(".")); project .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('\'')) .map(|line| { relative_case_insensitive(base, line) .map_err(|error| format!("{}: {error}", path.display())) }) .collect::, _>>()? } else { vec![path.to_path_buf()] }; let mut source = Vec::new(); let mut forms = Vec::new(); for member in paths { if member .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("frm")) { let form = read_form(&member)?; 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()))?; source.push(tb_frontend::source::SourceUnit { name: module_name(&member), segments: expand_includes(&member, &text, 1, &mut Vec::new())?, }); } } Ok((source, forms)) } fn compile( path_arg: Option<&String>, ) -> Result<(PathBuf, tb_vm::bytecode::CompiledModule), ExitCode> { let Some(path) = path_arg else { 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) => { eprintln!("{error}"); return Err(ExitCode::from(1)); } }; 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)), Err(diags) => { for d in &diags { eprintln!("{d}"); } eprintln!("{} Fehler.", diags.len()); Err(ExitCode::from(1)) } } } fn cmd_check(args: &[String]) -> ExitCode { match compile(args.first()) { Ok(_) => ExitCode::SUCCESS, Err(code) => code, } } fn cmd_build(args: &[String]) -> ExitCode { let (path, module) = match compile(args.first()) { Ok(x) => x, Err(code) => return code, }; let out = path.with_extension("tbc"); match std::fs::write(&out, module.to_tbc()) { Ok(()) => { println!("{}", out.display()); ExitCode::SUCCESS } Err(e) => { eprintln!("{}: {e}", out.display()); ExitCode::from(1) } } } fn cmd_run(args: &[String]) -> ExitCode { // Ohne Terminal (Pipe, Skript, CI) läuft das Programm im PipeHost: // Eingabe zeilenweise von stdin, Ausgabe am Ende als Snapshot. let result = match TerminalHost::new() { Ok(mut host) => { let size = host.groesse().ok(); let result = run_chain(args, &mut host, size); drop(host); // Alternativschirm verlassen, bevor gedruckt wird result } Err(_) => run_chain(args, &mut PipeHost::new(), None), }; let (ereignis, vm) = match result { Ok(result) => result, Err(code) => return code, }; print!("{}", tb_runtime::snapshot::text(&vm.rt.screen)); // `LPRINT` sammelt im Druckerpuffer; am Programmende geht er in die // Datei LPT1.TXT im aktuellen Verzeichnis (dokumentierte Abweichung — // einen Druckerkanal gibt es plattformübergreifend nicht). if !vm.rt.print.drucker.is_empty() { if let Err(e) = std::fs::write("LPT1.TXT", &vm.rt.print.drucker) { eprintln!("Druckerausgabe nicht schreibbar: {e}"); } } match ereignis { RunEvent::Ended => ExitCode::SUCCESS, RunEvent::Stopped { line } => { // STOP außerhalb der IDE: Meldung + Exit-Code ≠ 0 (D6). eprintln!("{}:{line}: STOP in line {line}", vm.current_file()); ExitCode::from(3) } RunEvent::Error { code, line, message, } => { 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. RunEvent::Interrupted { line } => { eprintln!("Abgebrochen in Zeile {line}"); ExitCode::from(3) } // Ohne Debugger-Flags treten diese Ereignisse nicht auf. RunEvent::Breakpoint { .. } | RunEvent::Stepped { .. } => ExitCode::from(2), RunEvent::Restart { .. } => unreachable!("RUN wird vom Runner aufgelöst"), } } fn run_target(current: &Path, program: &str) -> Result { let base = current.parent().unwrap_or(Path::new(".")); 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", "tbc"] { let candidate = format!("{program}.{extension}"); if let Ok(path) = relative_case_insensitive(base, &candidate) { return Ok(path); } } Err(format!("{program}: Programm nicht gefunden")) } fn run_chain( args: &[String], host: &mut dyn Host, size: Option<(usize, usize)>, ) -> Result<(RunEvent, Vm), ExitCode> { let Some(first) = args.first() else { eprintln!("Aufruf: tbc run "); return Err(ExitCode::from(1)); }; let mut current = PathBuf::from(first); let mut start_line = None; let command = args[1..].join(" "); loop { let current_arg = current.display().to_string(); let (path, module) = compile(Some(¤t_arg))?; let mut vm = Vm::new(module); if let Some(line) = start_line.take() { if let Err(error) = vm.start_at_line(line) { eprintln!("Runtime error {}: {}", error.0, error); return Err(ExitCode::from(2)); } } if let Some((cols, rows)) = size { vm.rt.screen.resize(cols, rows); } vm.rt.command.clone_from(&command); vm.set_poll_interrupt(true); if vm.rt.zeitzone == tb_runtime::datetime::Zeitzone::Unbekannt { eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC."); } let event = vm.run(host); let event = if event == RunEvent::Ended && vm.forms.has_visible_forms() { vm.run_visible_forms(host) } else { event }; match event { RunEvent::Restart { program, line } => { if let Some(program) = program { current = run_target(&path, &program).map_err(|error| { eprintln!("{error}"); ExitCode::from(1) })?; } start_line = line; } event => return Ok((event, vm)), } } } /// Host ohne Terminal: für Pipes und Skripte (`tbc run x.bas < eingabe.txt`). /// Zeigt während des Laufs nichts an; die Ausgabe entsteht am Ende aus dem /// Bildschirm-Snapshot. Tastendrücke kommen zeilenweise von stdin. struct PipeHost { puffer: std::collections::VecDeque, eof: bool, start: std::time::Instant, } impl PipeHost { fn new() -> Self { let mut host = PipeHost { puffer: std::collections::VecDeque::new(), eof: false, start: std::time::Instant::now(), }; use std::io::{IsTerminal, Read}; let mut stdin = std::io::stdin(); if !stdin.is_terminal() { let mut input = String::new(); let _ = stdin.read_to_string(&mut input); for line in input.split_inclusive('\n') { for character in line.trim_end_matches(['\r', '\n']).chars() { host.puffer .push_back(Ereignis::Taste(character.to_string(), 0)); } host.puffer.push_back(Ereignis::Taste( tb_runtime::host::taste::ENTER.to_string(), 0, )); } host.puffer.push_back(Ereignis::Ende); host.eof = true; } host } /// Eine Zeile von stdin in Tastendrücke zerlegen. fn nachfuellen(&mut self) { use std::io::BufRead; if self.eof { return; } let mut zeile = String::new(); match std::io::stdin().lock().read_line(&mut zeile) { Ok(0) | Err(_) => { self.eof = true; self.puffer.push_back(Ereignis::Ende); } Ok(_) => { while zeile.ends_with('\n') || zeile.ends_with('\r') { zeile.pop(); } for c in zeile.chars() { self.puffer.push_back(Ereignis::Taste(c.to_string(), 0)); } self.puffer.push_back(Ereignis::Taste( tb_runtime::host::taste::ENTER.to_string(), 0, )); } } } } impl Host for PipeHost { fn present(&mut self, _screen: &tb_runtime::screen::TextScreen) {} fn next_event(&mut self, blockierend: bool) -> Option { if self.puffer.is_empty() && blockierend { self.nachfuellen(); } self.puffer.pop_front() } fn warten(&mut self, deadline_ms: Option) -> Option { if let Some(event) = self.next_event(false) { return Some(event); } if let Some(deadline) = deadline_ms { std::thread::sleep(std::time::Duration::from_millis( deadline.saturating_sub(self.jetzt_ms()), )); None } else { self.next_event(true).or(Some(Ereignis::Ende)) } } fn jetzt_ms(&mut self) -> u64 { self.start.elapsed().as_millis() as u64 } }