Phase 4: Steuerelemente implementieren
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
//! `tbc` — Standalone-Compiler von Terminal Basic.
|
||||
//!
|
||||
//! Unterbefehle (Phase 2):
|
||||
//! - `tbc run <datei.bas>` Kompilieren und sofort ausführen
|
||||
//! - `tbc build <datei.bas>` Kompilieren zu `datei.tbc`
|
||||
//! - `tbc check <datei.bas>` Nur Syntax-/Semantikprüfung
|
||||
//! - `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};
|
||||
@@ -25,7 +26,7 @@ fn main() -> ExitCode {
|
||||
Some("convert-frm") => cmd_convert_frm(&args[1..]),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"Aufruf: tbc run|build|check <datei.bas> | tbc convert-frm <quelle.frm> <ziel.frm>"
|
||||
"Aufruf: tbc run|build|check <datei.bas|datei.frm|projekt.mak> | tbc convert-frm <quelle.frm> <ziel.frm>"
|
||||
);
|
||||
ExitCode::from(1)
|
||||
}
|
||||
@@ -81,23 +82,230 @@ fn module_name(path: &Path) -> String {
|
||||
.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), ExitCode> {
|
||||
) -> 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>");
|
||||
eprintln!("Aufruf: tbc run|build|check <datei.bas|datei.frm|projekt.mak>");
|
||||
return Err(ExitCode::from(1));
|
||||
};
|
||||
let path = PathBuf::from(path);
|
||||
let source = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("{}: {e}", path.display());
|
||||
let (source, forms) = match input_sources(&path) {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
return Err(ExitCode::from(1));
|
||||
}
|
||||
};
|
||||
match tb_vm::compile_source(&module_name(&path), &source) {
|
||||
Ok(m) => Ok((path, m)),
|
||||
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());
|
||||
@@ -116,7 +324,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,
|
||||
};
|
||||
@@ -134,36 +342,20 @@ fn cmd_build(args: &[String]) -> ExitCode {
|
||||
}
|
||||
|
||||
fn cmd_run(args: &[String]) -> ExitCode {
|
||||
let (_path, module) = match compile(args.first()) {
|
||||
Ok(x) => x,
|
||||
Err(code) => return code,
|
||||
};
|
||||
let mut vm = Vm::new(module);
|
||||
vm.rt.command = args[1..].join(" ");
|
||||
// Im Rohmodus ist Strg+C kein Signal mehr — der Abbruch kommt als
|
||||
// Ereignis und muss den Lauf beenden können.
|
||||
vm.set_poll_interrupt(true);
|
||||
// Der Rückfall auf UTC wird einmal beim Start gemeldet, nicht je
|
||||
// Zeitabfrage — ein stiller Wechsel wäre ein Verstoß gegen den
|
||||
// Guiding Principle.
|
||||
if vm.rt.zeitzone == tb_runtime::datetime::Zeitzone::Unbekannt {
|
||||
eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC.");
|
||||
}
|
||||
|
||||
// Ohne Terminal (Pipe, Skript, CI) läuft das Programm im PipeHost:
|
||||
// Eingabe zeilenweise von stdin, Ausgabe am Ende als Snapshot.
|
||||
let ereignis = match TerminalHost::new() {
|
||||
let result = match TerminalHost::new() {
|
||||
Ok(mut host) => {
|
||||
// Der Bildschirm folgt der Terminalgröße von Beginn an (80×25 ist
|
||||
// stets nur die untere Schranke, nie eine feste Größe).
|
||||
if let Ok((cols, rows)) = host.groesse() {
|
||||
vm.rt.screen.resize(cols, rows);
|
||||
}
|
||||
let e = vm.run(&mut host);
|
||||
let size = host.groesse().ok();
|
||||
let result = run_chain(args, &mut host, size);
|
||||
drop(host); // Alternativschirm verlassen, bevor gedruckt wird
|
||||
e
|
||||
result
|
||||
}
|
||||
Err(_) => vm.run(&mut PipeHost::new()),
|
||||
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
|
||||
@@ -197,6 +389,90 @@ fn cmd_run(args: &[String]) -> ExitCode {
|
||||
}
|
||||
// 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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,11 +487,30 @@ struct PipeHost {
|
||||
|
||||
impl PipeHost {
|
||||
fn new() -> Self {
|
||||
PipeHost {
|
||||
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.
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
//! nur die untere Schranke, nie eine feste Größe, und der Korpus muss das
|
||||
//! nachweisen können.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tb_runtime::host::CaptureHost;
|
||||
use tb_runtime::host::{CaptureHost, Host};
|
||||
use tb_vm::interp::{RunEvent, Vm};
|
||||
|
||||
fn compat_dir() -> PathBuf {
|
||||
@@ -100,10 +101,14 @@ fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> {
|
||||
/// `<F1>`…`<F12>`, sonst je ein Zeichen; `+` davor bedeutet Umschalt,
|
||||
/// `^` Strg, `%` Alt — gebraucht für die benutzerdefinierten Trap-Tasten.
|
||||
fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
|
||||
use tb_runtime::host::{taste, umschalt, Ereignis};
|
||||
let Some(wert) = direktive(src, "tb-keys:") else {
|
||||
return Vec::new();
|
||||
};
|
||||
tastenfolge(name, &wert)
|
||||
}
|
||||
|
||||
fn tastenfolge(name: &str, wert: &str) -> Vec<tb_runtime::host::Ereignis> {
|
||||
use tb_runtime::host::{taste, umschalt, Ereignis};
|
||||
let mut out = Vec::new();
|
||||
let zeichen: Vec<char> = wert.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -143,6 +148,21 @@ fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
|
||||
"LEFT" => 0x4B,
|
||||
"RIGHT" => 0x4D,
|
||||
"DOWN" => 0x50,
|
||||
"ENTER" => {
|
||||
out.push(Ereignis::Taste(taste::ENTER.into(), shift));
|
||||
i += ende + 1;
|
||||
continue;
|
||||
}
|
||||
"ESC" => {
|
||||
out.push(Ereignis::Taste(taste::ESC.into(), shift));
|
||||
i += ende + 1;
|
||||
continue;
|
||||
}
|
||||
"TAB" => {
|
||||
out.push(Ereignis::Taste(taste::TAB.into(), shift));
|
||||
i += ende + 1;
|
||||
continue;
|
||||
}
|
||||
other => panic!("{name}: tb-keys kennt `<{other}>` nicht"),
|
||||
};
|
||||
out.push(Ereignis::Taste(taste::sonder(code), shift));
|
||||
@@ -155,6 +175,98 @@ fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
|
||||
out
|
||||
}
|
||||
|
||||
enum Ereignisschritt {
|
||||
Ereignis(tb_runtime::host::Ereignis),
|
||||
Zeit(u64),
|
||||
}
|
||||
|
||||
fn ereignis_direktive(name: &str, src: &str) -> Vec<Ereignisschritt> {
|
||||
use tb_runtime::host::{Ereignis, MausArt, MausEreignis};
|
||||
let Some(wert) = direktive(src, "tb-events:") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for teil in wert.split('|').map(str::trim) {
|
||||
if let Some(keys) = teil.strip_prefix("key:") {
|
||||
out.extend(
|
||||
tastenfolge(name, keys)
|
||||
.into_iter()
|
||||
.map(Ereignisschritt::Ereignis),
|
||||
);
|
||||
} else if let Some(ms) = teil.strip_prefix("time:") {
|
||||
let ms = ms.trim().strip_suffix("ms").unwrap_or(ms.trim());
|
||||
out.push(Ereignisschritt::Zeit(
|
||||
ms.parse()
|
||||
.unwrap_or_else(|_| panic!("{name}: ungültige Zeit {ms:?}")),
|
||||
));
|
||||
} else if let Some(mouse) = teil.strip_prefix("mouse:") {
|
||||
let fields: Vec<_> = mouse.split(',').map(str::trim).collect();
|
||||
let [kind, button, shift, row, col] = fields.as_slice() else {
|
||||
panic!("{name}: mouse erwartet art,taste,shift,zeile,spalte");
|
||||
};
|
||||
let art = match *kind {
|
||||
"down" => MausArt::Druck,
|
||||
"up" => MausArt::Loslassen,
|
||||
"move" => MausArt::Bewegung,
|
||||
_ => panic!("{name}: unbekannte Mausart {kind:?}"),
|
||||
};
|
||||
let parse = |value: &str| {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.unwrap_or_else(|_| panic!("{name}: ungültige Zahl {value:?}"))
|
||||
};
|
||||
out.push(Ereignisschritt::Ereignis(Ereignis::Maus(MausEreignis {
|
||||
art,
|
||||
taste: parse(button) as u8,
|
||||
shift: parse(shift) as u8,
|
||||
zeile: parse(row),
|
||||
spalte: parse(col),
|
||||
})));
|
||||
} else {
|
||||
panic!("{name}: unbekannter tb-events-Schritt {teil:?}");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct EreignisHost {
|
||||
inner: CaptureHost,
|
||||
schritte: VecDeque<Ereignisschritt>,
|
||||
pause: bool,
|
||||
}
|
||||
|
||||
impl tb_runtime::host::Host for EreignisHost {
|
||||
fn present(&mut self, screen: &tb_runtime::screen::TextScreen) {
|
||||
self.inner.present(screen);
|
||||
}
|
||||
|
||||
fn next_event(&mut self, blockierend: bool) -> Option<tb_runtime::host::Ereignis> {
|
||||
if !blockierend && self.pause {
|
||||
self.pause = false;
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
match self.schritte.pop_front() {
|
||||
Some(Ereignisschritt::Zeit(ms)) => {
|
||||
self.inner.uhr_vorruecken(ms);
|
||||
if !blockierend {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Ereignisschritt::Ereignis(event)) => {
|
||||
self.pause = true;
|
||||
return Some(event);
|
||||
}
|
||||
None => return self.inner.next_event(blockierend),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn jetzt_ms(&mut self) -> u64 {
|
||||
self.inner.jetzt_ms()
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtuelle Uhr: `' tb-clock: <n>ms/Zustellpunkt`. Ohne die Direktive
|
||||
/// steht die Uhr des Testhosts still — ein Programm mit Zeit-Traps liefe
|
||||
/// dann endlos. Der Harness weist es deshalb ab, statt auf die Systemuhr
|
||||
@@ -242,45 +354,77 @@ impl Drop for TempVerzeichnis {
|
||||
|
||||
/// Korpusdatei ausführen und den Bildschirm-Snapshot liefern.
|
||||
fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String {
|
||||
let src = std::fs::read_to_string(path).unwrap();
|
||||
let raw = std::fs::read_to_string(path).unwrap();
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let modul_name = path.file_stem().unwrap().to_string_lossy().to_uppercase();
|
||||
let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, &src)[0]);
|
||||
let module = tb_vm::compile_source(&modul_name, &src)
|
||||
.unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
|
||||
let form = if path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
|
||||
{
|
||||
Some(tb_ui::frm::read_text(&name, &raw).unwrap_or_else(|error| panic!("{error}")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let src = form
|
||||
.as_ref()
|
||||
.map_or(raw.as_str(), |form| form.code.as_str());
|
||||
let modul_name = form.as_ref().map_or_else(
|
||||
|| path.file_stem().unwrap().to_string_lossy().to_uppercase(),
|
||||
|form| form.root.name.to_uppercase(),
|
||||
);
|
||||
let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, src)[0]);
|
||||
let module = match &form {
|
||||
Some(form) => tb_vm::compile_source_with_forms(&modul_name, src, &form.catalog()),
|
||||
None => tb_vm::compile_source(&modul_name, src),
|
||||
}
|
||||
.unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
|
||||
let mut vm = Vm::new(module);
|
||||
if let Some(form) = &form {
|
||||
form.apply(&mut vm.forms).unwrap();
|
||||
vm.forms.show(0, false).unwrap();
|
||||
}
|
||||
vm.rt.screen.resize(cols, rows);
|
||||
if let Some(z) = tz_direktive(&name, &src) {
|
||||
if let Some(z) = tz_direktive(&name, src) {
|
||||
vm.rt.zeitzone = z;
|
||||
}
|
||||
// `' tb-tempdir` — das Programm arbeitet mit Dateien.
|
||||
let _temp = direktive(&src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name));
|
||||
let _temp = direktive(src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name));
|
||||
let mut host = CaptureHost::default();
|
||||
if let Some((nach, (c, r))) = resize_direktive(&name, &src) {
|
||||
if let Some((nach, (c, r))) = resize_direktive(&name, src) {
|
||||
host.ereignis_nach(
|
||||
nach,
|
||||
tb_runtime::host::Ereignis::Groesse { cols: c, rows: r },
|
||||
);
|
||||
}
|
||||
for t in tasten_direktive(&name, &src) {
|
||||
for t in tasten_direktive(&name, src) {
|
||||
host.ereignis(t);
|
||||
}
|
||||
let schritt = clock_schritt(&name, &src);
|
||||
let schritt = clock_schritt(&name, src);
|
||||
let schritte = ereignis_direktive(&name, src);
|
||||
if !schritte.is_empty() {
|
||||
assert!(
|
||||
schritt.is_none(),
|
||||
"{name}: tb-events: time und tb-clock nicht mischen"
|
||||
);
|
||||
let mut host = EreignisHost {
|
||||
inner: host,
|
||||
schritte: schritte.into(),
|
||||
pause: false,
|
||||
};
|
||||
return run_to_snapshot(path, &mut vm, &mut host);
|
||||
}
|
||||
if let Some(schritt_ms) = schritt {
|
||||
let mut host = UhrHost {
|
||||
inner: host,
|
||||
schritt_ms,
|
||||
};
|
||||
return match vm.run(&mut host) {
|
||||
RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
|
||||
other => panic!(
|
||||
"{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
|
||||
path.display(),
|
||||
tb_runtime::snapshot::snapshot(&vm.rt.screen)
|
||||
),
|
||||
};
|
||||
return run_to_snapshot(path, &mut vm, &mut host);
|
||||
}
|
||||
match vm.run(&mut host) {
|
||||
run_to_snapshot(path, &mut vm, &mut host)
|
||||
}
|
||||
|
||||
fn run_to_snapshot(path: &Path, vm: &mut Vm, host: &mut dyn Host) -> String {
|
||||
match vm.run(host) {
|
||||
RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
|
||||
other => panic!(
|
||||
"{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
|
||||
@@ -290,6 +434,21 @@ fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn source_for_directives(path: &Path) -> String {
|
||||
let raw = std::fs::read_to_string(path).unwrap();
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
|
||||
{
|
||||
tb_ui::frm::read_text(&path.display().to_string(), &raw)
|
||||
.unwrap()
|
||||
.code
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshots vergleichen: erst das Textbild, dann die Attributebene.
|
||||
/// Bei Abweichung im Text zählt die Zeile, bei Attributen Zeile und Spalte.
|
||||
fn assert_output_matches(file: &str, want: &str, got: &str) {
|
||||
@@ -365,12 +524,16 @@ fn korpus_laeuft_mit_korrekter_ausgabe() {
|
||||
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.expect("tests/compat fehlt")
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("bas"))
|
||||
.filter(|p| {
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| matches!(ext, "bas" | "frm"))
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let src = std::fs::read_to_string(&path).unwrap();
|
||||
let src = source_for_directives(&path);
|
||||
let groessen = screen_groessen(&name, &src);
|
||||
for (cols, rows) in &groessen {
|
||||
// Bei mehreren Größen trägt jede ihre eigene Sollausgabe.
|
||||
@@ -406,6 +569,25 @@ fn uhr_direktive_wird_gelesen() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ereignisfolge_liefert_taste_maus_und_zeit_in_reihenfolge() {
|
||||
let steps = ereignis_direktive(
|
||||
"form.frm",
|
||||
"' tb-events: key:%o | mouse:down,1,0,5,7 | time:250ms | key:<ENTER>",
|
||||
);
|
||||
assert_eq!(steps.len(), 4);
|
||||
assert!(
|
||||
matches!(&steps[0], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, shift)) if key == "o" && *shift == 4)
|
||||
);
|
||||
assert!(
|
||||
matches!(&steps[1], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Maus(mouse)) if mouse.zeile == 5 && mouse.spalte == 7)
|
||||
);
|
||||
assert!(matches!(steps[2], Ereignisschritt::Zeit(250)));
|
||||
assert!(
|
||||
matches!(&steps[3], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, _)) if key == tb_runtime::host::taste::ENTER)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "deklariert aber keinen Zeitverlauf")]
|
||||
fn zeittrap_ohne_uhr_direktive_wird_abgewiesen() {
|
||||
@@ -467,6 +649,40 @@ fn tbc_build_erzeugt_tbc() {
|
||||
let _ = std::fs::remove_file(&tbc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tbc_loest_include_projekt_und_run_datei_auf() {
|
||||
let exe = env!("CARGO_BIN_EXE_tbc");
|
||||
let dir = std::env::temp_dir().join(format!("tb_project_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("werte.bi"), "CONST N = 7\n").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("main.bas"),
|
||||
"'$INCLUDE: 'WERTE.BI'\nDECLARE SUB Ausgabe\nAusgabe\nEND\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("lib.bas"), "SUB Ausgabe\nPRINT N\nEND SUB\n").unwrap();
|
||||
std::fs::write(dir.join("app.mak"), "MAIN.BAS\nLIB.BAS\n").unwrap();
|
||||
let project = Command::new(exe)
|
||||
.args(["run"])
|
||||
.arg(dir.join("app.mak"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(project.status.success(), "{project:?}");
|
||||
assert_eq!(String::from_utf8_lossy(&project.stdout), " 7 \n");
|
||||
|
||||
std::fs::write(dir.join("start.bas"), "RUN \"ZIEL\"\n").unwrap();
|
||||
std::fs::write(dir.join("ziel.bas"), "PRINT \"weiter\"\nEND\n").unwrap();
|
||||
let chained = Command::new(exe)
|
||||
.args(["run"])
|
||||
.arg(dir.join("start.bas"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(chained.status.success(), "{chained:?}");
|
||||
assert_eq!(String::from_utf8_lossy(&chained.stdout), "weiter\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---- Harness meldet Abweichungen benannt (Spec kompat-testkorpus) ----------
|
||||
|
||||
fn meldung(want: &str, got: &str) -> String {
|
||||
@@ -537,12 +753,16 @@ fn erzeuge_sollausgaben() {
|
||||
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.expect("tests/compat fehlt")
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("bas"))
|
||||
.filter(|p| {
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| matches!(ext, "bas" | "frm"))
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let src = std::fs::read_to_string(&path).unwrap();
|
||||
let src = source_for_directives(&path);
|
||||
let groessen = screen_groessen(&name, &src);
|
||||
for (cols, rows) in &groessen {
|
||||
let out_path = if groessen.len() == 1 {
|
||||
|
||||
Reference in New Issue
Block a user