572 lines
20 KiB
Rust
572 lines
20 KiB
Rust
//! `tbc` — Standalone-Compiler von Terminal Basic.
|
|
//!
|
|
//! Unterbefehle (Phase 2):
|
|
//! - `tbc run <datei.bas|datei.frm|projekt.mak>` Kompilieren und ausführen
|
|
//! - `tbc build <datei.bas|datei.frm|projekt.mak>` Zu `.tbc` kompilieren
|
|
//! - `tbc check <datei.bas|datei.frm|projekt.mak>` Syntax/Semantik prüfen
|
|
//! - `tbc convert-frm <quelle.frm> <ziel.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::collections::HashSet;
|
|
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<String> = 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 <datei.bas|datei.frm|projekt.mak> | tbc convert-frm <quelle.frm> <ziel.frm>"
|
|
);
|
|
ExitCode::from(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_convert_frm(args: &[String]) -> ExitCode {
|
|
if args.len() != 2 {
|
|
eprintln!("Aufruf: tbc convert-frm <quelle.frm> <ziel.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<PathBuf> {
|
|
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<String> {
|
|
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, stack: &mut Vec<PathBuf>) -> Result<String, 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') {
|
|
if let Some(name) = include_name(line) {
|
|
if name.is_empty() {
|
|
return Err(format!("{}: leeres $INCLUDE", path.display()));
|
|
}
|
|
let included =
|
|
relative_case_insensitive(path.parent().unwrap_or(Path::new(".")), &name)
|
|
.map_err(|error| format!("{}: {error}", 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');
|
|
}
|
|
} else {
|
|
out.push_str(line);
|
|
}
|
|
}
|
|
stack.pop();
|
|
Ok(out)
|
|
}
|
|
|
|
fn read_form(path: &Path) -> Result<tb_ui::frm::FormFile, String> {
|
|
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 mut 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)
|
|
.map(|read| read.form)
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ProjectSymbols {
|
|
constants: HashSet<String>,
|
|
types: HashSet<String>,
|
|
}
|
|
|
|
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::<Vec<_>>();
|
|
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<tb_ui::frm::FormFile>), 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())
|
|
.map(|line| {
|
|
relative_case_insensitive(base, line)
|
|
.map_err(|error| format!("{}: {error}", path.display()))
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
} else {
|
|
vec![path.to_path_buf()]
|
|
};
|
|
let mut source = String::new();
|
|
let mut forms = Vec::new();
|
|
let mut symbols = ProjectSymbols::default();
|
|
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)?;
|
|
append_project_source(&mut source, &form.code, &mut symbols);
|
|
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);
|
|
}
|
|
}
|
|
Ok((source, forms))
|
|
}
|
|
|
|
fn compile(
|
|
path_arg: Option<&String>,
|
|
) -> Result<
|
|
(
|
|
PathBuf,
|
|
tb_vm::bytecode::CompiledModule,
|
|
Vec<tb_ui::frm::FormFile>,
|
|
),
|
|
ExitCode,
|
|
> {
|
|
let Some(path) = path_arg else {
|
|
eprintln!("Aufruf: tbc run|build|check <datei.bas|datei.frm|projekt.mak>");
|
|
return Err(ExitCode::from(1));
|
|
};
|
|
let path = PathBuf::from(path);
|
|
let (source, forms) = match input_sources(&path) {
|
|
Ok(input) => input,
|
|
Err(error) => {
|
|
eprintln!("{error}");
|
|
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)
|
|
};
|
|
match compiled {
|
|
Ok(m) => Ok((path, m, forms)),
|
|
Err(diags) => {
|
|
for d in &diags {
|
|
eprintln!("{}:{d}", path.display());
|
|
}
|
|
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!("STOP in line {line}");
|
|
ExitCode::from(3)
|
|
}
|
|
RunEvent::Error {
|
|
code,
|
|
line,
|
|
message,
|
|
} => {
|
|
eprintln!("Runtime error {code}: {message} in line {line}");
|
|
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<PathBuf, String> {
|
|
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"] {
|
|
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 <datei.bas|datei.frm|projekt.mak>");
|
|
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, forms) = 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);
|
|
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<Ereignis>,
|
|
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<Ereignis> {
|
|
if self.puffer.is_empty() && blockierend {
|
|
self.nachfuellen();
|
|
}
|
|
self.puffer.pop_front()
|
|
}
|
|
|
|
fn warten(&mut self, deadline_ms: Option<u64>) -> Option<Ereignis> {
|
|
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
|
|
}
|
|
}
|