Phase 3 (Kern): Laufzeitbibliothek, Bildschirm und Datei-E/A
Setzt den OpenSpec-Change phase-3-runtime-bildschirm um (77/77 Aufgaben). Abdeckung laut Inventar: 195 implementiert, 37 offen, 53 Non-Feature. Vollstaendigkeits-Inventar - docs/inventar.md mit 285 Eintraegen aus den Themenlisten von bas7advr.hlp und qb45advr.hlp, je mit Status und Fundstelle - crates/tb-frontend/tests/inventar.rs haelt die Tabelle in beide Richtungen gegen den Code; der Abdeckungsstand kann nicht veralten Bruchschritt (Puffer, Host, Korpus) - Cell/TextScreen ziehen ratatui-frei nach tb-runtime::screen; tb-ui behaelt Farbabbildung, Widget und den neuen Terminal-Host - Host wechselt vom Zeichenstrom auf Anzeige des Zellenpuffers plus Ereignisse (Taste, Groessenaenderung, Abbruch) - Korpusvergleich auf getrimmten Bildschirm-Snapshot; die acht bestehenden Sollausgaben blieben dabei unveraendert Groessenunabhaengigkeit - 80x25 ist nirgends mehr eine feste Grenze; Groessenaenderungen waehrend der Ausfuehrung werden zugestellt (Inhalt oben links erhalten, Cursor und VIEW PRINT geklemmt) - Korpusnachweis: dasselbe Programm bei 80x25 und 120x40 mit je eigener Sollausgabe, plus ein Programm mit Groessenwechsel mitten im Lauf Bibliothek - Breite Unicode-Zeichen belegen zwei Zellen (Cursor, POS, Randumbruch) - Bildschirm: CLS, COLOR, LOCATE, WIDTH, VIEW PRINT, SCREEN, CSRLIN, POS - Tastatur: INKEY$, INPUT$, Funktionstasten-Makros (KEY n / LIST / ON) - PRINT USING, LPRINT USING, FORMAT$, SetFormatCC - Mathematik mit kompatiblem PRNG (gleiche Saat, gleiche Folge) - Datum und Zeit mit Serienwerten ab 1899 - Finanzmathematik: FV#, PV#, Pmt#, IPmt#, PPmt#, NPer#, Rate#, NPV#, IRR#, MIRR#, SLN#, SYD#, DDB# - Datei-E/A: sequenziell, RANDOM (Recordpuffer und UDT-Variablen), BINARY, FIELD/LSET/RSET, Statusfunktionen, Dateisystem, MK$/CV - System: ENVIRON, FRE, CLEAR, TRON/TROFF, STACK, ERDEV, ERR-Anweisung Altlasten aus Phase 2 - ON ERROR GOTO auf Modulebene ist aus Prozeduren ansprechbar (prozeduruebergreifender Fixup im Codegenerator) - DATA behaelt seinen Rohtext (Gross-/Kleinschreibung, innerer Leerraum) - Die vier TODO-verify-Zellen der Konvertierungsmatrix sind aufgeloest Vom Inventar aufgedeckte Fehler - Zwoelf Non-Features wies der Compiler entgegen der Phase-1-Spec nicht ab (CALLS, SSEG, POINT, VIEW, COM, PEN, STRIG, STICK, die String*-Routinen, LINE und OPEN "COMn:") -- geschlossen - Drei Gruppen fehlten im urspruenglichen Umfang: Finanzmathematik, Record-Konvertierung, Rest der Dateisystemfunktionen - bas7advr.hlp allein ist keine vollstaendige Quelle; das Inventar bildet die Vereinigung mit qb45advr.hlp Neue Changes - phase-3-isam: schliesst Phase 3 ab (Speicherschicht redb) - phase-3-ortszeit: zieht die UTC-Abweichung zurueck Neue Abhaengigkeit: unicode-width. Dokumentiert: sprachreferenz.md und tbvm-design.md sind TODO-frei, docs/bibliothek.md neu, PLAN.md fortgeschrieben. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,8 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use tb_runtime::host::ConsoleHost;
|
||||
use tb_runtime::host::{Ereignis, Host};
|
||||
use tb_ui::host::TerminalHost;
|
||||
use tb_vm::interp::{RunEvent, Vm};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -90,8 +91,36 @@ fn cmd_run(args: &[String]) -> ExitCode {
|
||||
};
|
||||
let mut vm = Vm::new(module);
|
||||
vm.rt.command = args[1..].join(" ");
|
||||
let mut host = ConsoleHost;
|
||||
match vm.run(&mut host) {
|
||||
// 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);
|
||||
|
||||
// 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() {
|
||||
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);
|
||||
drop(host); // Alternativschirm verlassen, bevor gedruckt wird
|
||||
e
|
||||
}
|
||||
Err(_) => vm.run(&mut PipeHost::new()),
|
||||
};
|
||||
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).
|
||||
@@ -103,8 +132,61 @@ fn cmd_run(args: &[String]) -> ExitCode {
|
||||
ExitCode::from(2)
|
||||
}
|
||||
// Ohne Debugger-Flags treten diese Ereignisse nicht auf.
|
||||
RunEvent::Breakpoint { .. } | RunEvent::Stepped { .. } | RunEvent::Interrupted { .. } => {
|
||||
ExitCode::from(2)
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl PipeHost {
|
||||
fn new() -> Self {
|
||||
PipeHost { puffer: std::collections::VecDeque::new(), eof: false }
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
}
|
||||
self.puffer
|
||||
.push_back(Ereignis::Taste(tb_runtime::host::taste::ENTER.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
//! Kompatibilitäts-Harness (Phase-2-Meilenstein): jede Korpusdatei
|
||||
//! `tests/compat/*.bas` wird kompiliert, im Capture-Host ausgeführt und
|
||||
//! byte-genau gegen ihre `.out` verglichen. Bei Abweichung nennt der
|
||||
//! Test Datei, erste abweichende Zeile sowie Soll und Ist.
|
||||
//! Kompatibilitäts-Harness: jede Korpusdatei `tests/compat/*.bas` wird
|
||||
//! kompiliert, ohne Terminal ausgeführt und ihr Bildschirmzustand gegen die
|
||||
//! `.out` verglichen — getrimmt auf die belegte Fläche, mit Attributebene nur
|
||||
//! bei gesetzten Farben (Entscheidung 2026-09-03, D3).
|
||||
//!
|
||||
//! Jede Korpusdatei nennt ihre Bildschirmgröße in der ersten Zeile:
|
||||
//!
|
||||
//! ```text
|
||||
//! ' tb-screen: 80x25
|
||||
//! ```
|
||||
//!
|
||||
//! Es gibt bewusst keinen stillschweigenden Vorgabewert — 80×25 ist überall
|
||||
//! nur die untere Schranke, nie eine feste Größe, und der Korpus muss das
|
||||
//! nachweisen können.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -12,43 +22,185 @@ fn compat_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat")
|
||||
}
|
||||
|
||||
fn run_corpus_file(path: &Path) -> String {
|
||||
/// `<cols>x<rows>` lesen.
|
||||
fn groesse_lesen(name: &str, wert: &str) -> (usize, usize) {
|
||||
let (c, r) = wert
|
||||
.trim()
|
||||
.split_once(['x', 'X'])
|
||||
.unwrap_or_else(|| panic!("{name}: Größe {wert:?} nicht als <spalten>x<zeilen> lesbar"));
|
||||
let parse = |s: &str, was: &str| {
|
||||
s.trim()
|
||||
.parse::<usize>()
|
||||
.unwrap_or_else(|_| panic!("{name}: {was} {s:?} ist keine Zahl"))
|
||||
};
|
||||
(parse(c, "Spaltenzahl"), parse(r, "Zeilenzahl"))
|
||||
}
|
||||
|
||||
/// Wert einer Kopfzeilen-Direktive `' <marke>: <wert>` aus den ersten Zeilen.
|
||||
fn direktive(src: &str, marke: &str) -> Option<String> {
|
||||
src.lines()
|
||||
.take(4)
|
||||
.find(|l| l.contains(marke))
|
||||
.map(|l| l.split(marke).nth(1).unwrap().trim().to_string())
|
||||
}
|
||||
|
||||
/// Bildschirmgrößen aus der Kopfzeile `' tb-screen: <cols>x<rows>[, …]`.
|
||||
/// Fehlt sie, schlägt der Test fehl — ohne Vorgabewert. Mehrere Größen
|
||||
/// bedeuten: das Programm läuft je Größe einmal, mit eigener Sollausgabe.
|
||||
fn screen_groessen(name: &str, src: &str) -> Vec<(usize, usize)> {
|
||||
let wert = direktive(src, "tb-screen:").unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{name}: keine Bildschirmgröße deklariert — erste Zeile braucht \
|
||||
`\' tb-screen: <spalten>x<zeilen>`"
|
||||
)
|
||||
});
|
||||
wert.split(',').map(|w| groesse_lesen(name, w)).collect()
|
||||
}
|
||||
|
||||
/// Optionale Größenänderung mitten im Lauf:
|
||||
/// `' tb-resize: nach <n> auf <cols>x<rows>`.
|
||||
fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> {
|
||||
let wert = direktive(src, "tb-resize:")?;
|
||||
let teile: Vec<&str> = wert.split_whitespace().collect();
|
||||
match teile.as_slice() {
|
||||
["nach", n, "auf", g] => Some((
|
||||
n.parse()
|
||||
.unwrap_or_else(|_| panic!("{name}: {n:?} ist keine Zahl")),
|
||||
groesse_lesen(name, g),
|
||||
)),
|
||||
_ => panic!("{name}: tb-resize erwartet `nach <n> auf <spalten>x<zeilen>`, fand {wert:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Arbeitsverzeichnis für die Dauer eines Korpusprogramms. Programme mit
|
||||
/// Datei-E/A laufen in einem frischen temporären Verzeichnis und hinterlassen
|
||||
/// nichts im Projektbaum; beim Verlassen wird es samt Inhalt entfernt.
|
||||
struct TempVerzeichnis {
|
||||
vorher: PathBuf,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TempVerzeichnis {
|
||||
fn neu(name: &str) -> TempVerzeichnis {
|
||||
let vorher = std::env::current_dir().unwrap();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"tb_korpus_{}_{}",
|
||||
name.replace('.', "_"),
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_current_dir(&dir).unwrap();
|
||||
TempVerzeichnis { vorher, dir }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempVerzeichnis {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::env::set_current_dir(&self.vorher);
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 name = path.file_stem().unwrap().to_string_lossy().to_uppercase();
|
||||
let module = tb_vm::compile_source(&name, &src)
|
||||
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 mut vm = Vm::new(module);
|
||||
vm.rt.screen.resize(cols, rows);
|
||||
// `' tb-tempdir` — das Programm arbeitet mit Dateien.
|
||||
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) {
|
||||
host.ereignis_nach(
|
||||
nach,
|
||||
tb_runtime::host::Ereignis::Groesse { cols: c, rows: r },
|
||||
);
|
||||
}
|
||||
match vm.run(&mut host) {
|
||||
RunEvent::Ended => host.output,
|
||||
RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
|
||||
other => panic!(
|
||||
"{}: unerwartetes Laufzeitende {other:?}\nAusgabe bisher:\n{}",
|
||||
"{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
|
||||
path.display(),
|
||||
host.output
|
||||
tb_runtime::snapshot::snapshot(&vm.rt.screen)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Erste abweichende Zeile melden (byte-genau, inkl. Leerzeichen am Ende).
|
||||
/// 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) {
|
||||
if want == got {
|
||||
return;
|
||||
}
|
||||
let want_lines: Vec<&str> = want.split('\n').collect();
|
||||
let got_lines: Vec<&str> = got.split('\n').collect();
|
||||
for (i, (w, g)) in want_lines.iter().zip(got_lines.iter()).enumerate() {
|
||||
if w != g {
|
||||
panic!(
|
||||
"{file}: Abweichung in Zeile {}:\n Soll: {w:?}\n Ist: {g:?}",
|
||||
i + 1
|
||||
);
|
||||
let marke = tb_runtime::snapshot::ATTRIBUT_MARKE;
|
||||
let teile = |s: &str| match s.split_once(&format!("{marke}\n")) {
|
||||
Some((t, a)) => (t.to_string(), Some(a.to_string())),
|
||||
None => (s.to_string(), None),
|
||||
};
|
||||
let (want_text, want_attr) = teile(want);
|
||||
let (got_text, got_attr) = teile(got);
|
||||
|
||||
if want_text != got_text {
|
||||
// `lines()` statt `split('\n')`: Snapshots enden je Zeile mit `\n`,
|
||||
// sonst zählt der leere Rest als zusätzliche Zeile und die
|
||||
// Zeilenanzahl-Meldung wäre nie erreichbar.
|
||||
let w: Vec<&str> = want_text.lines().collect();
|
||||
let g: Vec<&str> = got_text.lines().collect();
|
||||
for (i, (a, b)) in w.iter().zip(g.iter()).enumerate() {
|
||||
if a != b {
|
||||
panic!(
|
||||
"{file}: Abweichung in Zeile {}:\n Soll: {a:?}\n Ist: {b:?}",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
panic!(
|
||||
"{file}: Zeilenanzahl weicht ab (Soll {} / Ist {}).\nSoll:\n{want_text}\nIst:\n{got_text}",
|
||||
w.len(),
|
||||
g.len()
|
||||
);
|
||||
}
|
||||
|
||||
match (want_attr, got_attr) {
|
||||
(Some(w), None) => panic!("{file}: Attributebene fehlt in der Ausgabe.\nSoll:\n{w}"),
|
||||
(None, Some(g)) => panic!(
|
||||
"{file}: unerwartete Attributebene — das Programm setzt Farben, \
|
||||
die Sollausgabe kennt keine.\nIst:\n{g}"
|
||||
),
|
||||
(Some(w), Some(g)) => {
|
||||
for (z, (wz, gz)) in w.lines().zip(g.lines()).enumerate() {
|
||||
if wz != gz {
|
||||
// Je Zelle zwei Hexziffern → Spalte aus dem Byteoffset.
|
||||
let spalte = wz
|
||||
.chars()
|
||||
.zip(gz.chars())
|
||||
.position(|(a, b)| a != b)
|
||||
.map(|i| i / 2 + 1)
|
||||
.unwrap_or(0);
|
||||
let paar = |s: &str| {
|
||||
s.chars()
|
||||
.skip((spalte - 1) * 2)
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
};
|
||||
panic!(
|
||||
"{file}: Farbabweichung in Zeile {}, Spalte {spalte}:\n \
|
||||
Soll: {}\n Ist: {}",
|
||||
z + 1,
|
||||
paar(wz),
|
||||
paar(gz)
|
||||
);
|
||||
}
|
||||
}
|
||||
panic!("{file}: Attributebenen unterschiedlich lang.\nSoll:\n{w}\nIst:\n{g}");
|
||||
}
|
||||
(None, None) => unreachable!("Snapshots gleich, aber Vergleich schlug fehl"),
|
||||
}
|
||||
panic!(
|
||||
"{file}: Zeilenanzahl weicht ab (Soll {} / Ist {}).\nSoll:\n{want}\nIst:\n{got}",
|
||||
want_lines.len(),
|
||||
got_lines.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -63,15 +215,25 @@ fn korpus_laeuft_mit_korrekter_ausgabe() {
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let out_path = path.with_extension("out");
|
||||
let want = std::fs::read_to_string(&out_path)
|
||||
.unwrap_or_else(|_| panic!("{name}: Sollausgabe {} fehlt", out_path.display()));
|
||||
// .out-Dateien sind LF-normiert (.gitattributes); zur Sicherheit
|
||||
// CRLF des Checkouts entfernen.
|
||||
let want = want.replace("\r\n", "\n");
|
||||
let got = run_corpus_file(&path);
|
||||
assert_output_matches(&name, &want, &got);
|
||||
checked += 1;
|
||||
let src = std::fs::read_to_string(&path).unwrap();
|
||||
let groessen = screen_groessen(&name, &src);
|
||||
for (cols, rows) in &groessen {
|
||||
// Bei mehreren Größen trägt jede ihre eigene Sollausgabe.
|
||||
let out_path = if groessen.len() == 1 {
|
||||
path.with_extension("out")
|
||||
} else {
|
||||
path.with_extension(format!("{cols}x{rows}.out"))
|
||||
};
|
||||
let want = std::fs::read_to_string(&out_path)
|
||||
.unwrap_or_else(|_| panic!("{name}: Sollausgabe {} fehlt", out_path.display()));
|
||||
// .out-Dateien sind LF-normiert (.gitattributes); zur Sicherheit
|
||||
// CRLF des Checkouts entfernen.
|
||||
let want = want.replace("\r\n", "\n");
|
||||
let got = run_corpus_file(&path, Some((*cols, *rows)));
|
||||
let etikett = format!("{name} @ {cols}x{rows}");
|
||||
assert_output_matches(&etikett, &want, &got);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked >= 5, "zu wenige Korpusdateien gefunden: {checked}");
|
||||
}
|
||||
@@ -130,3 +292,96 @@ fn tbc_build_erzeugt_tbc() {
|
||||
let _ = std::fs::remove_file(&f);
|
||||
let _ = std::fs::remove_file(&tbc);
|
||||
}
|
||||
|
||||
// ---- Harness meldet Abweichungen benannt (Spec kompat-testkorpus) ----------
|
||||
|
||||
fn meldung(want: &str, got: &str) -> String {
|
||||
let r = std::panic::catch_unwind(|| assert_output_matches("probe.bas", want, got));
|
||||
let e = r.expect_err("erwartete eine Abweichung");
|
||||
e.downcast_ref::<String>()
|
||||
.cloned()
|
||||
.or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textabweichung_nennt_zeile_soll_und_ist() {
|
||||
let m = meldung("a\nb\n", "a\nX\n");
|
||||
assert!(m.contains("Zeile 2"), "{m}");
|
||||
assert!(m.contains("\"b\""), "{m}");
|
||||
assert!(m.contains("\"X\""), "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeilenanzahl_wird_benannt() {
|
||||
let m = meldung("a\nb\n", "a\n");
|
||||
assert!(m.contains("Zeilenanzahl"), "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn farbabweichung_nennt_zeile_spalte_soll_und_ist() {
|
||||
let marke = tb_runtime::snapshot::ATTRIBUT_MARKE;
|
||||
let want = format!("ab\n{marke}\n7070\n");
|
||||
let got = format!("ab\n{marke}\n70e1\n");
|
||||
let m = meldung(&want, &got);
|
||||
assert!(m.contains("Farbabweichung"), "{m}");
|
||||
assert!(m.contains("Zeile 1"), "{m}");
|
||||
assert!(m.contains("Spalte 2"), "{m}");
|
||||
assert!(m.contains("70") && m.contains("e1"), "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fehlende_und_unerwartete_attributebene_werden_benannt() {
|
||||
let marke = tb_runtime::snapshot::ATTRIBUT_MARKE;
|
||||
let mit = format!("ab\n{marke}\n7070\n");
|
||||
assert!(meldung(&mit, "ab\n").contains("Attributebene fehlt"));
|
||||
assert!(meldung("ab\n", &mit).contains("unerwartete Attributebene"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fehlende_groessenangabe_wird_abgewiesen() {
|
||||
let dir = std::env::temp_dir().join("tb_korpus_ohne_groesse");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("ohne.bas");
|
||||
std::fs::write(&f, "PRINT 1\n").unwrap();
|
||||
let r = std::panic::catch_unwind(|| run_corpus_file(&f, None));
|
||||
let e = r.expect_err("Datei ohne Größenangabe muss abgewiesen werden");
|
||||
let m = e
|
||||
.downcast_ref::<String>()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(m.contains("keine Bildschirmgröße deklariert"), "{m}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Sollausgaben neu erzeugen (nur auf Anforderung):
|
||||
/// `cargo test -p tb-cli --test compat -- --ignored erzeuge_sollausgaben`
|
||||
///
|
||||
/// Die erzeugten Dateien sind zu prüfen, bevor sie eingecheckt werden — ein
|
||||
/// unerwarteter Diff ist ein Befund, kein Anpassungsgrund.
|
||||
#[test]
|
||||
#[ignore = "erzeugt Golden Files; nur bewusst aufrufen"]
|
||||
fn erzeuge_sollausgaben() {
|
||||
let dir = compat_dir();
|
||||
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"))
|
||||
.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 groessen = screen_groessen(&name, &src);
|
||||
for (cols, rows) in &groessen {
|
||||
let out_path = if groessen.len() == 1 {
|
||||
path.with_extension("out")
|
||||
} else {
|
||||
path.with_extension(format!("{cols}x{rows}.out"))
|
||||
};
|
||||
let got = run_corpus_file(&path, Some((*cols, *rows)));
|
||||
std::fs::write(&out_path, &got).unwrap();
|
||||
println!("geschrieben: {}", out_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,10 +235,97 @@ pub enum Builtin {
|
||||
PrintTab,
|
||||
PrintSpc,
|
||||
PrintNewline,
|
||||
PrintUsing,
|
||||
FormatS,
|
||||
SetFormatCc,
|
||||
// Bildschirm (Anweisungen)
|
||||
Cls,
|
||||
Color,
|
||||
Locate,
|
||||
Width,
|
||||
ViewPrint,
|
||||
ScreenStmt,
|
||||
KeyAssign,
|
||||
KeyList,
|
||||
KeyDisplay,
|
||||
// Bildschirm (Funktionen)
|
||||
Csrlin,
|
||||
PosFn,
|
||||
ScreenFn,
|
||||
// Tastatur
|
||||
InkeyS,
|
||||
InputS,
|
||||
// System
|
||||
EnvironS,
|
||||
EnvironSet,
|
||||
Fre,
|
||||
Clear,
|
||||
Tron,
|
||||
Troff,
|
||||
StackFn,
|
||||
StackStmt,
|
||||
Erdev,
|
||||
ErdevS,
|
||||
// Datei-E/A
|
||||
Open,
|
||||
Close,
|
||||
CloseAll,
|
||||
PrintZiel,
|
||||
WriteFile,
|
||||
EofF,
|
||||
LofF,
|
||||
LocF,
|
||||
SeekF,
|
||||
SeekStmt,
|
||||
Freefile,
|
||||
Fileattr,
|
||||
LockStmt,
|
||||
Kill,
|
||||
NameStmt,
|
||||
Files,
|
||||
Chdir,
|
||||
Chdrive,
|
||||
Mkdir,
|
||||
Rmdir,
|
||||
CurdirS,
|
||||
DirS,
|
||||
Lpos,
|
||||
ShellStmt,
|
||||
ShellFn,
|
||||
MkS,
|
||||
CvF,
|
||||
// Finanzmathematik
|
||||
Fv,
|
||||
Pv,
|
||||
Pmt,
|
||||
NPer,
|
||||
IPmt,
|
||||
PPmt,
|
||||
Rate,
|
||||
Npv,
|
||||
Irr,
|
||||
Mirr,
|
||||
Sln,
|
||||
Syd,
|
||||
Ddb,
|
||||
// Sonstiges
|
||||
Timer,
|
||||
DateS,
|
||||
TimeS,
|
||||
DateSet,
|
||||
TimeSet,
|
||||
Now,
|
||||
DateSerial,
|
||||
TimeSerial,
|
||||
DateValue,
|
||||
TimeValue,
|
||||
DayF,
|
||||
MonthF,
|
||||
YearF,
|
||||
WeekdayF,
|
||||
HourF,
|
||||
MinuteF,
|
||||
SecondF,
|
||||
CommandS,
|
||||
Doevents,
|
||||
Sleep,
|
||||
@@ -365,7 +452,29 @@ pub enum HStmtKind {
|
||||
/// Endet die Anweisung mit `;`/`,` (kein Zeilenumbruch)?
|
||||
trailing: bool,
|
||||
},
|
||||
/// `FIELD #n, laenge AS var$, …` — Recordpuffer in Felder aufteilen.
|
||||
Field {
|
||||
file: HExpr,
|
||||
fields: Vec<(HExpr, HPlace)>,
|
||||
},
|
||||
/// `LSET`/`RSET` — links- bzw. rechtsbündig zuweisen; ist das Ziel ein
|
||||
/// `FIELD`-Feld, wirkt die Zuweisung zugleich auf den Recordpuffer.
|
||||
LsetRset {
|
||||
rset: bool,
|
||||
target: HPlace,
|
||||
value: HExpr,
|
||||
},
|
||||
/// `GET`/`PUT` auf einer Datei: Datensatz- bzw. Bytenummer und Ziel-
|
||||
/// bzw. Quellvariable. Ohne Variable wirkt der Recordpuffer.
|
||||
GetPut {
|
||||
put: bool,
|
||||
file: HExpr,
|
||||
recnum: Option<HExpr>,
|
||||
var: Option<HPlace>,
|
||||
},
|
||||
Input {
|
||||
/// `INPUT #n` — Quelle ist eine Datei statt der Tastatur.
|
||||
file: Option<HExpr>,
|
||||
line_mode: bool,
|
||||
prompt: Option<String>,
|
||||
/// Fragezeichen nach dem Prompt (`;`-Form).
|
||||
@@ -425,6 +534,8 @@ pub enum HStmtKind {
|
||||
Resume(HResume),
|
||||
/// `ERROR n`.
|
||||
RaiseError(HExpr),
|
||||
/// `ERR = n` — Fehlercode setzen, ohne einen Fehler auszulösen.
|
||||
SetErr(HExpr),
|
||||
Read(Vec<HPlace>),
|
||||
/// Ziel als Index in `HirModule::data` (0 = Anfang).
|
||||
Restore(u32),
|
||||
|
||||
@@ -119,6 +119,10 @@ pub enum TokenKind {
|
||||
/// Metabefehle `'$STATIC` / `'$DYNAMIC`.
|
||||
MetaStatic,
|
||||
MetaDynamic,
|
||||
/// Rohtext einer `DATA`-Anweisung bis zum Anweisungsende. Er wird
|
||||
/// bewusst **nicht** zerlegt oder normalisiert: unquotierte Elemente
|
||||
/// behalten ihre Groß- und Kleinschreibung und ihren inneren Leerraum.
|
||||
DataRaw(String),
|
||||
/// Ende einer logischen Zeile.
|
||||
Eol,
|
||||
Eof,
|
||||
@@ -432,6 +436,27 @@ pub fn lex(source: &str) -> LexOutput {
|
||||
break 'line;
|
||||
}
|
||||
tokens.push(Token { kind: TokenKind::Kw(kw), pos });
|
||||
if kw == Kw::Data {
|
||||
// Der Rest der Anweisung ist Rohtext: bis zum
|
||||
// `:` außerhalb von Anführungszeichen oder bis
|
||||
// zum Zeilenende.
|
||||
let start = i;
|
||||
let mut in_quote = false;
|
||||
while i < chars.len() {
|
||||
match chars[i] {
|
||||
'"' => in_quote = !in_quote,
|
||||
':' if !in_quote => break,
|
||||
'\'' if !in_quote => break,
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let roh: String = chars[start..i].iter().collect();
|
||||
tokens.push(Token {
|
||||
kind: TokenKind::DataRaw(roh),
|
||||
pos,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,9 +218,12 @@ impl<'a> P<'a> {
|
||||
}
|
||||
TokenKind::Kw(Kw::Line) => {
|
||||
self.advance();
|
||||
if self.expect_kw(Kw::Input, "INPUT") {
|
||||
if self.k() == TokenKind::Kw(Kw::Input) {
|
||||
self.advance();
|
||||
self.parse_input(true, pos)
|
||||
} else {
|
||||
// Grafikform LINE (x1,y1)-(x2,y2): deklariertes Non-Feature.
|
||||
self.err("Feature unavailable");
|
||||
self.sync();
|
||||
None
|
||||
}
|
||||
@@ -474,28 +477,40 @@ impl<'a> P<'a> {
|
||||
}
|
||||
TokenKind::Kw(Kw::Data) => {
|
||||
self.advance();
|
||||
// Der Lexer liefert den Rohtext; hier wird nur an Kommas
|
||||
// außerhalb von Anführungszeichen getrennt. Unquotierte
|
||||
// Elemente verlieren nur den Leerraum an den Rändern.
|
||||
let roh = match self.k() {
|
||||
TokenKind::DataRaw(t) => {
|
||||
self.advance();
|
||||
t
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
let mut cur = String::new();
|
||||
while !self.at_stmt_end() {
|
||||
match self.k() {
|
||||
TokenKind::Comma => {
|
||||
items.push(cur.trim().to_string());
|
||||
cur = String::new();
|
||||
let mut in_quote = false;
|
||||
let mut quotiert = false;
|
||||
for c in roh.chars() {
|
||||
match c {
|
||||
'"' if !in_quote && cur.trim().is_empty() => {
|
||||
in_quote = true;
|
||||
quotiert = true;
|
||||
cur.clear();
|
||||
}
|
||||
TokenKind::Str(s) => cur.push_str(&s),
|
||||
TokenKind::Num(n) => cur.push_str(&num_text(n)),
|
||||
TokenKind::Ident { name, suffix } => {
|
||||
cur.push_str(&name);
|
||||
if let Some(sfx) = suffix {
|
||||
cur.push(sfx.as_char());
|
||||
}
|
||||
'"' if in_quote => in_quote = false,
|
||||
',' if !in_quote => {
|
||||
items.push(if quotiert {
|
||||
std::mem::take(&mut cur)
|
||||
} else {
|
||||
std::mem::take(&mut cur).trim().to_string()
|
||||
});
|
||||
quotiert = false;
|
||||
}
|
||||
TokenKind::Minus => cur.push('-'),
|
||||
_ => {}
|
||||
_ => cur.push(c),
|
||||
}
|
||||
self.advance();
|
||||
}
|
||||
items.push(cur.trim().to_string());
|
||||
items.push(if quotiert { cur } else { cur.trim().to_string() });
|
||||
Some(Stmt::Data { items, pos })
|
||||
}
|
||||
TokenKind::Kw(Kw::Read) => {
|
||||
@@ -1401,6 +1416,14 @@ impl<'a> P<'a> {
|
||||
|
||||
fn parse_def_fn(&mut self, pos: SourcePos) -> Option<Stmt> {
|
||||
self.advance(); // DEF
|
||||
// `DEF SEG` ist Segmentadressierung und damit deklariertes
|
||||
// Non-Feature — an der Syntax erkennbar, weil `DEF` sonst nur
|
||||
// `DEF FNname` einleitet.
|
||||
if matches!(self.k(), TokenKind::Ident { ref name, suffix: None } if name == "SEG") {
|
||||
self.err("Feature unavailable");
|
||||
self.sync();
|
||||
return None;
|
||||
}
|
||||
let (name, suffix) = match self.k() {
|
||||
TokenKind::Ident { name, suffix } if name.starts_with("FN") => {
|
||||
self.advance();
|
||||
@@ -1710,6 +1733,23 @@ impl<'a> P<'a> {
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> Option<Expr> {
|
||||
// `SEEK(n)` ist zugleich Funktion und Anweisung; in Ausdrucksposition
|
||||
// gilt die Funktionsform.
|
||||
if self.k() == TokenKind::Kw(Kw::Seek) && self.k_at(1) == TokenKind::LParen {
|
||||
let pos = self.pos();
|
||||
self.advance(); // SEEK
|
||||
self.advance(); // (
|
||||
let arg = self.parse_expr()?;
|
||||
if !self.eat(&TokenKind::RParen) {
|
||||
self.err("Expected: )");
|
||||
}
|
||||
return Some(Expr::Name {
|
||||
name: "SEEK".into(),
|
||||
suffix: None,
|
||||
args: Some(vec![arg]),
|
||||
pos,
|
||||
});
|
||||
}
|
||||
match self.k() {
|
||||
TokenKind::Num(n) => {
|
||||
self.advance();
|
||||
@@ -1743,16 +1783,6 @@ impl<'a> P<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn num_text(n: NumValue) -> String {
|
||||
match n {
|
||||
NumValue::Int(v) => v.to_string(),
|
||||
NumValue::Long(v) => v.to_string(),
|
||||
NumValue::Single(v) => v.to_string(),
|
||||
NumValue::Double(v) => v.to_string(),
|
||||
NumValue::Currency(v) => format!("{}", v as f64 / 10_000.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -247,6 +247,33 @@ fn builtin_fn(name: &str) -> Option<(u8, u8, &'static [ArgK], RetK)> {
|
||||
"DOEVENTS" => (0, 0, &[], I),
|
||||
"MSGBOX" => (1, 3, &[S, N, S], I),
|
||||
"INPUTBOX$" => (1, 5, &[S, S, S, N, N], St),
|
||||
// Finanzmathematik (Original-Hilfe schreibt sie mit `#`; beide
|
||||
// Schreibweisen werden angenommen, gerechnet wird in DOUBLE).
|
||||
"FV" | "FV#" | "PV" | "PV#" | "PMT" | "PMT#" | "NPER" | "NPER#" => {
|
||||
(5, 5, &[N, N, N, N, N], Db)
|
||||
}
|
||||
"IPMT" | "IPMT#" | "PPMT" | "PPMT#" | "RATE" | "RATE#" => {
|
||||
(6, 6, &[N, N, N, N, N, N], Db)
|
||||
}
|
||||
"NPV" | "NPV#" | "IRR" | "IRR#" => (2, 2, &[N, A], Db),
|
||||
"MIRR" | "MIRR#" => (3, 3, &[A, N, N], Db),
|
||||
"SLN" | "SLN#" => (3, 3, &[N, N, N], Db),
|
||||
"SYD" | "SYD#" | "DDB" | "DDB#" => (4, 4, &[N, N, N, N], Db),
|
||||
// Record-Konvertierung (Zahl ↔ Bytedarstellung im Feldpuffer)
|
||||
"MKI$" | "MKL$" | "MKS$" | "MKD$" | "MKC$" => (1, 1, &[N], St),
|
||||
"MKSMBF$" | "MKDMBF$" => (1, 1, &[N], St),
|
||||
"CVI" => (1, 1, &[S], I),
|
||||
"CVL" => (1, 1, &[S], L),
|
||||
"CVS" | "CVSMBF" => (1, 1, &[S], Sg),
|
||||
"CVD" | "CVDMBF" => (1, 1, &[S], Db),
|
||||
"CVC" => (1, 1, &[S], Cu),
|
||||
// Dateisystem und System
|
||||
"CURDIR$" => (0, 1, &[S], St),
|
||||
"DIR$" => (0, 1, &[S], St),
|
||||
"LPOS" => (1, 1, &[N], I),
|
||||
"STACK" => (0, 0, &[], L),
|
||||
"ERDEV" => (0, 0, &[], I),
|
||||
"ERDEV$" => (0, 0, &[], St),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -261,16 +288,37 @@ fn banned_feature(name: &str) -> bool {
|
||||
"PEEK" | "POKE" | "INP" | "OUT" | "WAIT" | "BLOAD" | "BSAVE"
|
||||
| "VARPTR" | "VARSEG" | "SADD" | "VARPTR$" | "ABSOLUTE"
|
||||
| "INTERRUPT" | "INTERRUPTX" | "IOCTL" | "IOCTL$"
|
||||
// Interlanguage-Schnittstelle
|
||||
| "CALLS" | "SSEG" | "SSEGADD"
|
||||
| "STRINGADDRESS" | "STRINGASSIGN" | "STRINGLENGTH" | "STRINGRELEASE"
|
||||
// Overlay-Mechanismus
|
||||
| "CHAIN"
|
||||
// Grafik
|
||||
| "PSET" | "PRESET" | "CIRCLE" | "PAINT" | "DRAW" | "PALETTE"
|
||||
| "PCOPY" | "PMAP" | "WINDOW"
|
||||
| "PCOPY" | "PMAP" | "WINDOW" | "POINT" | "VIEW"
|
||||
// Event-Geräte (auch als Funktion in `ON COM(1) GOSUB …`)
|
||||
| "COM" | "PEN" | "STRIG" | "STICK"
|
||||
// Klang (außer BEEP)
|
||||
| "SOUND" | "PLAY"
|
||||
)
|
||||
}
|
||||
|
||||
/// Non-Features, die nicht über ihren Namen abgewiesen werden können, weil
|
||||
/// sie ihr Schlüsselwort mit einer unterstützten Form teilen. Sie werden an
|
||||
/// der Syntax erkannt: `GET`/`PUT` mit `(` (Grafik) und `LINE` ohne `INPUT`
|
||||
/// im Parser, `OPEN` mit einem `COMn:`-Gerätenamen hier in der Semantik.
|
||||
/// Der Inventar-Abgleich liest diese Liste.
|
||||
pub const SYNTAKTISCH_ABGEWIESEN: &[&str] =
|
||||
&["GET (Grafik)", "PUT (Grafik)", "LINE", "OPEN COM", "DEF SEG"];
|
||||
|
||||
/// Ist `s` ein serieller Gerätename (`COM1:` …)? Nur für Stringliterale.
|
||||
fn ist_com_geraet(s: &str) -> bool {
|
||||
let up = s.to_ascii_uppercase();
|
||||
up.strip_prefix("COM")
|
||||
.and_then(|r| r.chars().next())
|
||||
.is_some_and(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Builtin-Anweisungen (Bibliothek, keine Keywords).
|
||||
fn builtin_stmt(name: &str) -> Option<(u8, u8, &'static [ArgK])> {
|
||||
use ArgK::*;
|
||||
@@ -293,6 +341,10 @@ fn builtin_stmt(name: &str) -> Option<(u8, u8, &'static [ArgK])> {
|
||||
"ENVIRON" => (1, 1, &[S]),
|
||||
"MSGBOX" => (1, 3, &[S, N, S]),
|
||||
"SETUEVENT" => (0, 0, &[]),
|
||||
"CHDRIVE" => (1, 1, &[S]),
|
||||
"STACK" => (0, 1, &[N]),
|
||||
// `CALL SetFormatCC(49)` — Währungsformat nach Ländercode.
|
||||
"SETFORMATCC" => (1, 1, &[N]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -318,6 +370,8 @@ pub fn lower(module: &Module) -> (Option<hir::HirModule>, Vec<Diagnostic>) {
|
||||
udt_defs: Vec::new(),
|
||||
consts: HashMap::new(),
|
||||
module_vars: HashMap::new(),
|
||||
module_labels: HashMap::new(),
|
||||
module_line_labels: HashMap::new(),
|
||||
globals: Vec::new(),
|
||||
data: Vec::new(),
|
||||
data_marks_name: HashMap::new(),
|
||||
@@ -344,6 +398,10 @@ struct Sema {
|
||||
consts: HashMap<String, (Ty, Option<ConstVal>)>,
|
||||
/// Modulvariablen (Schlüssel wie `var_key`).
|
||||
module_vars: HashMap<String, VarInfo>,
|
||||
/// Sprungziele des Modulrumpfs — ein `ON ERROR GOTO` ohne `LOCAL` in
|
||||
/// einer Prozedur verweist auf sie (Scoping-Regel des Vorbilds).
|
||||
module_labels: HashMap<String, LabelId>,
|
||||
module_line_labels: HashMap<u32, LabelId>,
|
||||
/// Globale Slots (Modulvariablen, STATICs, versteckte Temps).
|
||||
globals: Vec<hir::HVar>,
|
||||
/// DATA-Konstanten des Moduls (aus dem Prescan, statisch).
|
||||
@@ -407,6 +465,8 @@ impl Sema {
|
||||
};
|
||||
self.hir_procs.resize_with(self.next_proc_id as usize, || None);
|
||||
self.hir_procs[0] = Some(main);
|
||||
self.module_labels = scope.labels.clone();
|
||||
self.module_line_labels = scope.line_labels.clone();
|
||||
|
||||
// Pass 3: Prozedurrümpfe.
|
||||
for proc in &module.procs {
|
||||
@@ -980,6 +1040,34 @@ impl Sema {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// `DATE$ = "..."` / `TIME$ = "..."` sind Anweisungen, keine
|
||||
// Zuweisungen an eine Variable.
|
||||
// `ERR = n` setzt den Fehlercode, ohne einen Fehler
|
||||
// auszulösen (Anweisungsform von ERR).
|
||||
if let Expr::Name { name, suffix: None, args: None, .. } = target {
|
||||
if name == "ERR" {
|
||||
let (e, t) = self.want_num(value, scope);
|
||||
let e = self.conv_num(e, &t, NumTy::Lng);
|
||||
self.push(out, scope, HStmtKind::SetErr(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Expr::Name { name, suffix: Some(Suffix::Str), args: None, .. } = target {
|
||||
if name == "DATE" || name == "TIME" {
|
||||
let (ve, _) = self.want_str(value, scope);
|
||||
let bt = if name == "DATE" {
|
||||
Builtin::DateSet
|
||||
} else {
|
||||
Builtin::TimeSet
|
||||
};
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: bt, args: vec![ve] },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let (place, tt) = self.lower_place(target, scope);
|
||||
let (ve, vt) = self.lower_expr(value, scope);
|
||||
self.check_assign(&tt, &vt, *pos);
|
||||
@@ -1028,39 +1116,76 @@ impl Sema {
|
||||
}
|
||||
}
|
||||
}
|
||||
if *printer {
|
||||
self.push(out, scope, HStmtKind::Unsupported("LPRINT"));
|
||||
} else if file.is_some() {
|
||||
self.push(out, scope, HStmtKind::Unsupported("PRINT #"));
|
||||
} else if using.is_some() {
|
||||
self.push(out, scope, HStmtKind::Unsupported("PRINT USING"));
|
||||
// `PRINT #n` und `LPRINT` schreiben dieselben Elemente an ein
|
||||
// anderes Ziel; danach steht der Bildschirm wieder.
|
||||
let ziel = if *printer {
|
||||
Some(HExpr::Lng(-2))
|
||||
} else if let Some(f) = file {
|
||||
let (e, t) = self.want_num(f, scope);
|
||||
Some(self.conv_num(e, &t, NumTy::Lng))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(z) = ziel.clone() {
|
||||
self.push_ziel(out, scope, z);
|
||||
}
|
||||
if let Some(u) = using {
|
||||
// `PRINT USING fmt$; a; b` — die Semikolons trennen nur
|
||||
// die Werte; Druckzonen gibt es hier nicht.
|
||||
let (fe, _) = self.want_str(u, scope);
|
||||
let mut args = vec![fe];
|
||||
for it in &hitems {
|
||||
match it {
|
||||
HPrintItem::Val(e) => args.push(e.clone()),
|
||||
_ => self.err(pos, "Illegal function call: PRINT USING"),
|
||||
}
|
||||
}
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::PrintUsing, args },
|
||||
);
|
||||
if !trailing {
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::PrintNewline, args: vec![] },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.push(out, scope, HStmtKind::Print { items: hitems, trailing });
|
||||
}
|
||||
if ziel.is_some() {
|
||||
self.push_ziel(out, scope, HExpr::Lng(-1));
|
||||
}
|
||||
}
|
||||
Stmt::Input { line, file, keep_cursor: _, prompt, vars, .. } => {
|
||||
if let Some(f) = file {
|
||||
self.want_num(f, scope);
|
||||
for v in vars {
|
||||
self.lower_place(v, scope);
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("INPUT #"));
|
||||
return;
|
||||
}
|
||||
let datei = file.as_ref().map(|f| {
|
||||
let (e, t) = self.want_num(f, scope);
|
||||
self.conv_num(e, &t, NumTy::Lng)
|
||||
});
|
||||
let mut targets = Vec::new();
|
||||
for v in vars {
|
||||
if let (Some(p), _) = self.lower_place(v, scope) {
|
||||
targets.push(p);
|
||||
}
|
||||
}
|
||||
let (ptext, question) = match prompt {
|
||||
Some((t, q)) => (Some(t.clone()), *q),
|
||||
None => (None, true),
|
||||
// `INPUT #n` kennt weder Eingabeaufforderung noch Fragezeichen.
|
||||
let (ptext, question) = match (&datei, prompt) {
|
||||
(Some(_), _) => (None, false),
|
||||
(None, Some((t, q))) => (Some(t.clone()), *q),
|
||||
(None, None) => (None, true),
|
||||
};
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::Input { line_mode: *line, prompt: ptext, question, targets },
|
||||
HStmtKind::Input {
|
||||
file: datei,
|
||||
line_mode: *line,
|
||||
prompt: ptext,
|
||||
question,
|
||||
targets,
|
||||
},
|
||||
);
|
||||
}
|
||||
Stmt::If { cond, then_body, elseifs, else_body, .. } => {
|
||||
@@ -1193,15 +1318,19 @@ impl Sema {
|
||||
self.push(out, scope, HStmtKind::ReturnGosub(id));
|
||||
}
|
||||
Stmt::OnError { local, action, pos } => {
|
||||
// Nicht-lokales ON ERROR in einer Prozedur: das Sprungziel
|
||||
// liegt zwingend im Prozedurrumpf (Label-Scoping) — wird wie
|
||||
// ON LOCAL ERROR behandelt. Die Scoping-Regel des Vorbilds
|
||||
// (Handler-Label auf Modulebene) ist als Aufgabe in PLAN.md
|
||||
// Phase 3 eingeplant.
|
||||
let local = *local || !scope.is_module;
|
||||
// `ON ERROR GOTO label` ohne `LOCAL` setzt den modulweiten
|
||||
// Handler; sein Sprungziel liegt im Modulrumpf, auch wenn die
|
||||
// Anweisung in einer Prozedur steht. Nur `ON LOCAL ERROR`
|
||||
// verlangt ein Label im eigenen Rumpf.
|
||||
let local = *local;
|
||||
match action {
|
||||
OnErrorAction::Goto(t) => {
|
||||
if let Some(id) = self.label_id(t, scope, *pos) {
|
||||
let id = if local || scope.is_module {
|
||||
self.label_id(t, scope, *pos)
|
||||
} else {
|
||||
self.module_label_id(t, *pos)
|
||||
};
|
||||
if let Some(id) = id {
|
||||
self.push(out, scope, HStmtKind::OnError { local, target: Some(id) });
|
||||
}
|
||||
}
|
||||
@@ -1370,54 +1499,114 @@ impl Sema {
|
||||
self.lower_def_fn(name, suffix, params, Some(body), None, scope, *pos);
|
||||
}
|
||||
// ---- Datei-E/A (Grammatik Phase 1, Laufzeit Phase 3) ----
|
||||
Stmt::Open { file, isam, number, len, pos, .. } => {
|
||||
self.want_str(file, scope);
|
||||
self.want_num(number, scope);
|
||||
if let Some(l) = len {
|
||||
self.want_num(l, scope);
|
||||
}
|
||||
Stmt::Open { file, mode, isam, number, len, pos, .. } => {
|
||||
let (fe, _) = self.want_str(file, scope);
|
||||
self.reject_com_device(file, *pos);
|
||||
let (ne, nt) = self.want_num(number, scope);
|
||||
let ne = self.conv_num(ne, &nt, NumTy::Lng);
|
||||
let le = match len {
|
||||
Some(l) => {
|
||||
let (e, t) = self.want_num(l, scope);
|
||||
self.conv_num(e, &t, NumTy::Lng)
|
||||
}
|
||||
// Vorgabe-Recordlänge des Vorbilds.
|
||||
None => HExpr::Lng(128),
|
||||
};
|
||||
if let Some((ty_name, _table)) = isam {
|
||||
if !ty_name.is_empty() && !self.udt_ids.contains_key(ty_name) {
|
||||
self.err(*pos, "Type not defined");
|
||||
}
|
||||
// ISAM kommt mit dem Change `phase-3-isam`.
|
||||
self.push(out, scope, HStmtKind::Unsupported("OPEN FOR ISAM"));
|
||||
return;
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("OPEN"));
|
||||
// Ohne `FOR`-Klausel gilt RANDOM (Vorbild).
|
||||
let m = match mode {
|
||||
Some(OpenMode::Input) => "I",
|
||||
Some(OpenMode::Output) => "O",
|
||||
Some(OpenMode::Append) => "A",
|
||||
Some(OpenMode::Binary) => "B",
|
||||
_ => "R",
|
||||
};
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt {
|
||||
b: Builtin::Open,
|
||||
args: vec![fe, ne, HExpr::Str(m.to_string()), le],
|
||||
},
|
||||
);
|
||||
}
|
||||
Stmt::OpenLegacy { mode, number, file, len, .. } => {
|
||||
self.want_str(mode, scope);
|
||||
self.want_num(number, scope);
|
||||
self.want_str(file, scope);
|
||||
if let Some(l) = len {
|
||||
self.want_num(l, scope);
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("OPEN"));
|
||||
Stmt::OpenLegacy { mode, number, file, len, pos, .. } => {
|
||||
let (me, _) = self.want_str(mode, scope);
|
||||
let (ne, nt) = self.want_num(number, scope);
|
||||
let ne = self.conv_num(ne, &nt, NumTy::Lng);
|
||||
let (fe, _) = self.want_str(file, scope);
|
||||
self.reject_com_device(file, *pos);
|
||||
let le = match len {
|
||||
Some(l) => {
|
||||
let (e, t) = self.want_num(l, scope);
|
||||
self.conv_num(e, &t, NumTy::Lng)
|
||||
}
|
||||
None => HExpr::Lng(128),
|
||||
};
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt {
|
||||
b: Builtin::Open,
|
||||
args: vec![fe, ne, me, le],
|
||||
},
|
||||
);
|
||||
}
|
||||
Stmt::CloseStmt { files, .. } => {
|
||||
for f in files {
|
||||
self.want_num(f, scope);
|
||||
if files.is_empty() {
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::CloseAll, args: vec![] },
|
||||
);
|
||||
}
|
||||
for f in files {
|
||||
let (e, t) = self.want_num(f, scope);
|
||||
let e = self.conv_num(e, &t, NumTy::Lng);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::Close, args: vec![e] },
|
||||
);
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("CLOSE"));
|
||||
}
|
||||
Stmt::FieldStmt { file, fields, .. } => {
|
||||
self.want_num(file, scope);
|
||||
let (fe, ft) = self.want_num(file, scope);
|
||||
let fe = self.conv_num(fe, &ft, NumTy::Lng);
|
||||
let mut hf = Vec::new();
|
||||
for (width, var) in fields {
|
||||
self.want_num(width, scope);
|
||||
let (_, t) = self.lower_place(var, scope);
|
||||
if !is_str(&t) {
|
||||
let (we, wt) = self.want_num(width, scope);
|
||||
let we = self.conv_num(we, &wt, NumTy::Lng);
|
||||
let (place, t) = self.lower_place(var, scope);
|
||||
if !is_str(&t) && t != Ty::Unknown {
|
||||
self.err(var.pos(), "Type mismatch");
|
||||
}
|
||||
if let Some(p) = place {
|
||||
hf.push((we, p));
|
||||
}
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("FIELD"));
|
||||
self.push(out, scope, HStmtKind::Field { file: fe, fields: hf });
|
||||
}
|
||||
Stmt::GetPut { put, file, recnum, var, .. } => {
|
||||
self.want_num(file, scope);
|
||||
if let Some(r) = recnum {
|
||||
self.want_num(r, scope);
|
||||
}
|
||||
if let Some(v) = var {
|
||||
self.lower_place(v, scope);
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported(if *put { "PUT" } else { "GET" }));
|
||||
let (fe, ft) = self.want_num(file, scope);
|
||||
let fe = self.conv_num(fe, &ft, NumTy::Lng);
|
||||
let re = recnum.as_ref().map(|r| {
|
||||
let (e, t) = self.want_num(r, scope);
|
||||
self.conv_num(e, &t, NumTy::Lng)
|
||||
});
|
||||
let ve = var.as_ref().and_then(|v| self.lower_place(v, scope).0);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::GetPut { put: *put, file: fe, recnum: re, var: ve },
|
||||
);
|
||||
}
|
||||
Stmt::LsetRset { rset, target, value, pos } => {
|
||||
let (_, tt) = self.lower_place(target, scope);
|
||||
@@ -1429,7 +1618,15 @@ impl Sema {
|
||||
if !ok {
|
||||
self.err(*pos, "Type mismatch");
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported(if *rset { "RSET" } else { "LSET" }));
|
||||
let (place, _) = self.lower_place(target, scope);
|
||||
let (ve, _) = self.lower_expr(value, scope);
|
||||
if let Some(place) = place {
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::LsetRset { rset: *rset, target: place, value: ve },
|
||||
);
|
||||
}
|
||||
}
|
||||
Stmt::WriteStmt { file, items, .. } => {
|
||||
if let Some(f) = file {
|
||||
@@ -1438,14 +1635,42 @@ impl Sema {
|
||||
for e in items {
|
||||
self.lower_expr(e, scope);
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("WRITE"));
|
||||
let mut args = Vec::new();
|
||||
for it in items {
|
||||
args.push(self.lower_expr(it, scope).0);
|
||||
}
|
||||
if let Some(f) = file {
|
||||
let (e, t) = self.want_num(f, scope);
|
||||
let e = self.conv_num(e, &t, NumTy::Lng);
|
||||
self.push_ziel(out, scope, e);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::WriteFile, args },
|
||||
);
|
||||
self.push_ziel(out, scope, HExpr::Lng(-1));
|
||||
} else {
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::WriteFile, args },
|
||||
);
|
||||
}
|
||||
}
|
||||
Stmt::SeekStmt { file, position, .. } => {
|
||||
self.want_num(file, scope);
|
||||
self.want_num(position, scope);
|
||||
self.push(out, scope, HStmtKind::Unsupported("SEEK"));
|
||||
let (fe, ft) = self.want_num(file, scope);
|
||||
let fe = self.conv_num(fe, &ft, NumTy::Lng);
|
||||
let (pe, pt) = self.want_num(position, scope);
|
||||
let pe = self.conv_num(pe, &pt, NumTy::Lng);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::SeekStmt, args: vec![fe, pe] },
|
||||
);
|
||||
}
|
||||
Stmt::LockStmt { unlock, file, from, to, .. } => {
|
||||
Stmt::LockStmt { file, from, to, .. } => {
|
||||
self.want_num(file, scope);
|
||||
if let Some(f) = from {
|
||||
self.want_num(f, scope);
|
||||
@@ -1453,16 +1678,24 @@ impl Sema {
|
||||
if let Some(t) = to {
|
||||
self.want_num(t, scope);
|
||||
}
|
||||
let (fe, ft) = self.want_num(file, scope);
|
||||
let fe = self.conv_num(fe, &ft, NumTy::Lng);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::Unsupported(if *unlock { "UNLOCK" } else { "LOCK" }),
|
||||
HStmtKind::BuiltinStmt { b: Builtin::LockStmt, args: vec![fe] },
|
||||
);
|
||||
}
|
||||
Stmt::NameStmt { old, new, .. } => {
|
||||
self.want_str(old, scope);
|
||||
self.want_str(new, scope);
|
||||
self.push(out, scope, HStmtKind::Unsupported("NAME"));
|
||||
let (a, _) = self.want_str(old, scope);
|
||||
let (b2, _) = self.want_str(new, scope);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::NameStmt, args: vec![a, b2] },
|
||||
);
|
||||
}
|
||||
// ---- Bildschirm/Ereignisse ----
|
||||
Stmt::ViewPrint { top, bottom, .. } => {
|
||||
@@ -1472,9 +1705,24 @@ impl Sema {
|
||||
if let Some(b) = bottom {
|
||||
self.want_num(b, scope);
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("VIEW PRINT"));
|
||||
let a = match (top, bottom) {
|
||||
(Some(t), Some(b)) => {
|
||||
let t = self.want_num(t, scope);
|
||||
let b = self.want_num(b, scope);
|
||||
vec![
|
||||
self.conv_num(t.0, &t.1, NumTy::Lng),
|
||||
self.conv_num(b.0, &b.1, NumTy::Lng),
|
||||
]
|
||||
}
|
||||
_ => vec![HExpr::Lng(-1), HExpr::Lng(-1)],
|
||||
};
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::ViewPrint, args: a },
|
||||
);
|
||||
}
|
||||
Stmt::EventControl { device, index, pos, .. } => {
|
||||
Stmt::EventControl { device, index, action, pos } => {
|
||||
match device.as_str() {
|
||||
"TIMER" | "KEY" | "UEVENT" | "EVENT" => {}
|
||||
_ => self.err(*pos, "Feature unavailable"),
|
||||
@@ -1482,6 +1730,21 @@ impl Sema {
|
||||
if let Some(i) = index {
|
||||
self.want_num(i, scope);
|
||||
}
|
||||
// `KEY ON`/`KEY OFF` ohne Index blendet die Softkey-Zeile
|
||||
// ein bzw. aus — das ist keine Ereignissteuerung
|
||||
// (die hieße `KEY(n) ON`).
|
||||
if device == "KEY" && index.is_none() && *action != EventAction::Stop {
|
||||
let ein = i32::from(*action == EventAction::On);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt {
|
||||
b: Builtin::KeyDisplay,
|
||||
args: vec![HExpr::Lng(ein)],
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.push(out, scope, HStmtKind::Unsupported("Ereignissteuerung"));
|
||||
}
|
||||
}
|
||||
@@ -1815,6 +2078,19 @@ impl Sema {
|
||||
id
|
||||
}
|
||||
|
||||
/// Sprungziel im Modulrumpf auflösen (für `ON ERROR GOTO` aus einer
|
||||
/// Prozedur heraus).
|
||||
fn module_label_id(&mut self, target: &LabelRef, pos: SourcePos) -> Option<LabelId> {
|
||||
let id = match target {
|
||||
LabelRef::Name(n) => self.module_labels.get(n).copied(),
|
||||
LabelRef::Line(n) => self.module_line_labels.get(n).copied(),
|
||||
};
|
||||
if id.is_none() {
|
||||
self.err(pos, "Label not defined");
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
// ---- Aufrufe -----------------------------------------------------------
|
||||
|
||||
fn lower_call_stmt(
|
||||
@@ -2068,26 +2344,98 @@ impl Sema {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bildschirmanweisungen: ausgelassene Argumente kommen als -1
|
||||
// durch, damit die Laufzeit „weglassen" von „null" unterscheidet.
|
||||
"ENVIRON" | "CLEAR" | "TRON" | "TROFF" | "STACK" => {
|
||||
let bt = match name {
|
||||
"ENVIRON" => Builtin::EnvironSet,
|
||||
"CLEAR" => Builtin::Clear,
|
||||
"TRON" => Builtin::Tron,
|
||||
"TROFF" => Builtin::Troff,
|
||||
_ => Builtin::StackStmt,
|
||||
};
|
||||
let args: Vec<HExpr> = lowered.iter().map(|(e, _)| e.clone()).collect();
|
||||
self.push(out, scope, HStmtKind::BuiltinStmt { b: bt, args });
|
||||
}
|
||||
"KILL" | "CHDIR" | "CHDRIVE" | "MKDIR" | "RMDIR" | "FILES" | "SHELL" => {
|
||||
let bt = match name {
|
||||
"KILL" => Builtin::Kill,
|
||||
"CHDIR" => Builtin::Chdir,
|
||||
"CHDRIVE" => Builtin::Chdrive,
|
||||
"MKDIR" => Builtin::Mkdir,
|
||||
"RMDIR" => Builtin::Rmdir,
|
||||
"FILES" => Builtin::Files,
|
||||
_ => Builtin::ShellStmt,
|
||||
};
|
||||
let args: Vec<HExpr> = lowered.iter().map(|(e, _)| e.clone()).collect();
|
||||
self.push(out, scope, HStmtKind::BuiltinStmt { b: bt, args });
|
||||
}
|
||||
"RESET" => self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::CloseAll, args: vec![] },
|
||||
),
|
||||
"SETFORMATCC" => {
|
||||
let (e, t) = lowered[0].clone();
|
||||
let n = self.conv_num(e, &t, NumTy::Lng);
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::SetFormatCc, args: vec![n] },
|
||||
);
|
||||
}
|
||||
"KEY" => {
|
||||
// `KEY LIST` (Makros auflisten) vs. `KEY n, text$` (zuweisen).
|
||||
let ist_list = matches!(
|
||||
args.first(),
|
||||
Some(Expr::Name { name, args: None, suffix: None, .. }) if name == "LIST"
|
||||
);
|
||||
if ist_list {
|
||||
if args.len() > 1 {
|
||||
self.err(pos, "Argument-count mismatch: KEY");
|
||||
}
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::KeyList, args: vec![] },
|
||||
);
|
||||
} else {
|
||||
if lowered.len() != 2 {
|
||||
self.err(pos, "Argument-count mismatch: KEY");
|
||||
}
|
||||
let (ne, nt) = lowered[0].clone();
|
||||
let n = self.conv_num(ne, &nt, NumTy::Lng);
|
||||
let t = lowered[1].0.clone();
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::KeyAssign, args: vec![n, t] },
|
||||
);
|
||||
}
|
||||
}
|
||||
"CLS" | "COLOR" | "LOCATE" | "WIDTH" | "SCREEN" => {
|
||||
// `SCREEN n` mit literalem n > 0 ist ein Grafikmodus und
|
||||
// damit schon zur Compile-Zeit abweisbar.
|
||||
if name == "SCREEN" {
|
||||
if let Some(Expr::IntLit(n)) = args.first() {
|
||||
if *n != 0 {
|
||||
self.err(pos, "Feature unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
let b = match name {
|
||||
"CLS" => Builtin::Cls,
|
||||
"COLOR" => Builtin::Color,
|
||||
"LOCATE" => Builtin::Locate,
|
||||
"SCREEN" => Builtin::ScreenStmt,
|
||||
_ => Builtin::Width,
|
||||
};
|
||||
let a = self.lower_opt_args(args, &lowered);
|
||||
self.push(out, scope, HStmtKind::BuiltinStmt { b, args: a });
|
||||
}
|
||||
// Bildschirm-/Datei-/System-Anweisungen späterer Phasen.
|
||||
_ => self.push(out, scope, HStmtKind::Unsupported(match name {
|
||||
"CLS" => "CLS",
|
||||
"COLOR" => "COLOR",
|
||||
"LOCATE" => "LOCATE",
|
||||
"WIDTH" => "WIDTH",
|
||||
"SCREEN" => "SCREEN",
|
||||
"KEY" => "KEY",
|
||||
"CLEAR" => "CLEAR",
|
||||
"FILES" => "FILES",
|
||||
"KILL" => "KILL",
|
||||
"CHDIR" => "CHDIR",
|
||||
"MKDIR" => "MKDIR",
|
||||
"RMDIR" => "RMDIR",
|
||||
"SHELL" => "SHELL",
|
||||
"RUN" => "RUN",
|
||||
"ENVIRON" => "ENVIRON",
|
||||
"RESET" => "RESET",
|
||||
"TRON" => "TRON",
|
||||
"TROFF" => "TROFF",
|
||||
"MSGBOX" => "MSGBOX",
|
||||
"SETUEVENT" => "SETUEVENT",
|
||||
_ => "Anweisung",
|
||||
@@ -2095,6 +2443,37 @@ impl Sema {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ausgabeziel von `PRINT` umschalten (−1 Bildschirm, −2 Drucker,
|
||||
/// sonst Dateinummer).
|
||||
fn push_ziel(&mut self, out: &mut Vec<HStmt>, scope: &mut Scope, n: HExpr) {
|
||||
self.push(
|
||||
out,
|
||||
scope,
|
||||
HStmtKind::BuiltinStmt { b: Builtin::PrintZiel, args: vec![n] },
|
||||
);
|
||||
}
|
||||
|
||||
/// Argumente einer Bildschirmanweisung: ausgelassene (`LOCATE , 5`)
|
||||
/// werden zu -1, damit die Laufzeit „weglassen" erkennt (0 ist bei
|
||||
/// `COLOR` eine gültige Farbe).
|
||||
fn lower_opt_args(&mut self, args: &[Expr], lowered: &[(HExpr, Ty)]) -> Vec<HExpr> {
|
||||
args.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| {
|
||||
if matches!(a, Expr::Missing) {
|
||||
HExpr::Lng(-1)
|
||||
} else {
|
||||
let (e, t) = lowered[i].clone();
|
||||
if t == Ty::Unknown {
|
||||
e
|
||||
} else {
|
||||
self.conv_num(e, &t, NumTy::Lng)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Zählt/prüft Builtin-Argumente und liefert die abgesenkten Ausdrücke.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn check_and_lower_builtin_args(
|
||||
@@ -2110,7 +2489,11 @@ impl Sema {
|
||||
let real: Vec<&Expr> =
|
||||
args.iter().filter(|a| !matches!(a, Expr::Missing)).collect();
|
||||
if real.len() < min as usize || args.len() > max as usize {
|
||||
self.err(pos, "Argument-count mismatch");
|
||||
// Katalogtext des Vorbilds, ergänzt um das Element: eine Diagnose
|
||||
// zu einem dokumentierten Element muss es benennen (Guiding
|
||||
// Principle) — sonst ist bei mehreren Aufrufen in einer Zeile
|
||||
// nicht erkennbar, welcher gemeint ist.
|
||||
self.err(pos, &format!("Argument-count mismatch: {name}"));
|
||||
}
|
||||
// Sonderfall INSTR([start%,] s$, such$)
|
||||
let instr_with_start = name == "INSTR" && args.len() == 3;
|
||||
@@ -2153,6 +2536,17 @@ impl Sema {
|
||||
}
|
||||
(he, t)
|
||||
}
|
||||
/// `OPEN "COM1:" …` — serielle Schnittstelle ist deklariertes Non-Feature.
|
||||
/// Greift nur bei Stringliteralen; ein zur Laufzeit gebildeter Gerätename
|
||||
/// bleibt der Datei-E/A überlassen.
|
||||
fn reject_com_device(&mut self, file: &Expr, pos: SourcePos) {
|
||||
if let Expr::StrLit(s) = file {
|
||||
if ist_com_geraet(s) {
|
||||
self.err(pos, "Feature unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn want_str(&mut self, e: &Expr, scope: &mut Scope) -> (HExpr, Ty) {
|
||||
let (he, t) = self.lower_expr(e, scope);
|
||||
if !is_str(&t) {
|
||||
@@ -2960,6 +3354,180 @@ impl Sema {
|
||||
};
|
||||
b(Builtin::Rnd, a, Ty::Sng, self)
|
||||
}
|
||||
// --- Datei-E/A ---
|
||||
"EOF" | "LOF" | "LOC" | "SEEK" | "FILEATTR" => {
|
||||
let f = conv_arg!(0, NumTy::Lng);
|
||||
match full_name {
|
||||
"EOF" => b(Builtin::EofF, vec![f], Ty::Int, self),
|
||||
"LOF" => b(Builtin::LofF, vec![f], Ty::Lng, self),
|
||||
"LOC" => b(Builtin::LocF, vec![f], Ty::Lng, self),
|
||||
"SEEK" => b(Builtin::SeekF, vec![f], Ty::Lng, self),
|
||||
_ => {
|
||||
let art = conv_arg!(1, NumTy::Lng);
|
||||
b(Builtin::Fileattr, vec![f, art], Ty::Lng, self)
|
||||
}
|
||||
}
|
||||
}
|
||||
"FREEFILE" => b(Builtin::Freefile, vec![], Ty::Int, self),
|
||||
"ENVIRON$" => b(Builtin::EnvironS, vec![take(&lowered, 0).0], Ty::Str, self),
|
||||
"FRE" => b(Builtin::Fre, vec![], Ty::Lng, self),
|
||||
"STACK" => b(Builtin::StackFn, vec![], Ty::Lng, self),
|
||||
"ERDEV" => b(Builtin::Erdev, vec![], Ty::Int, self),
|
||||
"ERDEV$" => b(Builtin::ErdevS, vec![], Ty::Str, self),
|
||||
"CURDIR$" => b(Builtin::CurdirS, vec![], Ty::Str, self),
|
||||
"DIR$" => {
|
||||
let m = if lowered.is_empty() {
|
||||
HExpr::Str(String::new())
|
||||
} else {
|
||||
take(&lowered, 0).0
|
||||
};
|
||||
b(Builtin::DirS, vec![m], Ty::Str, self)
|
||||
}
|
||||
"LPOS" => b(Builtin::Lpos, vec![], Ty::Int, self),
|
||||
"MKI$" | "MKL$" | "MKS$" | "MKD$" | "MKC$" | "MKSMBF$" | "MKDMBF$" => {
|
||||
// Zweites Argument wählt Breite und Typ.
|
||||
let art = match full_name {
|
||||
"MKI$" => 0,
|
||||
"MKL$" => 1,
|
||||
"MKS$" | "MKSMBF$" => 2,
|
||||
"MKD$" | "MKDMBF$" => 3,
|
||||
_ => 4,
|
||||
};
|
||||
let wert = take(&lowered, 0).0;
|
||||
b(Builtin::MkS, vec![wert, HExpr::Lng(art)], Ty::Str, self)
|
||||
}
|
||||
"CVI" | "CVL" | "CVS" | "CVD" | "CVC" | "CVSMBF" | "CVDMBF" => {
|
||||
let art = match full_name {
|
||||
"CVI" => 0,
|
||||
"CVL" => 1,
|
||||
"CVS" | "CVSMBF" => 2,
|
||||
"CVD" | "CVDMBF" => 3,
|
||||
_ => 4,
|
||||
};
|
||||
let ret = match art {
|
||||
0 => Ty::Int,
|
||||
1 => Ty::Lng,
|
||||
2 => Ty::Sng,
|
||||
3 => Ty::Dbl,
|
||||
_ => Ty::Cur,
|
||||
};
|
||||
let s = take(&lowered, 0).0;
|
||||
b(Builtin::CvF, vec![s, HExpr::Lng(art)], ret, self)
|
||||
}
|
||||
"SHELL" => {
|
||||
let c = take(&lowered, 0).0;
|
||||
b(Builtin::ShellFn, vec![c], Ty::Lng, self)
|
||||
}
|
||||
// --- Finanzmathematik ---
|
||||
"FV" | "FV#" | "PV" | "PV#" | "PMT" | "PMT#" | "NPER" | "NPER#" => {
|
||||
let a: Vec<HExpr> = (0..5).map(|i| conv_arg!(i, NumTy::Dbl)).collect();
|
||||
let bt = match full_name.trim_end_matches('#') {
|
||||
"FV" => Builtin::Fv,
|
||||
"PV" => Builtin::Pv,
|
||||
"PMT" => Builtin::Pmt,
|
||||
_ => Builtin::NPer,
|
||||
};
|
||||
b(bt, a, Ty::Dbl, self)
|
||||
}
|
||||
"IPMT" | "IPMT#" | "PPMT" | "PPMT#" | "RATE" | "RATE#" => {
|
||||
let a: Vec<HExpr> = (0..6).map(|i| conv_arg!(i, NumTy::Dbl)).collect();
|
||||
let bt = match full_name.trim_end_matches('#') {
|
||||
"IPMT" => Builtin::IPmt,
|
||||
"PPMT" => Builtin::PPmt,
|
||||
_ => Builtin::Rate,
|
||||
};
|
||||
b(bt, a, Ty::Dbl, self)
|
||||
}
|
||||
"NPV" | "NPV#" => {
|
||||
let zins = conv_arg!(0, NumTy::Dbl);
|
||||
let reihe = take(&lowered, 1).0;
|
||||
b(Builtin::Npv, vec![zins, reihe], Ty::Dbl, self)
|
||||
}
|
||||
"IRR" | "IRR#" => {
|
||||
let reihe = take(&lowered, 0).0;
|
||||
let schaetzung = conv_arg!(1, NumTy::Dbl);
|
||||
b(Builtin::Irr, vec![reihe, schaetzung], Ty::Dbl, self)
|
||||
}
|
||||
"MIRR" | "MIRR#" => {
|
||||
let reihe = take(&lowered, 0).0;
|
||||
let f = conv_arg!(1, NumTy::Dbl);
|
||||
let w = conv_arg!(2, NumTy::Dbl);
|
||||
b(Builtin::Mirr, vec![reihe, f, w], Ty::Dbl, self)
|
||||
}
|
||||
"SLN" | "SLN#" => {
|
||||
let a: Vec<HExpr> = (0..3).map(|i| conv_arg!(i, NumTy::Dbl)).collect();
|
||||
b(Builtin::Sln, a, Ty::Dbl, self)
|
||||
}
|
||||
"SYD" | "SYD#" | "DDB" | "DDB#" => {
|
||||
let a: Vec<HExpr> = (0..4).map(|i| conv_arg!(i, NumTy::Dbl)).collect();
|
||||
let bt = if full_name.starts_with("SYD") {
|
||||
Builtin::Syd
|
||||
} else {
|
||||
Builtin::Ddb
|
||||
};
|
||||
b(bt, a, Ty::Dbl, self)
|
||||
}
|
||||
"NOW" => b(Builtin::Now, vec![], Ty::Dbl, self),
|
||||
"DATESERIAL" | "TIMESERIAL" => {
|
||||
let x = conv_arg!(0, NumTy::Lng);
|
||||
let y = conv_arg!(1, NumTy::Lng);
|
||||
let z = conv_arg!(2, NumTy::Lng);
|
||||
let bt = if full_name == "DATESERIAL" {
|
||||
Builtin::DateSerial
|
||||
} else {
|
||||
Builtin::TimeSerial
|
||||
};
|
||||
b(bt, vec![x, y, z], Ty::Dbl, self)
|
||||
}
|
||||
"DATEVALUE" => b(Builtin::DateValue, vec![take(&lowered, 0).0], Ty::Dbl, self),
|
||||
"TIMEVALUE" => b(Builtin::TimeValue, vec![take(&lowered, 0).0], Ty::Dbl, self),
|
||||
"DAY" | "MONTH" | "YEAR" | "WEEKDAY" | "HOUR" | "MINUTE" | "SECOND" => {
|
||||
let e = conv_arg!(0, NumTy::Dbl);
|
||||
let bt = match full_name {
|
||||
"DAY" => Builtin::DayF,
|
||||
"MONTH" => Builtin::MonthF,
|
||||
"YEAR" => Builtin::YearF,
|
||||
"WEEKDAY" => Builtin::WeekdayF,
|
||||
"HOUR" => Builtin::HourF,
|
||||
"MINUTE" => Builtin::MinuteF,
|
||||
_ => Builtin::SecondF,
|
||||
};
|
||||
b(bt, vec![e], Ty::Int, self)
|
||||
}
|
||||
"FORMAT$" => {
|
||||
let mut a = vec![take(&lowered, 0).0];
|
||||
if get(1).is_some() {
|
||||
a.push(take(&lowered, 1).0);
|
||||
}
|
||||
b(Builtin::FormatS, a, Ty::Str, self)
|
||||
}
|
||||
// --- Bildschirm ---
|
||||
"TAB" | "SPC" => {
|
||||
// In einer PRINT-Liste werden sie eigens abgesenkt; hier
|
||||
// stehen sie außerhalb und sind dort nicht zulässig.
|
||||
self.err(pos, &format!("Illegal function call: {full_name}"));
|
||||
(HExpr::Str(String::new()), Ty::Str)
|
||||
}
|
||||
"CSRLIN" => b(Builtin::Csrlin, vec![], Ty::Int, self),
|
||||
"POS" => {
|
||||
// `POS(0)` — das Argument ist im Vorbild ein Dummy.
|
||||
b(Builtin::PosFn, vec![], Ty::Int, self)
|
||||
}
|
||||
"SCREEN" => {
|
||||
let zeile = conv_arg!(0, NumTy::Lng);
|
||||
let spalte = conv_arg!(1, NumTy::Lng);
|
||||
let farbe = if get(2).is_some() {
|
||||
conv_arg!(2, NumTy::Lng)
|
||||
} else {
|
||||
HExpr::Lng(0)
|
||||
};
|
||||
b(Builtin::ScreenFn, vec![zeile, spalte, farbe], Ty::Int, self)
|
||||
}
|
||||
"INKEY$" => b(Builtin::InkeyS, vec![], Ty::Str, self),
|
||||
"INPUT$" => {
|
||||
let n = conv_arg!(0, NumTy::Lng);
|
||||
b(Builtin::InputS, vec![n], Ty::Str, self)
|
||||
}
|
||||
// --- Fehlerstatus ---
|
||||
"ERR" => (HExpr::Err, Ty::Lng),
|
||||
"ERL" => (HExpr::Erl, Ty::Lng),
|
||||
@@ -2972,34 +3540,6 @@ impl Sema {
|
||||
// --- Spätere Phasen: dokumentiert, aber noch nicht verfügbar ---
|
||||
_ => {
|
||||
let name: &'static str = match full_name {
|
||||
"INKEY$" => "INKEY$",
|
||||
"INPUT$" => "INPUT$",
|
||||
"CSRLIN" => "CSRLIN",
|
||||
"POS" => "POS",
|
||||
"SCREEN" => "SCREEN",
|
||||
"TAB" => "TAB",
|
||||
"SPC" => "SPC",
|
||||
"NOW" => "NOW",
|
||||
"DATESERIAL" => "DATESERIAL",
|
||||
"TIMESERIAL" => "TIMESERIAL",
|
||||
"DATEVALUE" => "DATEVALUE",
|
||||
"TIMEVALUE" => "TIMEVALUE",
|
||||
"DAY" => "DAY",
|
||||
"MONTH" => "MONTH",
|
||||
"YEAR" => "YEAR",
|
||||
"WEEKDAY" => "WEEKDAY",
|
||||
"HOUR" => "HOUR",
|
||||
"MINUTE" => "MINUTE",
|
||||
"SECOND" => "SECOND",
|
||||
"FORMAT$" => "FORMAT$",
|
||||
"FRE" => "FRE",
|
||||
"EOF" => "EOF",
|
||||
"LOF" => "LOF",
|
||||
"LOC" => "LOC",
|
||||
"SEEK" => "SEEK",
|
||||
"FREEFILE" => "FREEFILE",
|
||||
"FILEATTR" => "FILEATTR",
|
||||
"ENVIRON$" => "ENVIRON$",
|
||||
"MSGBOX" => "MSGBOX",
|
||||
"INPUTBOX$" => "INPUTBOX$",
|
||||
_ => "Funktion",
|
||||
@@ -3110,7 +3650,7 @@ mod tests {
|
||||
#[test]
|
||||
fn argumentanzahl() {
|
||||
assert!(diags("PRINT LEFT$(\"a\")")
|
||||
.contains(&"Argument-count mismatch".to_string()));
|
||||
.contains(&"Argument-count mismatch: LEFT$".to_string()));
|
||||
assert!(diags("SUB Foo (a%, b%)\nEND SUB\nFoo 1")
|
||||
.contains(&"Argument-count mismatch".to_string()));
|
||||
}
|
||||
|
||||
498
crates/tb-frontend/tests/inventar.rs
Normal file
498
crates/tb-frontend/tests/inventar.rs
Normal file
@@ -0,0 +1,498 @@
|
||||
//! Abgleich `docs/inventar.md` ↔ Implementierungsstand.
|
||||
//!
|
||||
//! Das Inventar ist die Sollseite der Leitplanke „Vollständigkeit ist das
|
||||
//! Soll"; dieser Test hält es gegen den Code, damit der Status nicht
|
||||
//! verrottet. Prüfregeln siehe `pruefe_status`.
|
||||
//!
|
||||
//! ponytail: Die Tabellen `builtin_fn`/`builtin_stmt`/`banned_feature` sind
|
||||
//! private `match`-Ausdrücke ohne Namensliste. Statt sie öffentlich zu machen
|
||||
//! oder die Namen ein zweites Mal zu pflegen, liest der Test die Stringliterale
|
||||
//! aus dem Quelltext. Ceiling: Wird `sema.rs` umstrukturiert, schlägt
|
||||
//! `abschnitt` fehl und nennt den fehlenden Anker — dann hier nachziehen oder
|
||||
//! die Tabellen als `pub const`-Listen exportieren.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const IMPLEMENTIERT: &str = "implementiert";
|
||||
const OFFEN: &str = "offen";
|
||||
const NON_FEATURE: &str = "Non-Feature";
|
||||
|
||||
fn wurzel() -> PathBuf {
|
||||
// crates/tb-frontend/ → Projektwurzel
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
// ---- Inventar lesen --------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Eintrag {
|
||||
name: String,
|
||||
art: String,
|
||||
status: String,
|
||||
fundstelle: String,
|
||||
zeile: usize,
|
||||
}
|
||||
|
||||
fn inventar() -> Vec<Eintrag> {
|
||||
let pfad = wurzel().join("docs/inventar.md");
|
||||
let text = std::fs::read_to_string(&pfad)
|
||||
.unwrap_or_else(|e| panic!("docs/inventar.md nicht lesbar: {e}"));
|
||||
let mut out = Vec::new();
|
||||
for (i, zeile) in text.lines().enumerate() {
|
||||
let zeile = zeile.trim();
|
||||
if !zeile.starts_with("| `") {
|
||||
continue;
|
||||
}
|
||||
let spalten: Vec<&str> = zeile
|
||||
.trim_matches('|')
|
||||
.split('|')
|
||||
.map(|s| s.trim())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
spalten.len(),
|
||||
6,
|
||||
"docs/inventar.md Zeile {}: {} Spalten statt 6 — {zeile}",
|
||||
i + 1,
|
||||
spalten.len()
|
||||
);
|
||||
out.push(Eintrag {
|
||||
name: spalten[0].trim_matches('`').to_string(),
|
||||
art: spalten[1].to_string(),
|
||||
status: spalten[3].to_string(),
|
||||
fundstelle: spalten[4].to_string(),
|
||||
zeile: i + 1,
|
||||
});
|
||||
}
|
||||
assert!(!out.is_empty(), "docs/inventar.md enthält keine Einträge");
|
||||
out
|
||||
}
|
||||
|
||||
// ---- Implementierungsstand aus sema.rs -------------------------------------
|
||||
|
||||
fn sema_quelle() -> String {
|
||||
let pfad = wurzel().join("crates/tb-frontend/src/sema.rs");
|
||||
std::fs::read_to_string(&pfad).unwrap_or_else(|e| panic!("sema.rs nicht lesbar: {e}"))
|
||||
}
|
||||
|
||||
/// Quelltextabschnitt zwischen zwei Ankern (exklusive Endanker).
|
||||
fn abschnitt<'a>(quelle: &'a str, von: &str, bis: &str) -> &'a str {
|
||||
let a = quelle
|
||||
.find(von)
|
||||
.unwrap_or_else(|| panic!("Anker nicht gefunden in sema.rs: {von}"));
|
||||
let rest = &quelle[a..];
|
||||
let b = rest
|
||||
.find(bis)
|
||||
.unwrap_or_else(|| panic!("Endanker nicht gefunden in sema.rs: {bis}"));
|
||||
&rest[..b]
|
||||
}
|
||||
|
||||
/// Alle Stringliterale eines Abschnitts.
|
||||
fn literale(abschnitt: &str) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
let bytes: Vec<char> = abschnitt.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == '"' {
|
||||
let mut s = String::new();
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i] != '"' {
|
||||
s.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
if !s.is_empty() {
|
||||
out.insert(s);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Stand {
|
||||
signaturen: BTreeSet<String>,
|
||||
verboten: BTreeSet<String>,
|
||||
nicht_verfuegbar: BTreeSet<String>,
|
||||
/// Non-Features, die an ihrer Syntax statt an ihrem Namen erkannt werden.
|
||||
syntaktisch: BTreeSet<String>,
|
||||
}
|
||||
|
||||
fn stand() -> Stand {
|
||||
let q = sema_quelle();
|
||||
let fn_tab = abschnitt(&q, "fn builtin_fn(", "/// Deklarierte Non-Features");
|
||||
let ban = abschnitt(
|
||||
&q,
|
||||
"fn banned_feature(",
|
||||
"/// Non-Features, die nicht über ihren Namen",
|
||||
);
|
||||
let stmt_tab = abschnitt(&q, "fn builtin_stmt(", "// ---- Einstiegspunkte");
|
||||
let unsup_fn = abschnitt(
|
||||
&q,
|
||||
"// --- Spätere Phasen: dokumentiert",
|
||||
"(HExpr::Unsupported(name)",
|
||||
);
|
||||
let unsup_stmt = abschnitt(
|
||||
&q,
|
||||
"// Bildschirm-/Datei-/System-Anweisungen späterer Phasen.",
|
||||
r#"_ => "Anweisung""#,
|
||||
);
|
||||
|
||||
let mut signaturen = literale(fn_tab);
|
||||
signaturen.extend(literale(stmt_tab));
|
||||
let mut nicht_verfuegbar = literale(unsup_fn);
|
||||
nicht_verfuegbar.extend(literale(unsup_stmt));
|
||||
// Platzhalter der Sammelzweige sind keine Elementnamen.
|
||||
nicht_verfuegbar.remove("Funktion");
|
||||
nicht_verfuegbar.remove("Anweisung");
|
||||
|
||||
let syn = abschnitt(
|
||||
&q,
|
||||
"pub const SYNTAKTISCH_ABGEWIESEN",
|
||||
"/// Ist `s` ein serieller Gerätename",
|
||||
);
|
||||
|
||||
Stand {
|
||||
signaturen,
|
||||
verboten: literale(ban),
|
||||
nicht_verfuegbar,
|
||||
syntaktisch: literale(syn).into_iter().map(|s| s.to_uppercase()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Regeln ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn statusvokabular_ist_beschraenkt() {
|
||||
for e in inventar() {
|
||||
assert!(
|
||||
matches!(
|
||||
e.art.as_str(),
|
||||
"Anweisung" | "Funktion" | "Metabefehl" | "Routine"
|
||||
),
|
||||
"Zeile {}: `{}` trägt unzulässige Art `{}`",
|
||||
e.zeile,
|
||||
e.name,
|
||||
e.art
|
||||
);
|
||||
assert!(
|
||||
matches!(e.status.as_str(), IMPLEMENTIERT | OFFEN | NON_FEATURE),
|
||||
"Zeile {}: `{}` trägt unzulässigen Status `{}` \
|
||||
(erlaubt: {IMPLEMENTIERT}/{OFFEN}/{NON_FEATURE})",
|
||||
e.zeile,
|
||||
e.name,
|
||||
e.status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_features_nennen_eine_fundstelle_in_der_sprachreferenz() {
|
||||
for e in inventar() {
|
||||
if e.status == NON_FEATURE {
|
||||
assert!(
|
||||
e.fundstelle.contains("sprachreferenz.md"),
|
||||
"Zeile {}: `{}` ist Non-Feature, nennt aber keine Fundstelle \
|
||||
in docs/sprachreferenz.md (gefunden: `{}`)",
|
||||
e.zeile,
|
||||
e.name,
|
||||
e.fundstelle
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offene_elemente_haben_keine_fundstelle() {
|
||||
for e in inventar() {
|
||||
if e.status == OFFEN {
|
||||
assert_eq!(
|
||||
e.fundstelle, "-",
|
||||
"Zeile {}: `{}` ist offen, trägt aber die Fundstelle `{}`",
|
||||
e.zeile, e.name, e.fundstelle
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kernregel: Inventarstatus und Code dürfen nicht auseinanderlaufen.
|
||||
///
|
||||
/// Geprüft wird nur, was mechanisch belegbar ist — die Bibliothekselemente aus
|
||||
/// `builtin_fn`/`builtin_stmt`, die Non-Feature-Liste und die
|
||||
/// „nicht verfügbar"-Marker. Anweisungen, die der Parser direkt kennt
|
||||
/// (`PRINT`, `IF`, …), und Metabefehle tauchen in keiner dieser Tabellen auf;
|
||||
/// für sie gilt nur die schwächere Regel `parserelemente_sind_keine_non_features`.
|
||||
#[test]
|
||||
fn inventar_stimmt_mit_code_ueberein() {
|
||||
let inv = inventar();
|
||||
let st = stand();
|
||||
let mut fehler: Vec<String> = Vec::new();
|
||||
|
||||
// Ein Name kann zweimal vorkommen (Funktion und Anweisung, z. B. `SCREEN`).
|
||||
// Schlüssel ist der Name in Großschreibung — der Dialekt ist
|
||||
// case-insensitiv, der Code führt die Namen groß.
|
||||
//
|
||||
// ponytail: Einträge mit klammerndem Zusatz (`GET (Grafik)`) sind
|
||||
// syntaktische Varianten eines Tokens, das auch eine unterstützte Form hat.
|
||||
// Sie sind über den Namen nicht prüfbar und bleiben hier außen vor; ihre
|
||||
// Ablehnung liegt im Parser. Ceiling: sobald der Parser eine Liste seiner
|
||||
// abgewiesenen Formen exportiert, hier gegenprüfen.
|
||||
let mut je_name: BTreeMap<String, Vec<&Eintrag>> = BTreeMap::new();
|
||||
for e in &inv {
|
||||
// Syntaktisch erkannte Formen tragen einen klammernden Zusatz oder
|
||||
// stehen namentlich in SYNTAKTISCH_ABGEWIESEN — sie werden unten
|
||||
// gegen diese Liste geprüft, nicht gegen `banned_feature`.
|
||||
if st.syntaktisch.contains(&e.name.to_uppercase()) {
|
||||
assert_eq!(
|
||||
e.status, NON_FEATURE,
|
||||
"Zeile {}: `{}` steht in SYNTAKTISCH_ABGEWIESEN, das Inventar sagt aber `{}`",
|
||||
e.zeile, e.name, e.status
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Ein klammernder Zusatz benennt die Form (`KEY (Ereignis)`); für den
|
||||
// Codeabgleich zählt das Grundtoken davor.
|
||||
let schluessel = e
|
||||
.name
|
||||
.split('(')
|
||||
.next()
|
||||
.unwrap_or(&e.name)
|
||||
.trim()
|
||||
.to_uppercase();
|
||||
je_name.entry(schluessel).or_default().push(e);
|
||||
}
|
||||
|
||||
for (name, eintraege) in &je_name {
|
||||
let stati: BTreeSet<&str> = eintraege.iter().map(|e| e.status.as_str()).collect();
|
||||
let _ = &name;
|
||||
let zeilen: Vec<String> = eintraege.iter().map(|e| e.zeile.to_string()).collect();
|
||||
let wo = format!("Zeile(n) {}", zeilen.join(", "));
|
||||
|
||||
let hat_signatur = st.signaturen.contains(name.as_str());
|
||||
// `ON COM(1) GOSUB …` scheitert am Gerätenamen: für die ON-Formen
|
||||
// zählt das zweite Token. Andere Mehrwortnamen (`LINE INPUT`,
|
||||
// `VIEW PRINT`) sind eigenständige unterstützte Anweisungen und
|
||||
// erben nichts vom Grundtoken.
|
||||
let ist_verboten = match name.strip_prefix("ON ") {
|
||||
Some(geraet) => st.verboten.contains(geraet),
|
||||
None => st.verboten.contains(name.as_str()),
|
||||
};
|
||||
let ist_marker = st.nicht_verfuegbar.contains(name.as_str());
|
||||
|
||||
if ist_verboten && !stati.contains(NON_FEATURE) {
|
||||
fehler.push(format!(
|
||||
"`{name}` ({wo}): Code weist es als Non-Feature ab, Inventar sagt {stati:?}"
|
||||
));
|
||||
}
|
||||
if !ist_verboten && stati.contains(NON_FEATURE) {
|
||||
fehler.push(format!(
|
||||
"`{name}` ({wo}): Inventar sagt Non-Feature, Code weist es nicht ab \
|
||||
(fehlt in banned_feature)"
|
||||
));
|
||||
}
|
||||
if ist_marker && !stati.contains(OFFEN) {
|
||||
fehler.push(format!(
|
||||
"`{name}` ({wo}): senkt auf den „nicht verfügbar\"-Marker ab, \
|
||||
Inventar sagt {stati:?}"
|
||||
));
|
||||
}
|
||||
if stati == BTreeSet::from([IMPLEMENTIERT]) && ist_marker {
|
||||
fehler.push(format!(
|
||||
"`{name}` ({wo}): Inventar sagt implementiert, senkt aber weiterhin \
|
||||
auf den „nicht verfügbar\"-Marker ab"
|
||||
));
|
||||
}
|
||||
// Nur wenn ALLE Formen dieses Namens offen sind — sonst erklärt die
|
||||
// implementierte Form (etwa `DATE$` als Funktion) die Signatur.
|
||||
if stati == BTreeSet::from([OFFEN]) && hat_signatur && !ist_marker && !ist_verboten {
|
||||
fehler.push(format!(
|
||||
"`{name}` ({wo}): Inventar sagt offen, aber der Code hat eine Signatur \
|
||||
ohne „nicht verfügbar\"-Marker — entweder implementiert oder Marker fehlt"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Gegenrichtung: kennt der Code Elemente, die das Inventar nicht führt?
|
||||
// Ein Element darf im Code unter mehreren Schreibweisen stehen: die
|
||||
// Original-Hilfe schreibt die Finanzfunktionen mit Typsuffix (`FV#`), der
|
||||
// Code nimmt beide an. Für den Abgleich zählt das Inventar als getroffen,
|
||||
// wenn eine der Schreibweisen passt.
|
||||
let bekannt = |n: &str| {
|
||||
let up = n.to_uppercase();
|
||||
je_name.contains_key(&up)
|
||||
|| ['#', '!', '&', '%', '@', '$']
|
||||
.iter()
|
||||
.any(|s| je_name.contains_key(&format!("{up}{s}")))
|
||||
};
|
||||
for name in st.signaturen.iter().chain(st.verboten.iter()) {
|
||||
if !bekannt(name) {
|
||||
fehler.push(format!(
|
||||
"`{name}`: im Code bekannt, fehlt aber in docs/inventar.md"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
fehler.is_empty(),
|
||||
"Inventar und Code laufen auseinander ({} Abweichungen):\n {}",
|
||||
fehler.len(),
|
||||
fehler.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abdeckungsstand_wird_ausgewiesen() {
|
||||
let inv = inventar();
|
||||
let zaehle = |s: &str| inv.iter().filter(|e| e.status == s).count();
|
||||
let (i, o, n) = (
|
||||
zaehle(IMPLEMENTIERT),
|
||||
zaehle(OFFEN),
|
||||
zaehle(NON_FEATURE),
|
||||
);
|
||||
println!(
|
||||
"Abdeckung: implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}",
|
||||
inv.len()
|
||||
);
|
||||
|
||||
// Der Kopf der Tabelle nennt dieselben Zahlen — sonst ist die Doku stale.
|
||||
let text = std::fs::read_to_string(wurzel().join("docs/inventar.md")).unwrap();
|
||||
let erwartet = format!(
|
||||
"implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}",
|
||||
inv.len()
|
||||
);
|
||||
assert!(
|
||||
text.contains(&erwartet),
|
||||
"Abdeckungszeile in docs/inventar.md stimmt nicht; erwartet: {erwartet}"
|
||||
);
|
||||
assert_eq!(i + o + n, inv.len(), "Statussumme ≠ Einträge");
|
||||
}
|
||||
|
||||
// ---- Namentliche Ablehnung -------------------------------------------------
|
||||
|
||||
/// Ein im Inventar geführtes Element darf nie als unbekannter Bezeichner oder
|
||||
/// mit einem generischen Syntaxfehler enden — die Ablehnung MUSS es nennen
|
||||
/// (Guiding Principle, Capability `sprachinventar`).
|
||||
///
|
||||
/// Geprüft wird stichprobenartig je Ablehnungsweg, nicht über alle 285
|
||||
/// Einträge: die vollständige Zuordnung leistet `inventar_stimmt_mit_code_ueberein`.
|
||||
#[test]
|
||||
fn dokumentierte_elemente_werden_namentlich_abgewiesen() {
|
||||
// (Quelltext, erwartetes Element, erwarteter Meldungsbestandteil)
|
||||
let faelle: &[(&str, &str, &str)] = &[
|
||||
// Non-Feature über die Namensliste
|
||||
("X = PEEK(100)", "PEEK", "Feature unavailable"),
|
||||
("Y = POINT(1, 2)", "POINT", "Feature unavailable"),
|
||||
("CALLS Foo", "CALLS", "Feature unavailable"),
|
||||
// Non-Feature über die Syntax (teilt das Schlüsselwort)
|
||||
("LINE (1, 1)-(2, 2)", "LINE", "Feature unavailable"),
|
||||
("GET (1, 1)-(2, 2), A", "GET (Grafik)", "Feature unavailable"),
|
||||
(r#"OPEN "COM1:9600,N,8,1" AS #1"#, "OPEN COM", "Feature unavailable"),
|
||||
// Ereignisgerät als Funktionsform
|
||||
("ON PEN GOSUB Ziel\nZiel:\nRETURN", "ON PEN", "Feature unavailable"),
|
||||
];
|
||||
|
||||
for (quelle, element, erwartet) in faelle {
|
||||
let a = tb_frontend::analyze_source("probe.bas", quelle);
|
||||
let texte: Vec<String> = a.diagnostics.iter().map(|d| d.message.clone()).collect();
|
||||
assert!(
|
||||
texte.iter().any(|m| m.contains(erwartet)),
|
||||
"`{element}`: erwartete Meldung `{erwartet}`, bekam {texte:?} \
|
||||
(Quelle: {quelle:?})"
|
||||
);
|
||||
assert!(
|
||||
!texte.iter().any(|m| m.contains("not defined")),
|
||||
"`{element}`: wird als unbekannter Bezeichner abgewiesen statt \
|
||||
namentlich — {texte:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gegenprobe: ein Element mit „nicht verfügbar"-Marker kompiliert zwar, endet
|
||||
/// aber zur Laufzeit in einem Fehler, der es benennt — nicht in einem
|
||||
/// generischen Syntaxfehler.
|
||||
#[test]
|
||||
fn offene_elemente_erzeugen_keinen_syntaxfehler() {
|
||||
for quelle in ["CLS", "LOCATE 5, 10", "COLOR 14, 1", r#"OPEN "d.txt" FOR OUTPUT AS #1"#] {
|
||||
let a = tb_frontend::analyze_source("probe.bas", quelle);
|
||||
assert!(
|
||||
a.diagnostics.is_empty(),
|
||||
"{quelle:?}: offenes Element soll übersetzen und erst zur Laufzeit \
|
||||
namentlich abgewiesen werden, bekam {:?}",
|
||||
a.diagnostics
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jedes Inventarelement besitzt eine Signatur — auch die, deren
|
||||
/// Laufzeitverhalten noch aussteht. Sie dürfen nie als unbekannter Bezeichner
|
||||
/// enden (Capability `sprach-frontend`, Anforderung „Jedes dokumentierte
|
||||
/// Element besitzt eine Signatur").
|
||||
#[test]
|
||||
fn bislang_signaturlose_elemente_haben_signaturen() {
|
||||
let quelle = r#"
|
||||
DIM Zahlungen#(1 TO 3)
|
||||
DIM D#, S$, I%, L&, C@, G!
|
||||
D# = FV#(0.05, 10, -100, 0, 0)
|
||||
D# = PV#(0.05, 10, -100, 0, 0)
|
||||
D# = PMT#(0.05, 10, 1000, 0, 0)
|
||||
D# = NPER#(0.05, -100, 1000, 0, 0)
|
||||
D# = IPMT#(0.05, 1, 10, 1000, 0, 0)
|
||||
D# = PPMT#(0.05, 1, 10, 1000, 0, 0)
|
||||
D# = RATE#(10, -100, 1000, 0, 0, 0.1)
|
||||
D# = NPV#(0.05, Zahlungen#())
|
||||
D# = IRR#(Zahlungen#(), 0.1)
|
||||
D# = MIRR#(Zahlungen#(), 0.05, 0.06)
|
||||
D# = SLN#(1000, 100, 10)
|
||||
D# = SYD#(1000, 100, 10, 1)
|
||||
D# = DDB#(1000, 100, 10, 1)
|
||||
S$ = MKI$(1)
|
||||
S$ = MKL$(1)
|
||||
S$ = MKS$(1)
|
||||
S$ = MKD$(1)
|
||||
S$ = MKC$(1)
|
||||
S$ = MKSMBF$(1)
|
||||
S$ = MKDMBF$(1)
|
||||
I% = CVI("ab")
|
||||
L& = CVL("abcd")
|
||||
G! = CVS("abcd")
|
||||
D# = CVD("abcdefgh")
|
||||
C@ = CVC("abcdefgh")
|
||||
G! = CVSMBF("abcd")
|
||||
D# = CVDMBF("abcdefgh")
|
||||
S$ = CURDIR$
|
||||
S$ = DIR$("*.txt")
|
||||
I% = LPOS(0)
|
||||
L& = STACK
|
||||
I% = ERDEV
|
||||
S$ = ERDEV$
|
||||
CHDRIVE "C"
|
||||
STACK 2048
|
||||
SetFormatCC 49
|
||||
"#;
|
||||
let a = tb_frontend::analyze_source("signaturen.bas", quelle);
|
||||
let unbekannt: Vec<&tb_frontend::Diagnostic> = a
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.message.contains("not defined"))
|
||||
.collect();
|
||||
assert!(
|
||||
unbekannt.is_empty(),
|
||||
"Elemente werden als unbekannter Bezeichner abgewiesen: {unbekannt:?}"
|
||||
);
|
||||
assert!(
|
||||
a.diagnostics.is_empty(),
|
||||
"unerwartete Diagnosen: {:?}",
|
||||
a.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
/// Die Signatur wirkt auch dort, wo das Laufzeitverhalten noch aussteht:
|
||||
/// eine falsche Argumentanzahl wird gemeldet und nennt das Element.
|
||||
#[test]
|
||||
fn signatur_greift_ohne_laufzeitverhalten() {
|
||||
let a = tb_frontend::analyze_source("argzahl.bas", "S$ = MKL$(1, 2)");
|
||||
let texte: Vec<String> = a.diagnostics.iter().map(|d| d.message.clone()).collect();
|
||||
assert!(
|
||||
texte.iter().any(|m| m.contains("MKL$")),
|
||||
"Diagnose nennt `MKL$` nicht: {texte:?}"
|
||||
);
|
||||
}
|
||||
@@ -7,5 +7,6 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
unicode-width.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,112 +1,199 @@
|
||||
//! Konsolen-Druckzustand: Spaltenverfolgung, PRINT-Formatierung,
|
||||
//! 14-Zeichen-Druckzonen, TAB/SPC. Wirkung ausschließlich über `Host`.
|
||||
//! Konsolen-Druckzustand: PRINT-Formatierung, 14-Zeichen-Druckzonen, TAB/SPC.
|
||||
//! Wirkung ausschließlich auf dem [`TextScreen`].
|
||||
//!
|
||||
//! Die Druckspalte wird nicht mehr eigenständig mitgezählt, sondern vom
|
||||
//! Bildschirm gelesen (`POS`). Damit stimmen Druckzonen und Cursor auch
|
||||
//! nach `LOCATE`, `CLS` oder einem Umbruch am rechten Rand überein.
|
||||
|
||||
use crate::errors::RuntimeError;
|
||||
use crate::format::format_print;
|
||||
use crate::host::Host;
|
||||
use crate::fileio::Dateien;
|
||||
use crate::screen::TextScreen;
|
||||
use crate::value::Value;
|
||||
|
||||
/// Breite einer Druckzone (Vorbild: 14 Zeichen).
|
||||
pub const ZONE_WIDTH: usize = 14;
|
||||
|
||||
/// Druckzustand (Spalte 0-basiert, in Zeichen).
|
||||
/// Wohin `PRINT` schreibt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Ziel {
|
||||
#[default]
|
||||
Bildschirm,
|
||||
/// `PRINT #n` — in eine offene Datei.
|
||||
Datei(i32),
|
||||
/// `LPRINT` — in den Druckerkanal.
|
||||
Drucker,
|
||||
}
|
||||
|
||||
/// PRINT-Verhalten. Auf dem Bildschirm lebt die Spalte im Bildschirm; für
|
||||
/// Datei und Drucker wird sie hier mitgezählt, damit Druckzonen, `TAB` und
|
||||
/// `SPC` dort genauso wirken.
|
||||
#[derive(Default)]
|
||||
pub struct PrintState {
|
||||
pub col: usize,
|
||||
pub ziel: Ziel,
|
||||
/// Spalte (0-basiert) für Datei- und Druckerausgabe.
|
||||
pub spalte: usize,
|
||||
/// Druckerpuffer (Grundlage von `LPOS`); wird beim Programmende
|
||||
/// ausgegeben.
|
||||
pub drucker: String,
|
||||
}
|
||||
|
||||
impl PrintState {
|
||||
/// Text ausgeben und Spalte nachführen.
|
||||
pub fn write(&mut self, host: &mut dyn Host, s: &str) {
|
||||
host.write(s);
|
||||
/// Aktuelle Druckspalte (0-basiert) des eingestellten Ziels.
|
||||
pub fn col(&self, screen: &TextScreen) -> usize {
|
||||
match self.ziel {
|
||||
Ziel::Bildschirm => screen.pos() - 1,
|
||||
_ => self.spalte,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text an das eingestellte Ziel ausgeben.
|
||||
pub fn write(
|
||||
&mut self,
|
||||
screen: &mut TextScreen,
|
||||
dateien: &mut Dateien,
|
||||
s: &str,
|
||||
) -> Result<(), RuntimeError> {
|
||||
match self.ziel {
|
||||
Ziel::Bildschirm => screen.print(s),
|
||||
Ziel::Drucker => {
|
||||
self.drucker.push_str(s);
|
||||
self.spalte_nachfuehren(s);
|
||||
}
|
||||
Ziel::Datei(n) => {
|
||||
dateien.get(n)?.schreiben(s)?;
|
||||
self.spalte_nachfuehren(s);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spalte nach ausgegebenem Text nachführen (nur für Nicht-Bildschirm).
|
||||
fn spalte_nachfuehren(&mut self, s: &str) {
|
||||
match s.rfind('\n') {
|
||||
Some(i) => self.col = s[i + 1..].chars().count(),
|
||||
None => self.col += s.chars().count(),
|
||||
Some(i) => self.spalte = s[i + 1..].chars().count(),
|
||||
None => self.spalte += s.chars().count(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein PRINT-Element: Zahlen mit Vorzeichenspalte und nachgestelltem
|
||||
/// Leerzeichen, Strings unverändert.
|
||||
pub fn print_value(&mut self, host: &mut dyn Host, v: &Value) {
|
||||
match v {
|
||||
Value::Str(s) => {
|
||||
let s = s.clone();
|
||||
self.write(host, &s);
|
||||
}
|
||||
_ => {
|
||||
let s = format_print(v);
|
||||
self.write(host, &s);
|
||||
}
|
||||
}
|
||||
pub fn print_value(
|
||||
&mut self,
|
||||
screen: &mut TextScreen,
|
||||
dateien: &mut Dateien,
|
||||
v: &Value,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let text = match v {
|
||||
Value::Str(s) => s.to_string(),
|
||||
_ => format_print(v),
|
||||
};
|
||||
self.write(screen, dateien, &text)
|
||||
}
|
||||
|
||||
/// `,` — Sprung zur nächsten Druckzone (belegte Zone → übernächste).
|
||||
pub fn print_comma(&mut self, host: &mut dyn Host) {
|
||||
let next = (self.col / ZONE_WIDTH + 1) * ZONE_WIDTH;
|
||||
let pad = next - self.col;
|
||||
self.write(host, &" ".repeat(pad));
|
||||
pub fn print_comma(
|
||||
&mut self,
|
||||
screen: &mut TextScreen,
|
||||
dateien: &mut Dateien,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let col = self.col(screen);
|
||||
let next = (col / ZONE_WIDTH + 1) * ZONE_WIDTH;
|
||||
self.write(screen, dateien, &" ".repeat(next - col))
|
||||
}
|
||||
|
||||
/// `TAB(n)` — zur Spalte n (1-basiert); liegt der Cursor bereits
|
||||
/// dahinter, zuerst Zeilenumbruch (Vorbild).
|
||||
pub fn print_tab(&mut self, host: &mut dyn Host, n: i32) {
|
||||
pub fn print_tab(
|
||||
&mut self,
|
||||
screen: &mut TextScreen,
|
||||
dateien: &mut Dateien,
|
||||
n: i32,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let target = (n.max(1) as usize) - 1;
|
||||
if self.col > target {
|
||||
self.write(host, "\n");
|
||||
if self.col(screen) > target {
|
||||
self.write(screen, dateien, "\n")?;
|
||||
}
|
||||
if target > self.col {
|
||||
let pad = target - self.col;
|
||||
self.write(host, &" ".repeat(pad));
|
||||
let col = self.col(screen);
|
||||
if target > col {
|
||||
self.write(screen, dateien, &" ".repeat(target - col))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `SPC(n)` — n Leerzeichen.
|
||||
pub fn print_spc(&mut self, host: &mut dyn Host, n: i32) {
|
||||
pub fn print_spc(
|
||||
&mut self,
|
||||
screen: &mut TextScreen,
|
||||
dateien: &mut Dateien,
|
||||
n: i32,
|
||||
) -> Result<(), RuntimeError> {
|
||||
if n > 0 {
|
||||
self.write(host, &" ".repeat(n as usize));
|
||||
self.write(screen, dateien, &" ".repeat(n as usize))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print_newline(&mut self, host: &mut dyn Host) {
|
||||
self.write(host, "\n");
|
||||
pub fn print_newline(
|
||||
&mut self,
|
||||
screen: &mut TextScreen,
|
||||
dateien: &mut Dateien,
|
||||
) -> Result<(), RuntimeError> {
|
||||
self.write(screen, dateien, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::CaptureHost;
|
||||
|
||||
/// Zeile `z` (1-basiert) als getrimmter Text.
|
||||
fn zeile(s: &TextScreen, z: usize) -> String {
|
||||
let t: String = (1..=s.cols()).map(|c| s.cell(z, c).ch).collect();
|
||||
t.trim_end().to_string()
|
||||
}
|
||||
|
||||
fn ps() -> (PrintState, TextScreen, Dateien) {
|
||||
(PrintState::default(), TextScreen::new(), Dateien::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn druckzonen() {
|
||||
let mut ps = PrintState::default();
|
||||
let mut h = CaptureHost::default();
|
||||
// "a" (1 Zeichen) , → Spalte 14
|
||||
ps.write(&mut h, "a");
|
||||
ps.print_comma(&mut h);
|
||||
ps.write(&mut h, "b");
|
||||
assert_eq!(h.output, "a b");
|
||||
assert_eq!(ps.col, 15);
|
||||
let (mut ps, mut s, mut d) = ps();
|
||||
// "a" (1 Zeichen) , → Spalte 15 (1-basiert)
|
||||
ps.write(&mut s, &mut d, "a").unwrap();
|
||||
ps.print_comma(&mut s, &mut d).unwrap();
|
||||
ps.write(&mut s, &mut d, "b").unwrap();
|
||||
assert_eq!(zeile(&s, 1), "a b");
|
||||
assert_eq!(s.pos(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volle_zone_springt_zur_uebernaechsten() {
|
||||
let mut ps = PrintState::default();
|
||||
let mut h = CaptureHost::default();
|
||||
ps.write(&mut h, "12345678901234"); // 14 Zeichen, Zone voll
|
||||
ps.print_comma(&mut h);
|
||||
ps.write(&mut h, "x");
|
||||
// x beginnt in Spalte 29 (1-basiert) = Index 28
|
||||
assert_eq!(h.output.chars().count(), 29);
|
||||
assert!(h.output.ends_with("x"));
|
||||
let (mut ps, mut s, mut d) = ps();
|
||||
ps.write(&mut s, &mut d, "12345678901234").unwrap(); // Zone voll
|
||||
ps.print_comma(&mut s, &mut d).unwrap();
|
||||
ps.write(&mut s, &mut d, "x").unwrap();
|
||||
// x beginnt in Spalte 29 (1-basiert)
|
||||
assert_eq!(s.cell(1, 29).ch, 'x');
|
||||
assert_eq!(s.pos(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zahlen_mit_vorzeichenspalte() {
|
||||
let mut ps = PrintState::default();
|
||||
let mut h = CaptureHost::default();
|
||||
ps.print_value(&mut h, &Value::Int(1));
|
||||
ps.print_value(&mut h, &Value::Int(-2));
|
||||
ps.print_newline(&mut h);
|
||||
assert_eq!(h.output, " 1 -2 \n");
|
||||
let (mut ps, mut s, mut d) = ps();
|
||||
ps.print_value(&mut s, &mut d, &Value::Int(1)).unwrap();
|
||||
ps.print_value(&mut s, &mut d, &Value::Int(-2)).unwrap();
|
||||
ps.print_newline(&mut s, &mut d).unwrap();
|
||||
assert_eq!(zeile(&s, 1), " 1 -2");
|
||||
assert_eq!(s.csrlin(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn druckzone_folgt_dem_cursor_nach_locate() {
|
||||
let (mut ps, mut s, mut d) = ps();
|
||||
s.locate(3, 5).unwrap();
|
||||
ps.print_comma(&mut s, &mut d).unwrap(); // Spalte 4 → nächste Zone 14
|
||||
assert_eq!(s.pos(), 15);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,199 @@
|
||||
//! Datums- und Zeitfunktionen der Laufzeitbibliothek.
|
||||
//!
|
||||
//! Serielle Datumswerte folgen der Zählweise des Vorbilds: der ganzzahlige
|
||||
//! Anteil zählt Tage ab dem 30.12.1899 (Serie 2 = 1.1.1900), der
|
||||
//! Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1 für Sonntag.
|
||||
//!
|
||||
//! ponytail: Die Uhr läuft in UTC — die Standardbibliothek kennt keine
|
||||
//! Zeitzonen, und eine Zeitzonenbibliothek wäre die erste schwere
|
||||
//! Abhängigkeit der Laufzeit. Die Abweichung steht in der Sprachreferenz.
|
||||
//! Ceiling: sobald lokale Zeit gefordert ist, hier eine Zeitzonenquelle
|
||||
//! einhängen — der Rest des Moduls rechnet bereits auf Sekunden seit Epoche.
|
||||
|
||||
// Platzhalter — wird in Phase 3 ausgearbeitet (siehe PLAN.md)
|
||||
/// Tage zwischen dem 1.1.1970 (Unix) und dem 30.12.1899 (Serie 0).
|
||||
const EPOCH_OFFSET_TAGE: i64 = 25_569;
|
||||
|
||||
/// Tage → (Jahr, Monat, Tag) ab Unix-Epoche (Howard Hinnant).
|
||||
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
||||
(y + i64::from(m <= 2), m, d)
|
||||
}
|
||||
|
||||
/// (Jahr, Monat, Tag) → Tage ab Unix-Epoche.
|
||||
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = y - i64::from(m <= 2);
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = (y - era * 400) as u64;
|
||||
let mp = if m > 2 { m - 3 } else { m + 9 } as u64;
|
||||
let doy = (153 * mp + 2) / 5 + u64::from(d) - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe as i64 - 719_468
|
||||
}
|
||||
|
||||
/// Gültige Kalenderangabe? (Monat 1–12, Tag im Monat.)
|
||||
pub fn gueltig(y: i64, m: i64, d: i64) -> bool {
|
||||
if !(1..=12).contains(&m) || d < 1 {
|
||||
return false;
|
||||
}
|
||||
let tage = tage_im_monat(y, m as u32);
|
||||
d <= tage as i64
|
||||
}
|
||||
|
||||
fn tage_im_monat(y: i64, m: u32) -> u32 {
|
||||
match m {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if schaltjahr(y) => 29,
|
||||
2 => 28,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn schaltjahr(y: i64) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
}
|
||||
|
||||
/// Serieller Wert aus Jahr/Monat/Tag.
|
||||
pub fn serial_aus_ymd(y: i64, m: u32, d: u32) -> f64 {
|
||||
(days_from_civil(y, m, d) + EPOCH_OFFSET_TAGE) as f64
|
||||
}
|
||||
|
||||
/// (Jahr, Monat, Tag) aus einem seriellen Wert.
|
||||
pub fn ymd_aus_serial(s: f64) -> (i64, u32, u32) {
|
||||
civil_from_days(s.floor() as i64 - EPOCH_OFFSET_TAGE)
|
||||
}
|
||||
|
||||
/// Tageszeit eines seriellen Werts als (Stunde, Minute, Sekunde).
|
||||
pub fn hms_aus_serial(s: f64) -> (u32, u32, u32) {
|
||||
// Auf die Sekunde runden, damit 0.5 exakt 12:00:00 ergibt.
|
||||
let sekunden = ((s - s.floor()) * 86_400.0).round() as i64;
|
||||
let sekunden = sekunden.rem_euclid(86_400);
|
||||
(
|
||||
(sekunden / 3600) as u32,
|
||||
((sekunden / 60) % 60) as u32,
|
||||
(sekunden % 60) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Wochentag: 1 = Sonntag … 7 = Samstag.
|
||||
pub fn weekday(s: f64) -> u32 {
|
||||
// Der 1.1.1970 war ein Donnerstag (Index 4 in dieser Zählung).
|
||||
let tage = s.floor() as i64 - EPOCH_OFFSET_TAGE;
|
||||
(tage.rem_euclid(7) as u32 + 4) % 7 + 1
|
||||
}
|
||||
|
||||
/// Tageszeit als serieller Bruchteil.
|
||||
pub fn serial_aus_hms(h: i64, m: i64, s: i64) -> f64 {
|
||||
(h * 3600 + m * 60 + s) as f64 / 86_400.0
|
||||
}
|
||||
|
||||
/// Sekunden seit der Unix-Epoche (UTC).
|
||||
pub fn jetzt_sekunden() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `NOW` — aktueller Zeitpunkt als serieller Wert.
|
||||
pub fn jetzt_serial(offset_sekunden: i64) -> f64 {
|
||||
let s = jetzt_sekunden() + offset_sekunden;
|
||||
s as f64 / 86_400.0 + EPOCH_OFFSET_TAGE as f64
|
||||
}
|
||||
|
||||
/// `DATEVALUE` — Datum aus einem Text lesen (`MM-TT-JJJJ`, `MM/TT/JJJJ`,
|
||||
/// zweistelliges Jahr wird als 19xx/20xx gedeutet wie beim Vorbild).
|
||||
pub fn datevalue(t: &str) -> Option<f64> {
|
||||
let teile: Vec<&str> = t
|
||||
.trim()
|
||||
.split(['-', '/', '.'])
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
if teile.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let m: i64 = teile[0].trim().parse().ok()?;
|
||||
let d: i64 = teile[1].trim().parse().ok()?;
|
||||
let mut y: i64 = teile[2].trim().parse().ok()?;
|
||||
if y < 100 {
|
||||
y += if y < 80 { 2000 } else { 1900 };
|
||||
}
|
||||
if !gueltig(y, m, d) {
|
||||
return None;
|
||||
}
|
||||
Some(serial_aus_ymd(y, m as u32, d as u32))
|
||||
}
|
||||
|
||||
/// `TIMEVALUE` — Tageszeit aus einem Text lesen (`HH:MM[:SS]`).
|
||||
pub fn timevalue(t: &str) -> Option<f64> {
|
||||
let teile: Vec<&str> = t.trim().split(':').collect();
|
||||
if !(2..=3).contains(&teile.len()) {
|
||||
return None;
|
||||
}
|
||||
let h: i64 = teile[0].trim().parse().ok()?;
|
||||
let m: i64 = teile[1].trim().parse().ok()?;
|
||||
let s: i64 = teile.get(2).map_or(Ok(0), |x| x.trim().parse()).ok()?;
|
||||
if !(0..24).contains(&h) || !(0..60).contains(&m) || !(0..60).contains(&s) {
|
||||
return None;
|
||||
}
|
||||
Some(serial_aus_hms(h, m, s))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serienwert_und_rueckumwandlung() {
|
||||
let s = serial_aus_ymd(1992, 3, 15);
|
||||
assert_eq!(ymd_aus_serial(s), (1992, 3, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zaehlweise_des_vorbilds() {
|
||||
// Serie 2 = 1. Januar 1900.
|
||||
assert_eq!(serial_aus_ymd(1900, 1, 1), 2.0);
|
||||
assert_eq!(ymd_aus_serial(2.0), (1900, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wochentag_sonntag_ist_eins() {
|
||||
// 15. März 1992 war ein Sonntag.
|
||||
assert_eq!(weekday(serial_aus_ymd(1992, 3, 15)), 1);
|
||||
// 16. März 1992 war ein Montag.
|
||||
assert_eq!(weekday(serial_aus_ymd(1992, 3, 16)), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tageszeit() {
|
||||
let s = serial_aus_hms(13, 45, 30);
|
||||
assert_eq!(hms_aus_serial(s), (13, 45, 30));
|
||||
assert_eq!(hms_aus_serial(0.5), (12, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gueltigkeitspruefung() {
|
||||
assert!(gueltig(1992, 2, 29)); // Schaltjahr
|
||||
assert!(!gueltig(1993, 2, 29));
|
||||
assert!(!gueltig(1992, 13, 1));
|
||||
assert!(!gueltig(1992, 4, 31));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_serienwert() {
|
||||
assert_eq!(datevalue("03-15-1992"), Some(serial_aus_ymd(1992, 3, 15)));
|
||||
assert_eq!(datevalue("3/15/92"), Some(serial_aus_ymd(1992, 3, 15)));
|
||||
assert_eq!(datevalue("13-01-1992"), None);
|
||||
assert_eq!(timevalue("13:45:30"), Some(serial_aus_hms(13, 45, 30)));
|
||||
assert_eq!(timevalue("13:45"), Some(serial_aus_hms(13, 45, 0)));
|
||||
assert_eq!(timevalue("25:00"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,660 @@
|
||||
//! Datei-E/A der Laufzeitbibliothek (sequenziell, Random Access, binär).
|
||||
//! Datei-E/A: `OPEN`/`CLOSE`, sequenzieller, wahlfreier und binärer Zugriff,
|
||||
//! Recordpuffer mit Festtextfeldern, Positions- und Statusfunktionen sowie
|
||||
//! die Dateisystem-Anweisungen.
|
||||
//!
|
||||
//! Feste Strings in Recordpuffern liegen als **UTF-32** (4 Bytes je Zeichen),
|
||||
//! damit `STRING * n` unabhängig vom Inhalt genau `4 · n` Bytes belegt und
|
||||
//! die Recordlänge konstant bleibt (Entscheidung 2026-09-02). Binärdateien
|
||||
//! sind dadurch bewusst nicht mit denen des Vorbilds austauschbar.
|
||||
|
||||
// Platzhalter — wird in Phase 3 ausgearbeitet (siehe PLAN.md)
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::errors::RuntimeError;
|
||||
|
||||
/// Zugriffsart einer offenen Datei.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Modus {
|
||||
Input,
|
||||
Output,
|
||||
Append,
|
||||
Random,
|
||||
Binary,
|
||||
}
|
||||
|
||||
impl Modus {
|
||||
/// Aus der Kurzsyntax `OPEN "I", #1, …`.
|
||||
pub fn aus_text(s: &str) -> Option<Modus> {
|
||||
match s.trim().to_ascii_uppercase().chars().next()? {
|
||||
'I' => Some(Modus::Input),
|
||||
'O' => Some(Modus::Output),
|
||||
'A' => Some(Modus::Append),
|
||||
'R' => Some(Modus::Random),
|
||||
'B' => Some(Modus::Binary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Feld des Recordpuffers (`FIELD`): die gebundene Variable und ihr
|
||||
/// Bereich im Puffer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Feld {
|
||||
pub referenz: crate::value::VarRef,
|
||||
pub start: usize,
|
||||
pub laenge: usize,
|
||||
}
|
||||
|
||||
/// Eine offene Datei.
|
||||
#[derive(Debug)]
|
||||
pub struct Datei {
|
||||
pub modus: Modus,
|
||||
pub pfad: PathBuf,
|
||||
datei: File,
|
||||
/// Recordlänge (`RANDOM`); bei `BINARY` immer 1.
|
||||
pub reclen: usize,
|
||||
/// Recordpuffer für `FIELD`/`GET`/`PUT`.
|
||||
pub puffer: Vec<u8>,
|
||||
pub felder: Vec<Feld>,
|
||||
/// Nächste Position: Datensatz bei `RANDOM`, Byte bei `BINARY`,
|
||||
/// jeweils 1-basiert.
|
||||
pub position: u64,
|
||||
/// Zeilenpuffer für sequenzielles Lesen.
|
||||
leser: Option<BufReader<File>>,
|
||||
}
|
||||
|
||||
impl Datei {
|
||||
fn oeffnen(pfad: &Path, modus: Modus, reclen: usize) -> Result<Datei, RuntimeError> {
|
||||
let mut opt = OpenOptions::new();
|
||||
match modus {
|
||||
Modus::Input => {
|
||||
opt.read(true);
|
||||
}
|
||||
Modus::Output => {
|
||||
opt.write(true).create(true).truncate(true);
|
||||
}
|
||||
Modus::Append => {
|
||||
opt.write(true).create(true).append(true);
|
||||
}
|
||||
Modus::Random | Modus::Binary => {
|
||||
opt.read(true).write(true).create(true);
|
||||
}
|
||||
}
|
||||
let datei = opt.open(pfad).map_err(fehler_aus_io)?;
|
||||
let leser = match modus {
|
||||
Modus::Input => Some(BufReader::new(
|
||||
File::open(pfad).map_err(fehler_aus_io)?,
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
let reclen = if modus == Modus::Random { reclen.max(1) } else { 1 };
|
||||
Ok(Datei {
|
||||
modus,
|
||||
pfad: pfad.to_path_buf(),
|
||||
datei,
|
||||
reclen,
|
||||
puffer: vec![b' '; reclen],
|
||||
felder: Vec::new(),
|
||||
position: 1,
|
||||
leser,
|
||||
})
|
||||
}
|
||||
|
||||
/// Dateigröße in Bytes.
|
||||
pub fn laenge(&self) -> Result<u64, RuntimeError> {
|
||||
self.datei.metadata().map(|m| m.len()).map_err(fehler_aus_io)
|
||||
}
|
||||
|
||||
/// `EOF` — bei sequenziellem Lesen: nichts mehr im Puffer; sonst:
|
||||
/// Position hinter dem letzten Datensatz bzw. Byte.
|
||||
pub fn eof(&mut self) -> Result<bool, RuntimeError> {
|
||||
match self.modus {
|
||||
Modus::Input => {
|
||||
let l = self.leser.as_mut().ok_or(RuntimeError(52))?;
|
||||
Ok(l.fill_buf().map_err(fehler_aus_io)?.is_empty())
|
||||
}
|
||||
Modus::Random => Ok((self.position - 1) * self.reclen as u64 >= self.laenge()?),
|
||||
Modus::Binary => Ok(self.position > self.laenge()?),
|
||||
// Beim Schreiben ist das Ende immer erreicht.
|
||||
Modus::Output | Modus::Append => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text anhängen (`PRINT #`, `WRITE #`).
|
||||
pub fn schreiben(&mut self, text: &str) -> Result<(), RuntimeError> {
|
||||
if !matches!(self.modus, Modus::Output | Modus::Append) {
|
||||
return Err(RuntimeError(54)); // Bad file mode
|
||||
}
|
||||
self.datei
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(fehler_aus_io)
|
||||
}
|
||||
|
||||
/// Eine Zeile lesen (ohne Zeilenende); `None` = Dateiende.
|
||||
pub fn zeile_lesen(&mut self) -> Result<Option<String>, RuntimeError> {
|
||||
if self.modus != Modus::Input {
|
||||
return Err(RuntimeError(54));
|
||||
}
|
||||
let l = self.leser.as_mut().ok_or(RuntimeError(52))?;
|
||||
let mut roh = Vec::new();
|
||||
let n = l.read_until(b'\n', &mut roh).map_err(fehler_aus_io)?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
while roh.last() == Some(&b'\n') || roh.last() == Some(&b'\r') {
|
||||
roh.pop();
|
||||
}
|
||||
Ok(Some(String::from_utf8_lossy(&roh).into_owned()))
|
||||
}
|
||||
|
||||
/// Datensatz `nr` (1-basiert) in den Puffer lesen.
|
||||
pub fn record_lesen(&mut self, nr: u64) -> Result<(), RuntimeError> {
|
||||
let offset = (nr - 1) * self.reclen as u64;
|
||||
self.datei
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(fehler_aus_io)?;
|
||||
self.puffer = vec![b' '; self.reclen];
|
||||
// Ein unvollständig gelesener Satz bleibt mit Leerzeichen gefüllt.
|
||||
let mut gelesen = 0;
|
||||
while gelesen < self.reclen {
|
||||
match self.datei.read(&mut self.puffer[gelesen..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => gelesen += n,
|
||||
Err(e) => return Err(fehler_aus_io(e)),
|
||||
}
|
||||
}
|
||||
self.position = nr + 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puffer als Datensatz `nr` schreiben.
|
||||
pub fn record_schreiben(&mut self, nr: u64) -> Result<(), RuntimeError> {
|
||||
let offset = (nr - 1) * self.reclen as u64;
|
||||
self.datei
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(fehler_aus_io)?;
|
||||
let puffer = std::mem::take(&mut self.puffer);
|
||||
self.datei.write_all(&puffer).map_err(fehler_aus_io)?;
|
||||
self.puffer = puffer;
|
||||
self.position = nr + 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `n` Bytes ab Position `pos` (1-basiert) lesen.
|
||||
pub fn bytes_lesen(&mut self, pos: u64, n: usize) -> Result<Vec<u8>, RuntimeError> {
|
||||
self.datei
|
||||
.seek(SeekFrom::Start(pos - 1))
|
||||
.map_err(fehler_aus_io)?;
|
||||
let mut buf = vec![0u8; n];
|
||||
let mut gelesen = 0;
|
||||
while gelesen < n {
|
||||
match self.datei.read(&mut buf[gelesen..]) {
|
||||
Ok(0) => break,
|
||||
Ok(k) => gelesen += k,
|
||||
Err(e) => return Err(fehler_aus_io(e)),
|
||||
}
|
||||
}
|
||||
if gelesen < n {
|
||||
return Err(RuntimeError(62)); // Input past end of file
|
||||
}
|
||||
self.position = pos + n as u64;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Bytes ab Position `pos` (1-basiert) schreiben.
|
||||
pub fn bytes_schreiben(&mut self, pos: u64, daten: &[u8]) -> Result<(), RuntimeError> {
|
||||
self.datei
|
||||
.seek(SeekFrom::Start(pos - 1))
|
||||
.map_err(fehler_aus_io)?;
|
||||
self.datei.write_all(daten).map_err(fehler_aus_io)?;
|
||||
self.position = pos + daten.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schliessen(&mut self) -> Result<(), RuntimeError> {
|
||||
self.datei.flush().map_err(fehler_aus_io)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tabelle der offenen Dateien, nach Dateinummer.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Dateien {
|
||||
offen: BTreeMap<i32, Datei>,
|
||||
}
|
||||
|
||||
impl Dateien {
|
||||
/// `OPEN` — Datei an eine Nummer binden.
|
||||
pub fn oeffnen(
|
||||
&mut self,
|
||||
nummer: i32,
|
||||
pfad: &str,
|
||||
modus: Modus,
|
||||
reclen: usize,
|
||||
) -> Result<(), RuntimeError> {
|
||||
if nummer < 1 {
|
||||
return Err(RuntimeError(52)); // Bad file name or number
|
||||
}
|
||||
if self.offen.contains_key(&nummer) {
|
||||
return Err(RuntimeError(55)); // File already open
|
||||
}
|
||||
let p = pfad_normieren(pfad);
|
||||
if modus == Modus::Input && !p.exists() {
|
||||
return Err(RuntimeError(53)); // File not found
|
||||
}
|
||||
self.offen.insert(nummer, Datei::oeffnen(&p, modus, reclen)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&mut self, nummer: i32) -> Result<&mut Datei, RuntimeError> {
|
||||
self.offen.get_mut(&nummer).ok_or(RuntimeError(52))
|
||||
}
|
||||
|
||||
pub fn ist_offen(&self, nummer: i32) -> bool {
|
||||
self.offen.contains_key(&nummer)
|
||||
}
|
||||
|
||||
/// `CLOSE #n` — eine Datei schließen.
|
||||
pub fn schliessen(&mut self, nummer: i32) -> Result<(), RuntimeError> {
|
||||
match self.offen.remove(&nummer) {
|
||||
Some(mut d) => d.schliessen(),
|
||||
None => Ok(()), // CLOSE auf eine geschlossene Nummer ist folgenlos
|
||||
}
|
||||
}
|
||||
|
||||
/// `CLOSE` / `RESET` — alle Dateien schließen.
|
||||
pub fn alle_schliessen(&mut self) -> Result<(), RuntimeError> {
|
||||
let nummern: Vec<i32> = self.offen.keys().copied().collect();
|
||||
for n in nummern {
|
||||
self.schliessen(n)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Zu welcher Datei und welchem Bereich gehört diese Variable?
|
||||
pub fn feld_finden(&self, r: &crate::value::VarRef) -> Option<(i32, usize, usize)> {
|
||||
for (n, d) in &self.offen {
|
||||
for f in &d.felder {
|
||||
if gleiche_referenz(&f.referenz, r) {
|
||||
return Some((*n, f.start, f.laenge));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `FREEFILE` — kleinste freie Nummer.
|
||||
pub fn freie_nummer(&self) -> i32 {
|
||||
(1..).find(|n| !self.offen.contains_key(n)).unwrap_or(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeigen zwei Referenzen auf denselben Speicherort?
|
||||
fn gleiche_referenz(a: &crate::value::VarRef, b: &crate::value::VarRef) -> bool {
|
||||
use crate::value::VarRef::*;
|
||||
match (a, b) {
|
||||
(Global(x), Global(y)) => x == y,
|
||||
(Stack(x), Stack(y)) => x == y,
|
||||
(Elem(x, i), Elem(y, j)) => std::rc::Rc::ptr_eq(x, y) && i == j,
|
||||
(Field(x, p), Field(y, q)) => std::rc::Rc::ptr_eq(x, y) && p == q,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Recordpuffer: feste Strings als UTF-32 ---------------------------------
|
||||
|
||||
/// Bytes je Zeichen eines festen Strings im Recordpuffer.
|
||||
pub const UTF32_BREITE: usize = 4;
|
||||
|
||||
/// Festen String in den Puffer schreiben: links- bzw. rechtsbündig,
|
||||
/// überlange Werte werden abgeschnitten.
|
||||
pub fn feld_setzen(puffer: &mut [u8], start: usize, laenge: usize, wert: &str, rechts: bool) {
|
||||
let zeichen: Vec<char> = wert.chars().take(laenge).collect();
|
||||
let fehlt = laenge - zeichen.len();
|
||||
let mut alle: Vec<char> = Vec::with_capacity(laenge);
|
||||
if rechts {
|
||||
alle.extend(std::iter::repeat_n(' ', fehlt));
|
||||
alle.extend(zeichen);
|
||||
} else {
|
||||
alle.extend(zeichen);
|
||||
alle.extend(std::iter::repeat_n(' ', fehlt));
|
||||
}
|
||||
for (i, c) in alle.iter().enumerate() {
|
||||
let off = start + i * UTF32_BREITE;
|
||||
if off + UTF32_BREITE <= puffer.len() {
|
||||
puffer[off..off + UTF32_BREITE].copy_from_slice(&(*c as u32).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Festen String aus dem Puffer lesen.
|
||||
pub fn feld_lesen(puffer: &[u8], start: usize, laenge: usize) -> String {
|
||||
let mut s = String::with_capacity(laenge);
|
||||
for i in 0..laenge {
|
||||
let off = start + i * UTF32_BREITE;
|
||||
if off + UTF32_BREITE > puffer.len() {
|
||||
break;
|
||||
}
|
||||
let code = u32::from_le_bytes([
|
||||
puffer[off],
|
||||
puffer[off + 1],
|
||||
puffer[off + 2],
|
||||
puffer[off + 3],
|
||||
]);
|
||||
s.push(char::from_u32(code).unwrap_or(' '));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ---- Werte im Recordpuffer --------------------------------------------------
|
||||
|
||||
use crate::value::{RecordObj, TypeInit, UdtLayout, Value};
|
||||
|
||||
/// Bytebreite eines Feldtyps im Recordpuffer. Feste Strings zählen
|
||||
/// `UTF32_BREITE` Bytes je Zeichen; Strings variabler Länge sind in Records
|
||||
/// nicht zulässig (Vorbild: nur `STRING * n`).
|
||||
pub fn breite(t: &TypeInit, layouts: &[UdtLayout]) -> Option<usize> {
|
||||
Some(match t {
|
||||
TypeInit::Int => 2,
|
||||
TypeInit::Lng | TypeInit::Sng => 4,
|
||||
TypeInit::Dbl | TypeInit::Cur => 8,
|
||||
TypeInit::FixedStr(n) => *n as usize * UTF32_BREITE,
|
||||
TypeInit::Udt(i) => {
|
||||
let felder = &layouts.get(*i as usize)?.fields;
|
||||
let mut summe = 0;
|
||||
for f in felder {
|
||||
summe += breite(f, layouts)?;
|
||||
}
|
||||
summe
|
||||
}
|
||||
TypeInit::Str | TypeInit::Empty => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wert in den Puffer schreiben; liefert die belegte Bytezahl.
|
||||
pub fn wert_schreiben(
|
||||
puffer: &mut [u8],
|
||||
offset: usize,
|
||||
wert: &Value,
|
||||
typ: &TypeInit,
|
||||
layouts: &[UdtLayout],
|
||||
) -> Result<usize, RuntimeError> {
|
||||
let n = breite(typ, layouts).ok_or(RuntimeError::TYPE_MISMATCH)?;
|
||||
if offset + n > puffer.len() {
|
||||
return Err(RuntimeError(59)); // Bad record length
|
||||
}
|
||||
match (typ, wert) {
|
||||
(TypeInit::Int, v) => puffer[offset..offset + 2]
|
||||
.copy_from_slice(&(crate::value::as_f64(v) as i16).to_le_bytes()),
|
||||
(TypeInit::Lng, v) => puffer[offset..offset + 4]
|
||||
.copy_from_slice(&(crate::value::as_f64(v) as i32).to_le_bytes()),
|
||||
(TypeInit::Sng, v) => puffer[offset..offset + 4]
|
||||
.copy_from_slice(&(crate::value::as_f64(v) as f32).to_le_bytes()),
|
||||
(TypeInit::Dbl, v) => {
|
||||
puffer[offset..offset + 8].copy_from_slice(&crate::value::as_f64(v).to_le_bytes())
|
||||
}
|
||||
(TypeInit::Cur, Value::Cur(c)) => {
|
||||
puffer[offset..offset + 8].copy_from_slice(&c.to_le_bytes())
|
||||
}
|
||||
(TypeInit::Cur, v) => puffer[offset..offset + 8]
|
||||
.copy_from_slice(&crate::value::f64_to_cur(crate::value::as_f64(v))?.to_le_bytes()),
|
||||
(TypeInit::FixedStr(len), Value::Str(s)) => {
|
||||
feld_setzen(puffer, offset, *len as usize, s, false)
|
||||
}
|
||||
(TypeInit::Udt(i), Value::Rec(r)) => {
|
||||
let felder = &layouts.get(*i as usize).ok_or(RuntimeError::TYPE_MISMATCH)?.fields;
|
||||
let rec = r.borrow();
|
||||
let mut off = offset;
|
||||
for (ft, fv) in felder.iter().zip(rec.fields.iter()) {
|
||||
off += wert_schreiben(puffer, off, fv, ft, layouts)?;
|
||||
}
|
||||
}
|
||||
_ => return Err(RuntimeError::TYPE_MISMATCH),
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Wert aus dem Puffer lesen.
|
||||
pub fn wert_lesen(
|
||||
puffer: &[u8],
|
||||
offset: usize,
|
||||
typ: &TypeInit,
|
||||
layouts: &[UdtLayout],
|
||||
) -> Result<(Value, usize), RuntimeError> {
|
||||
let n = breite(typ, layouts).ok_or(RuntimeError::TYPE_MISMATCH)?;
|
||||
if offset + n > puffer.len() {
|
||||
return Err(RuntimeError(59));
|
||||
}
|
||||
let b = &puffer[offset..offset + n];
|
||||
let v = match typ {
|
||||
TypeInit::Int => Value::Int(i16::from_le_bytes([b[0], b[1]])),
|
||||
TypeInit::Lng => Value::Lng(i32::from_le_bytes([b[0], b[1], b[2], b[3]])),
|
||||
TypeInit::Sng => Value::Sng(f32::from_le_bytes([b[0], b[1], b[2], b[3]])),
|
||||
TypeInit::Dbl => Value::Dbl(f64::from_le_bytes(b[..8].try_into().unwrap())),
|
||||
TypeInit::Cur => Value::Cur(i64::from_le_bytes(b[..8].try_into().unwrap())),
|
||||
TypeInit::FixedStr(len) => {
|
||||
Value::Str(std::rc::Rc::from(feld_lesen(puffer, offset, *len as usize).as_str()))
|
||||
}
|
||||
TypeInit::Udt(i) => {
|
||||
let felder = &layouts.get(*i as usize).ok_or(RuntimeError::TYPE_MISMATCH)?.fields;
|
||||
let mut off = offset;
|
||||
let mut werte = Vec::with_capacity(felder.len());
|
||||
for ft in felder {
|
||||
let (v, k) = wert_lesen(puffer, off, ft, layouts)?;
|
||||
werte.push(v);
|
||||
off += k;
|
||||
}
|
||||
Value::Rec(std::rc::Rc::new(std::cell::RefCell::new(RecordObj {
|
||||
fields: werte,
|
||||
})))
|
||||
}
|
||||
TypeInit::Str | TypeInit::Empty => return Err(RuntimeError::TYPE_MISMATCH),
|
||||
};
|
||||
Ok((v, n))
|
||||
}
|
||||
|
||||
// ---- Pfade und Dateisystem --------------------------------------------------
|
||||
|
||||
/// Pfadtrennzeichen des Vorbilds (`\`) auf die Plattform abbilden.
|
||||
pub fn pfad_normieren(p: &str) -> PathBuf {
|
||||
if std::path::MAIN_SEPARATOR == '\\' {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
PathBuf::from(p.replace('\\', "/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn fehler_aus_io(e: std::io::Error) -> RuntimeError {
|
||||
use std::io::ErrorKind::*;
|
||||
match e.kind() {
|
||||
NotFound => RuntimeError(53), // File not found
|
||||
PermissionDenied => RuntimeError(70), // Permission denied
|
||||
AlreadyExists => RuntimeError(58), // File already exists
|
||||
_ => RuntimeError(57), // Device I/O error
|
||||
}
|
||||
}
|
||||
|
||||
/// `KILL` — Datei löschen.
|
||||
pub fn loeschen(pfad: &str) -> Result<(), RuntimeError> {
|
||||
std::fs::remove_file(pfad_normieren(pfad)).map_err(fehler_aus_io)
|
||||
}
|
||||
|
||||
/// `NAME alt AS neu`.
|
||||
pub fn umbenennen(alt: &str, neu: &str) -> Result<(), RuntimeError> {
|
||||
let (a, n) = (pfad_normieren(alt), pfad_normieren(neu));
|
||||
if !a.exists() {
|
||||
return Err(RuntimeError(53));
|
||||
}
|
||||
if n.exists() {
|
||||
return Err(RuntimeError(58)); // File already exists
|
||||
}
|
||||
std::fs::rename(a, n).map_err(fehler_aus_io)
|
||||
}
|
||||
|
||||
/// `MKDIR` / `RMDIR` / `CHDIR`.
|
||||
pub fn verzeichnis_anlegen(pfad: &str) -> Result<(), RuntimeError> {
|
||||
let p = pfad_normieren(pfad);
|
||||
if p.parent().is_some_and(|e| !e.as_os_str().is_empty() && !e.exists()) {
|
||||
return Err(RuntimeError(76)); // Path not found
|
||||
}
|
||||
std::fs::create_dir(p).map_err(fehler_aus_io)
|
||||
}
|
||||
|
||||
pub fn verzeichnis_entfernen(pfad: &str) -> Result<(), RuntimeError> {
|
||||
std::fs::remove_dir(pfad_normieren(pfad)).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => RuntimeError(76),
|
||||
_ => fehler_aus_io(e),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verzeichnis_wechseln(pfad: &str) -> Result<(), RuntimeError> {
|
||||
std::env::set_current_dir(pfad_normieren(pfad)).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => RuntimeError(76),
|
||||
_ => fehler_aus_io(e),
|
||||
})
|
||||
}
|
||||
|
||||
/// `CURDIR$`.
|
||||
pub fn aktuelles_verzeichnis() -> String {
|
||||
std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Verzeichnissuche für `DIR$` und `FILES`: alle Namen, die auf das Muster
|
||||
/// passen (`*` und `?` wie beim Vorbild).
|
||||
pub fn suchen(muster: &str) -> Result<Vec<String>, RuntimeError> {
|
||||
let p = pfad_normieren(muster);
|
||||
let (verzeichnis, maske) = match p.file_name() {
|
||||
Some(n) => (
|
||||
p.parent()
|
||||
.filter(|e| !e.as_os_str().is_empty())
|
||||
.map(|e| e.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from(".")),
|
||||
n.to_string_lossy().into_owned(),
|
||||
),
|
||||
None => (p.clone(), "*".to_string()),
|
||||
};
|
||||
let eintraege = std::fs::read_dir(&verzeichnis).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => RuntimeError(76),
|
||||
_ => fehler_aus_io(e),
|
||||
})?;
|
||||
let mut namen: Vec<String> = eintraege
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| passt(n, &maske))
|
||||
.collect();
|
||||
namen.sort();
|
||||
Ok(namen)
|
||||
}
|
||||
|
||||
/// Musterabgleich mit `*` (beliebig viele Zeichen) und `?` (genau eines).
|
||||
pub fn passt(name: &str, muster: &str) -> bool {
|
||||
let n: Vec<char> = name.chars().collect();
|
||||
let m: Vec<char> = muster.chars().collect();
|
||||
fn rek(n: &[char], m: &[char]) -> bool {
|
||||
match m.first() {
|
||||
None => n.is_empty(),
|
||||
Some('*') => rek(n, &m[1..]) || (!n.is_empty() && rek(&n[1..], m)),
|
||||
Some('?') => !n.is_empty() && rek(&n[1..], &m[1..]),
|
||||
Some(c) => {
|
||||
!n.is_empty()
|
||||
&& n[0].eq_ignore_ascii_case(c)
|
||||
&& rek(&n[1..], &m[1..])
|
||||
}
|
||||
}
|
||||
}
|
||||
rek(&n, &m)
|
||||
}
|
||||
|
||||
/// Elemente einer `INPUT #`-Zeile trennen: Komma trennt, Anführungszeichen
|
||||
/// klammern, Leerraum am Rand fällt weg.
|
||||
pub fn felder_trennen(zeile: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut aktuell = String::new();
|
||||
let mut in_quote = false;
|
||||
let mut hatte_quote = false;
|
||||
for c in zeile.chars() {
|
||||
match c {
|
||||
'"' if !in_quote && aktuell.trim().is_empty() => {
|
||||
in_quote = true;
|
||||
hatte_quote = true;
|
||||
aktuell.clear();
|
||||
}
|
||||
'"' if in_quote => in_quote = false,
|
||||
',' if !in_quote => {
|
||||
out.push(if hatte_quote {
|
||||
std::mem::take(&mut aktuell)
|
||||
} else {
|
||||
std::mem::take(&mut aktuell).trim().to_string()
|
||||
});
|
||||
hatte_quote = false;
|
||||
}
|
||||
_ => aktuell.push(c),
|
||||
}
|
||||
}
|
||||
out.push(if hatte_quote {
|
||||
aktuell
|
||||
} else {
|
||||
aktuell.trim().to_string()
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Ein Element für `WRITE #` aufbereiten: Strings in Anführungszeichen.
|
||||
pub fn write_element(text: &str, ist_string: bool) -> String {
|
||||
if ist_string {
|
||||
format!("\"{text}\"")
|
||||
} else {
|
||||
text.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn muster_mit_stern_und_fragezeichen() {
|
||||
assert!(passt("test.txt", "*.txt"));
|
||||
assert!(passt("test.txt", "t?st.txt"));
|
||||
assert!(!passt("test.txt", "*.bas"));
|
||||
assert!(passt("TEST.TXT", "*.txt")); // Vorbild: Groß/klein egal
|
||||
assert!(passt("a", "*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn festes_feld_ist_inhaltsunabhaengig_gleich_lang() {
|
||||
let mut p = vec![0u8; 10 * UTF32_BREITE];
|
||||
feld_setzen(&mut p, 0, 10, "abc", false);
|
||||
assert_eq!(feld_lesen(&p, 0, 10), "abc ");
|
||||
feld_setzen(&mut p, 0, 10, "äöüßÄÖÜ", false);
|
||||
assert_eq!(feld_lesen(&p, 0, 10), "äöüßÄÖÜ ");
|
||||
// Der Puffer hat in beiden Fällen dieselbe Länge.
|
||||
assert_eq!(p.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rset_schneidet_ab_und_richtet_rechts_aus() {
|
||||
let mut p = vec![0u8; 5 * UTF32_BREITE];
|
||||
feld_setzen(&mut p, 0, 5, "ABCDEFG", true);
|
||||
assert_eq!(feld_lesen(&p, 0, 5), "ABCDE");
|
||||
feld_setzen(&mut p, 0, 5, "xy", true);
|
||||
assert_eq!(feld_lesen(&p, 0, 5), " xy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn felder_einer_input_zeile() {
|
||||
assert_eq!(felder_trennen("a,b,c"), vec!["a", "b", "c"]);
|
||||
assert_eq!(felder_trennen(" 1 , 2 "), vec!["1", "2"]);
|
||||
assert_eq!(felder_trennen("\"a,b\",5"), vec!["a,b", "5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modus_aus_kurzsyntax() {
|
||||
assert_eq!(Modus::aus_text("I"), Some(Modus::Input));
|
||||
assert_eq!(Modus::aus_text("output"), Some(Modus::Output));
|
||||
assert_eq!(Modus::aus_text("x"), None);
|
||||
}
|
||||
}
|
||||
|
||||
377
crates/tb-runtime/src/finanz.rs
Normal file
377
crates/tb-runtime/src/finanz.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
//! Finanzmathematische Funktionen des Vorbilds (`FV#`, `PV#`, `Pmt#`,
|
||||
//! `IPmt#`, `PPmt#`, `NPer#`, `Rate#`, `NPV#`, `IRR#`, `MIRR#`, `SLN#`,
|
||||
//! `SYD#`, `DDB#`).
|
||||
//!
|
||||
//! Vorzeichenkonvention wie beim Vorbild: ausgehende Zahlungen sind negativ,
|
||||
//! eingehende positiv. Das Fälligkeitsargument (`faellig`) ist 0 für
|
||||
//! nachschüssige und 1 für vorschüssige Zahlung. Gerechnet wird in DOUBLE.
|
||||
//!
|
||||
//! `Rate#` und `IRR#` nähern sich iterativ; die Abbruchbedingung steht in
|
||||
//! [`ITERATIONEN`] und [`TOLERANZ`] und ist in docs/bibliothek.md
|
||||
//! dokumentiert.
|
||||
|
||||
use crate::errors::RuntimeError;
|
||||
|
||||
/// Höchstzahl der Näherungsschritte von `Rate#` und `IRR#`.
|
||||
pub const ITERATIONEN: usize = 100;
|
||||
/// Abbruchschranke der Näherung (Betrag der Restabweichung).
|
||||
pub const TOLERANZ: f64 = 1e-9;
|
||||
|
||||
type R = Result<f64, RuntimeError>;
|
||||
|
||||
fn ungueltig() -> RuntimeError {
|
||||
RuntimeError::ILLEGAL_FUNCTION_CALL
|
||||
}
|
||||
|
||||
/// `(1 + zins)^perioden`.
|
||||
fn faktor(zins: f64, perioden: f64) -> f64 {
|
||||
(1.0 + zins).powf(perioden)
|
||||
}
|
||||
|
||||
/// Barwertfaktor der Rentenzahlungen.
|
||||
fn rentenfaktor(zins: f64, perioden: f64, faellig: f64) -> f64 {
|
||||
if zins == 0.0 {
|
||||
perioden
|
||||
} else {
|
||||
(1.0 + zins * faellig) * (faktor(zins, perioden) - 1.0) / zins
|
||||
}
|
||||
}
|
||||
|
||||
/// `FV#` — Endwert.
|
||||
pub fn fv(zins: f64, perioden: f64, zahlung: f64, barwert: f64, faellig: f64) -> R {
|
||||
if zins <= -1.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
Ok(-(barwert * faktor(zins, perioden) + zahlung * rentenfaktor(zins, perioden, faellig)))
|
||||
}
|
||||
|
||||
/// `PV#` — Barwert.
|
||||
pub fn pv(zins: f64, perioden: f64, zahlung: f64, endwert: f64, faellig: f64) -> R {
|
||||
if zins <= -1.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
Ok(-(endwert + zahlung * rentenfaktor(zins, perioden, faellig)) / faktor(zins, perioden))
|
||||
}
|
||||
|
||||
/// `Pmt#` — Zahlung je Periode.
|
||||
pub fn pmt(zins: f64, perioden: f64, barwert: f64, endwert: f64, faellig: f64) -> R {
|
||||
if perioden == 0.0 || zins <= -1.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
let rf = rentenfaktor(zins, perioden, faellig);
|
||||
if rf == 0.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
Ok(-(endwert + barwert * faktor(zins, perioden)) / rf)
|
||||
}
|
||||
|
||||
/// `NPer#` — Anzahl Perioden.
|
||||
pub fn nper(zins: f64, zahlung: f64, barwert: f64, endwert: f64, faellig: f64) -> R {
|
||||
if zins <= -1.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
if zins == 0.0 {
|
||||
if zahlung == 0.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
return Ok(-(barwert + endwert) / zahlung);
|
||||
}
|
||||
let z = zahlung * (1.0 + zins * faellig);
|
||||
let zaehler = z - endwert * zins;
|
||||
let nenner = barwert * zins + z;
|
||||
if nenner == 0.0 || zaehler / nenner <= 0.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
Ok((zaehler / nenner).ln() / (1.0 + zins).ln())
|
||||
}
|
||||
|
||||
/// `IPmt#` — Zinsanteil der Periode `periode` (1-basiert).
|
||||
pub fn ipmt(
|
||||
zins: f64,
|
||||
periode: f64,
|
||||
perioden: f64,
|
||||
barwert: f64,
|
||||
endwert: f64,
|
||||
faellig: f64,
|
||||
) -> R {
|
||||
if periode < 1.0 || periode > perioden {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
let zahlung = pmt(zins, perioden, barwert, endwert, faellig)?;
|
||||
if faellig != 0.0 && periode == 1.0 {
|
||||
// Vorschüssig fällt in der ersten Periode kein Zins an.
|
||||
return Ok(0.0);
|
||||
}
|
||||
let vorher = if faellig != 0.0 {
|
||||
fv(zins, periode - 2.0, zahlung, barwert, faellig)?
|
||||
} else {
|
||||
fv(zins, periode - 1.0, zahlung, barwert, faellig)?
|
||||
};
|
||||
Ok(vorher * zins)
|
||||
}
|
||||
|
||||
/// `PPmt#` — Tilgungsanteil der Periode.
|
||||
pub fn ppmt(
|
||||
zins: f64,
|
||||
periode: f64,
|
||||
perioden: f64,
|
||||
barwert: f64,
|
||||
endwert: f64,
|
||||
faellig: f64,
|
||||
) -> R {
|
||||
let zahlung = pmt(zins, perioden, barwert, endwert, faellig)?;
|
||||
let zinsanteil = ipmt(zins, periode, perioden, barwert, endwert, faellig)?;
|
||||
Ok(zahlung - zinsanteil)
|
||||
}
|
||||
|
||||
/// `Rate#` — Zinssatz je Periode, iterativ genähert.
|
||||
pub fn rate(
|
||||
perioden: f64,
|
||||
zahlung: f64,
|
||||
barwert: f64,
|
||||
endwert: f64,
|
||||
faellig: f64,
|
||||
schaetzung: f64,
|
||||
) -> R {
|
||||
if perioden <= 0.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
// Sekantenverfahren auf f(r) = FV(r) − Sollendwert.
|
||||
let f = |r: f64| -> Option<f64> {
|
||||
if r <= -1.0 {
|
||||
return None;
|
||||
}
|
||||
Some(barwert * faktor(r, perioden) + zahlung * rentenfaktor(r, perioden, faellig) + endwert)
|
||||
};
|
||||
let mut r0 = schaetzung;
|
||||
let mut r1 = if schaetzung == 0.0 { 0.1 } else { schaetzung * 1.1 };
|
||||
let (mut f0, mut f1) = match (f(r0), f(r1)) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => return Err(ungueltig()),
|
||||
};
|
||||
for _ in 0..ITERATIONEN {
|
||||
if f1.abs() < TOLERANZ {
|
||||
return Ok(r1);
|
||||
}
|
||||
if (f1 - f0).abs() < f64::MIN_POSITIVE {
|
||||
break;
|
||||
}
|
||||
let r2 = r1 - f1 * (r1 - r0) / (f1 - f0);
|
||||
let Some(f2) = f(r2) else { break };
|
||||
r0 = r1;
|
||||
f0 = f1;
|
||||
r1 = r2;
|
||||
f1 = f2;
|
||||
}
|
||||
if f1.abs() < TOLERANZ * 1e3 {
|
||||
Ok(r1)
|
||||
} else {
|
||||
Err(ungueltig())
|
||||
}
|
||||
}
|
||||
|
||||
/// `NPV#` — Kapitalwert einer Zahlungsreihe (erste Zahlung am Ende der
|
||||
/// ersten Periode).
|
||||
pub fn npv(zins: f64, werte: &[f64]) -> R {
|
||||
if zins <= -1.0 || werte.is_empty() {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
let mut summe = 0.0;
|
||||
let mut nenner = 1.0 + zins;
|
||||
for w in werte {
|
||||
summe += w / nenner;
|
||||
nenner *= 1.0 + zins;
|
||||
}
|
||||
Ok(summe)
|
||||
}
|
||||
|
||||
/// `IRR#` — interner Zinsfuß, iterativ genähert.
|
||||
pub fn irr(werte: &[f64], schaetzung: f64) -> R {
|
||||
// Ohne Vorzeichenwechsel gibt es keine Lösung.
|
||||
let hat_plus = werte.iter().any(|w| *w > 0.0);
|
||||
let hat_minus = werte.iter().any(|w| *w < 0.0);
|
||||
if !hat_plus || !hat_minus {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
let f = |r: f64| -> Option<f64> {
|
||||
if r <= -1.0 {
|
||||
return None;
|
||||
}
|
||||
let mut summe = 0.0;
|
||||
let mut nenner = 1.0;
|
||||
for w in werte {
|
||||
summe += w / nenner;
|
||||
nenner *= 1.0 + r;
|
||||
}
|
||||
Some(summe)
|
||||
};
|
||||
let mut r0 = schaetzung;
|
||||
let mut r1 = if schaetzung == 0.0 { 0.1 } else { schaetzung * 1.1 };
|
||||
let (mut f0, mut f1) = match (f(r0), f(r1)) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => return Err(ungueltig()),
|
||||
};
|
||||
for _ in 0..ITERATIONEN {
|
||||
if f1.abs() < TOLERANZ {
|
||||
return Ok(r1);
|
||||
}
|
||||
if (f1 - f0).abs() < f64::MIN_POSITIVE {
|
||||
break;
|
||||
}
|
||||
let r2 = r1 - f1 * (r1 - r0) / (f1 - f0);
|
||||
let Some(f2) = f(r2) else { break };
|
||||
r0 = r1;
|
||||
f0 = f1;
|
||||
r1 = r2;
|
||||
f1 = f2;
|
||||
}
|
||||
if f1.abs() < TOLERANZ * 1e3 {
|
||||
Ok(r1)
|
||||
} else {
|
||||
Err(ungueltig())
|
||||
}
|
||||
}
|
||||
|
||||
/// `MIRR#` — modifizierter interner Zinsfuß.
|
||||
pub fn mirr(werte: &[f64], finanzierungszins: f64, wiederanlagezins: f64) -> R {
|
||||
let n = werte.len();
|
||||
if n < 2 || finanzierungszins <= -1.0 || wiederanlagezins <= -1.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
let negative: Vec<f64> = werte.iter().map(|w| w.min(0.0)).collect();
|
||||
let positive: Vec<f64> = werte.iter().map(|w| w.max(0.0)).collect();
|
||||
let bw_neg = npv(finanzierungszins, &negative)?;
|
||||
let bw_pos = npv(wiederanlagezins, &positive)?;
|
||||
if bw_neg == 0.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
let n = n as f64;
|
||||
let verhaeltnis = -bw_pos * (1.0 + wiederanlagezins).powf(n) / (bw_neg * (1.0 + finanzierungszins));
|
||||
if verhaeltnis <= 0.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
Ok(verhaeltnis.powf(1.0 / (n - 1.0)) - 1.0)
|
||||
}
|
||||
|
||||
/// `SLN#` — lineare Abschreibung.
|
||||
pub fn sln(anschaffung: f64, restwert: f64, nutzungsdauer: f64) -> R {
|
||||
if nutzungsdauer == 0.0 {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
Ok((anschaffung - restwert) / nutzungsdauer)
|
||||
}
|
||||
|
||||
/// `SYD#` — arithmetisch-degressive Abschreibung.
|
||||
pub fn syd(anschaffung: f64, restwert: f64, nutzungsdauer: f64, periode: f64) -> R {
|
||||
if nutzungsdauer <= 0.0 || periode < 1.0 || periode > nutzungsdauer {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
Ok((anschaffung - restwert) * (nutzungsdauer - periode + 1.0) * 2.0
|
||||
/ (nutzungsdauer * (nutzungsdauer + 1.0)))
|
||||
}
|
||||
|
||||
/// `DDB#` — geometrisch-degressive Abschreibung (doppelter linearer Satz).
|
||||
pub fn ddb(anschaffung: f64, restwert: f64, nutzungsdauer: f64, periode: f64) -> R {
|
||||
if nutzungsdauer <= 0.0 || periode < 1.0 || periode > nutzungsdauer {
|
||||
return Err(ungueltig());
|
||||
}
|
||||
let mut buchwert = anschaffung;
|
||||
let mut betrag = 0.0;
|
||||
let satz = 2.0 / nutzungsdauer;
|
||||
for _ in 0..(periode as u64) {
|
||||
betrag = (buchwert * satz).min(buchwert - restwert).max(0.0);
|
||||
buchwert -= betrag;
|
||||
}
|
||||
Ok(betrag)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nah(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} ≠ {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endwert_einer_rentenzahlung() {
|
||||
// 10 Perioden, 5 % Zins, Zahlung 100 nachschüssig, Barwert 0.
|
||||
nah(fv(0.05, 10.0, -100.0, 0.0, 0.0).unwrap(), 1257.789253);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn barwert_und_endwert_sind_gegenlaeufig() {
|
||||
let b = pv(0.05, 10.0, -100.0, 0.0, 0.0).unwrap();
|
||||
nah(fv(0.05, 10.0, -100.0, b, 0.0).unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zins_und_tilgung_ergeben_die_zahlung() {
|
||||
let (r, n, bw) = (0.05, 10.0, 1000.0);
|
||||
let z = pmt(r, n, bw, 0.0, 0.0).unwrap();
|
||||
for periode in 1..=10 {
|
||||
let i = ipmt(r, periode as f64, n, bw, 0.0, 0.0).unwrap();
|
||||
let t = ppmt(r, periode as f64, n, bw, 0.0, 0.0).unwrap();
|
||||
nah(i + t, z);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zinssatz_wird_zurueckgerechnet() {
|
||||
let z = pmt(0.05, 10.0, 1000.0, 0.0, 0.0).unwrap();
|
||||
nah(rate(10.0, z, 1000.0, 0.0, 0.0, 0.1).unwrap(), 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nper_zaehlt_die_perioden() {
|
||||
let z = pmt(0.05, 10.0, 1000.0, 0.0, 0.0).unwrap();
|
||||
nah(nper(0.05, z, 1000.0, 0.0, 0.0).unwrap(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kapitalwert_und_interner_zinsfuss() {
|
||||
let reihe = [-1000.0, 400.0, 400.0, 400.0];
|
||||
let r = irr(&reihe, 0.1).unwrap();
|
||||
// Bei r ist der Kapitalwert (inkl. Anfangsauszahlung) null.
|
||||
let mut summe = 0.0;
|
||||
let mut nenner = 1.0;
|
||||
for w in reihe {
|
||||
summe += w / nenner;
|
||||
nenner *= 1.0 + r;
|
||||
}
|
||||
nah(summe, 0.0);
|
||||
nah(npv(0.05, &[100.0, 100.0]).unwrap(), 185.941043);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irr_ohne_vorzeichenwechsel_ist_fehler() {
|
||||
assert!(irr(&[100.0, 200.0], 0.1).is_err());
|
||||
assert!(irr(&[-100.0, -200.0], 0.1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abschreibungen() {
|
||||
nah(sln(1000.0, 100.0, 10.0).unwrap(), 90.0);
|
||||
// SYD: Summe über alle Perioden = Abschreibungsvolumen.
|
||||
let summe: f64 = (1..=10).map(|p| syd(1000.0, 100.0, 10.0, p as f64).unwrap()).sum();
|
||||
nah(summe, 900.0);
|
||||
nah(syd(1000.0, 100.0, 10.0, 1.0).unwrap(), 163.636363);
|
||||
nah(ddb(1000.0, 100.0, 10.0, 1.0).unwrap(), 200.0);
|
||||
nah(ddb(1000.0, 100.0, 10.0, 2.0).unwrap(), 160.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungueltige_argumente() {
|
||||
assert!(sln(1.0, 0.0, 0.0).is_err());
|
||||
assert!(syd(1.0, 0.0, 10.0, 11.0).is_err());
|
||||
assert!(ipmt(0.05, 11.0, 10.0, 1000.0, 0.0, 0.0).is_err());
|
||||
assert!(pmt(0.05, 0.0, 1000.0, 0.0, 0.0).is_err());
|
||||
assert!(nper(0.0, 0.0, 1000.0, 0.0, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirr_liegt_zwischen_den_zinsen() {
|
||||
let r = mirr(&[-1000.0, 400.0, 400.0, 400.0], 0.05, 0.06).unwrap();
|
||||
assert!(r > 0.0 && r < 0.3, "{r}");
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,117 @@
|
||||
//! `Host`-Abstraktion für Konsolen-E/A (Design-Entscheidung D3):
|
||||
//! Alle Konsolenwirkungen der VM laufen über dieses Trait. Host-Aufrufe
|
||||
//! dürfen blockieren; die Abbruchprüfung (Strg+Untbr) obliegt dem Host.
|
||||
//! `Host`-Abstraktion für den Textbildschirm.
|
||||
//!
|
||||
//! Der Host ist bewusst dumm: er zeigt den Zellenpuffer an und liefert
|
||||
//! Ereignisse. Alle Bildschirmwirkungen entstehen in der Laufzeitbibliothek
|
||||
//! auf [`TextScreen`] — ein Zeichenstrom könnte `LOCATE`, `COLOR`, `CSRLIN`
|
||||
//! oder das Zurücklesen per `SCREEN(z, s)` nicht transportieren
|
||||
//! (Entscheidung 2026-09-03, D2).
|
||||
//!
|
||||
//! Host-Aufrufe dürfen blockieren; kooperative Zustellpunkte bringt erst die
|
||||
//! Ereignisschleife der Forms-Engine (Phase 4).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, Write as _};
|
||||
|
||||
use crate::screen::TextScreen;
|
||||
|
||||
/// Was vom Terminal hereinkommt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Ereignis {
|
||||
/// Tastendruck in `INKEY$`-Form: ein Zeichen für Zeichentasten,
|
||||
/// Nullzeichen + Kennung für Sondertasten.
|
||||
Taste(String),
|
||||
/// Die Darstellungsfläche hat ihre Größe geändert.
|
||||
Groesse { cols: usize, rows: usize },
|
||||
/// Abbruchwunsch (Strg+Untbr).
|
||||
Abbruch,
|
||||
/// Eingabeende — es kommt nichts mehr.
|
||||
Ende,
|
||||
}
|
||||
|
||||
/// Steuerzeichen in `INKEY$`-Form (Kodierung des Vorbilds).
|
||||
pub mod taste {
|
||||
pub const ENTER: &str = "\r";
|
||||
pub const BACKSPACE: &str = "\u{8}";
|
||||
pub const ESC: &str = "\u{1b}";
|
||||
pub const TAB: &str = "\t";
|
||||
|
||||
/// Sondertaste: Nullzeichen + Kennung (F1 = 59, F2 = 60 …).
|
||||
pub fn sonder(code: u8) -> String {
|
||||
format!("\0{}", code as char)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Host {
|
||||
/// Text ausgeben (ohne implizite Zeilenumbrüche).
|
||||
fn write(&mut self, s: &str);
|
||||
/// Eine Eingabezeile lesen (ohne Zeilenende); `None` = Eingabeende.
|
||||
fn read_line(&mut self) -> Option<String>;
|
||||
/// Abbruchwunsch (Strg+Untbr)? Wird an Anweisungsgrenzen geprüft.
|
||||
fn interrupted(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `SLEEP` — blockierend im Host.
|
||||
fn sleep(&mut self, _secs: f64) {}
|
||||
/// Aktuellen Bildschirmzustand anzeigen.
|
||||
fn present(&mut self, screen: &TextScreen);
|
||||
|
||||
/// Nächstes Ereignis holen. Mit `blockierend = false` sofort `None`,
|
||||
/// wenn nichts anliegt (Grundlage von `INKEY$`).
|
||||
fn next_event(&mut self, blockierend: bool) -> Option<Ereignis>;
|
||||
}
|
||||
|
||||
/// Konsolen-Host für `tbc run`: stdout/stdin.
|
||||
#[derive(Default)]
|
||||
pub struct ConsoleHost;
|
||||
|
||||
impl Host for ConsoleHost {
|
||||
fn write(&mut self, s: &str) {
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = out.write_all(s.as_bytes());
|
||||
let _ = out.flush();
|
||||
}
|
||||
|
||||
fn read_line(&mut self) -> Option<String> {
|
||||
let mut line = String::new();
|
||||
match std::io::stdin().lock().read_line(&mut line) {
|
||||
Ok(0) => None,
|
||||
Ok(_) => {
|
||||
while line.ends_with('\n') || line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
Some(line)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sleep(&mut self, secs: f64) {
|
||||
if secs > 0.0 {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f64(secs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture-Host für Tests: zeichnet die Ausgabe byte-genau auf und
|
||||
/// liefert vorbereitete Eingabezeilen.
|
||||
/// Host ohne Terminal: hält den Bildschirmzustand fest und speist
|
||||
/// vorbereitete Ereignisse ein. Grundlage des Korpus-Harness.
|
||||
#[derive(Default)]
|
||||
pub struct CaptureHost {
|
||||
pub output: String,
|
||||
pub input: VecDeque<String>,
|
||||
/// Zuletzt angezeigter Bildschirm.
|
||||
pub screen: Option<TextScreen>,
|
||||
/// Wie oft angezeigt wurde (Nachweis der Zustellpunkte).
|
||||
pub presents: usize,
|
||||
ereignisse: VecDeque<Ereignis>,
|
||||
/// Ereignisse, die erst nach der n-ten Anzeige eingereiht werden —
|
||||
/// so lässt sich eine Größenänderung mitten im Lauf nachstellen.
|
||||
verzoegert: Vec<(usize, Ereignis)>,
|
||||
}
|
||||
|
||||
impl CaptureHost {
|
||||
/// Host mit vorbereiteten Eingabezeilen: jede Zeile wird zu ihren
|
||||
/// Zeichen plus Enter.
|
||||
pub fn with_input(lines: &[&str]) -> Self {
|
||||
CaptureHost {
|
||||
output: String::new(),
|
||||
input: lines.iter().map(|s| s.to_string()).collect(),
|
||||
let mut h = CaptureHost::default();
|
||||
for l in lines {
|
||||
h.tippe(l);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Text als Folge von Tastendrücken anhängen, abgeschlossen mit Enter.
|
||||
pub fn tippe(&mut self, text: &str) {
|
||||
for c in text.chars() {
|
||||
self.ereignisse.push_back(Ereignis::Taste(c.to_string()));
|
||||
}
|
||||
self.ereignisse
|
||||
.push_back(Ereignis::Taste(taste::ENTER.to_string()));
|
||||
}
|
||||
|
||||
/// Beliebiges Ereignis einreihen (Größenänderung, Abbruch, Sondertaste).
|
||||
pub fn ereignis(&mut self, e: Ereignis) {
|
||||
self.ereignisse.push_back(e);
|
||||
}
|
||||
|
||||
/// Ereignis erst nach der `nach`-ten Anzeige einreihen.
|
||||
pub fn ereignis_nach(&mut self, nach: usize, e: Ereignis) {
|
||||
self.verzoegert.push((nach, e));
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for CaptureHost {
|
||||
fn write(&mut self, s: &str) {
|
||||
self.output.push_str(s);
|
||||
fn present(&mut self, screen: &TextScreen) {
|
||||
self.screen = Some(screen.clone());
|
||||
self.presents += 1;
|
||||
let faellig = self.presents;
|
||||
let mut i = 0;
|
||||
while i < self.verzoegert.len() {
|
||||
if self.verzoegert[i].0 <= faellig {
|
||||
let (_, e) = self.verzoegert.remove(i);
|
||||
self.ereignisse.push_back(e);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_line(&mut self) -> Option<String> {
|
||||
self.input.pop_front()
|
||||
fn next_event(&mut self, _blockierend: bool) -> Option<Ereignis> {
|
||||
self.ereignisse.pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +120,55 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capture_host_zeichnet_bytegenau_auf() {
|
||||
fn capture_host_haelt_zeichen_und_attribut() {
|
||||
let mut h = CaptureHost::default();
|
||||
h.write(" 1 2 ");
|
||||
h.write("\n");
|
||||
h.write("x");
|
||||
assert_eq!(h.output, " 1 2 \nx");
|
||||
let mut s = TextScreen::new();
|
||||
s.locate(5, 10).unwrap();
|
||||
s.set_color(14, 1);
|
||||
s.print("x");
|
||||
h.present(&s);
|
||||
let gehalten = h.screen.as_ref().unwrap();
|
||||
let z = gehalten.cell(5, 10);
|
||||
assert_eq!(z.ch, 'x');
|
||||
assert_eq!((z.fg, z.bg), (14, 1));
|
||||
assert_eq!(h.presents, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verzoegertes_ereignis_kommt_nach_der_anzeige() {
|
||||
let mut h = CaptureHost::default();
|
||||
h.ereignis_nach(2, Ereignis::Groesse { cols: 120, rows: 40 });
|
||||
let s = TextScreen::new();
|
||||
h.present(&s);
|
||||
assert_eq!(h.next_event(false), None);
|
||||
h.present(&s);
|
||||
assert_eq!(
|
||||
h.next_event(false),
|
||||
Some(Ereignis::Groesse { cols: 120, rows: 40 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groessenaenderung_und_abbruch_als_ereignis() {
|
||||
let mut h = CaptureHost::default();
|
||||
h.ereignis(Ereignis::Groesse { cols: 120, rows: 40 });
|
||||
h.ereignis(Ereignis::Abbruch);
|
||||
assert_eq!(
|
||||
h.next_event(true),
|
||||
Some(Ereignis::Groesse { cols: 120, rows: 40 })
|
||||
);
|
||||
assert_eq!(h.next_event(true), Some(Ereignis::Abbruch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vorbereitete_zeile_wird_zu_tasten() {
|
||||
let mut h = CaptureHost::with_input(&["ab"]);
|
||||
assert_eq!(h.next_event(true), Some(Ereignis::Taste("a".into())));
|
||||
assert_eq!(h.next_event(true), Some(Ereignis::Taste("b".into())));
|
||||
assert_eq!(
|
||||
h.next_event(true),
|
||||
Some(Ereignis::Taste(taste::ENTER.into()))
|
||||
);
|
||||
assert_eq!(h.next_event(false), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ pub mod console; // Druckzustand: Zonen, TAB/SPC, Zahlenausgabe
|
||||
pub mod errors; // Laufzeitfehler-Codes und -Meldungen des Vorbilds
|
||||
pub mod fileio; // OPEN/CLOSE/PRINT#/INPUT#/GET/PUT, sequenziell/random/binär
|
||||
pub mod format; // Zahlendarstellung (PRINT/STR$) und VAL
|
||||
pub mod host; // Host-Trait (Konsole, Capture) — Entscheidung D3
|
||||
pub mod host;
|
||||
pub mod screen;
|
||||
pub mod finanz; // Finanzmathematik (FV#, PV#, Pmt#, IRR# …)
|
||||
pub mod using; // PRINT USING / FORMAT$ — Formatzeichenketten
|
||||
pub mod snapshot; // Bildschirmzustand als vergleichbarer Text (Korpus) // Zellenpuffer des Textbildschirms (ohne Terminalbezug) // Host-Trait (Konsole, Capture) — Entscheidung D3
|
||||
pub mod datetime; // DATE$, TIME$, TIMER
|
||||
pub mod math; // Arithmetik, Rundung (Banker's Rounding), RND/RANDOMIZE …
|
||||
pub mod strings; // LEFT$, MID$, INSTR, STR$, VAL, Formatierung mit PRINT USING …
|
||||
|
||||
557
crates/tb-runtime/src/screen.rs
Normal file
557
crates/tb-runtime/src/screen.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
//! Textbildschirm-Emulation: Zellenpuffer (Unicode-Zeichen + Farbattribut),
|
||||
//! Cursor, Scrollen.
|
||||
//!
|
||||
//! Der Puffer liegt in der Laufzeitbibliothek und ist frei von jeder
|
||||
//! Terminal-Abhängigkeit: `PRINT`, `LOCATE`, `CLS`, `COLOR` und `SCREEN()`
|
||||
//! sind Bibliothekselemente und müssen ihn erreichen, und jedes
|
||||
//! Bildschirmverhalten bleibt ohne Terminal prüfbar. Die Darstellung liegt in
|
||||
//! `tb-ui` (`ScreenWidget`).
|
||||
//!
|
||||
//! Entscheidungen (siehe PLAN.md):
|
||||
//! - durchgängig Unicode, keine CP437-Emulation
|
||||
//! - **dynamische Größe**: der Bildschirm folgt der Terminalgröße; unterhalb
|
||||
//! der Mindestgröße (80×25) wird nur ein Hinweis gerendert (btop-artig),
|
||||
//! größere Terminals werden voll genutzt.
|
||||
//!
|
||||
//! Zeichen belegen so viele Zellen, wie ihre Darstellungsbreite verlangt
|
||||
//! (Entscheidung 2026-09-02): schmale Zeichen eine, breite (Emoji, CJK)
|
||||
//! zwei. Die zweite Zelle ist als Fortsetzung markiert und wird nie
|
||||
//! eigenständig beschrieben.
|
||||
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
/// Mindestgröße; darunter wird nur ein Hinweis angezeigt.
|
||||
pub const MIN_COLS: usize = 80;
|
||||
pub const MIN_ROWS: usize = 25;
|
||||
|
||||
/// Eine Bildschirmzelle: Zeichen plus klassisches Farbattribut.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Cell {
|
||||
pub ch: char,
|
||||
/// Vordergrund 0–15 (klassische Palette).
|
||||
pub fg: u8,
|
||||
/// Hintergrund 0–7.
|
||||
pub bg: u8,
|
||||
/// Zweite Hälfte eines breiten Zeichens. Solche Zellen tragen kein
|
||||
/// eigenes Zeichen und werden bei der Darstellung übersprungen.
|
||||
pub fortsetzung: bool,
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
fn default() -> Self {
|
||||
Cell { ch: ' ', fg: 7, bg: 0, fortsetzung: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Der emulierte Textbildschirm mit dynamischer Größe.
|
||||
///
|
||||
/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile, Spalte),
|
||||
/// wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextScreen {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
cells: Vec<Cell>,
|
||||
/// Cursorposition, 0-basiert intern.
|
||||
cur_row: usize,
|
||||
cur_col: usize,
|
||||
pub cursor_visible: bool,
|
||||
/// Aktuelle Ausgabefarben (`COLOR`).
|
||||
pub fg: u8,
|
||||
pub bg: u8,
|
||||
/// Scrollbereich (`VIEW PRINT`), 0-basiert inklusiv.
|
||||
view_top: usize,
|
||||
view_bottom: usize,
|
||||
/// true, solange kein eigenes `VIEW PRINT` gesetzt ist — der
|
||||
/// Scrollbereich folgt dann der Bildschirmgröße.
|
||||
view_full: bool,
|
||||
/// Je Zeile die größte beschriebene Spalte (0-basiert), `None` für
|
||||
/// unberührte Zeilen. Grundlage des getrimmten Snapshots: nachgestellte
|
||||
/// Leerzeichen, die `PRINT` selbst ausgibt, zählen als belegt, ein nie
|
||||
/// beschriebener Zeilenrest nicht.
|
||||
belegt: Vec<Option<usize>>,
|
||||
/// Seit der letzten Anzeige verändert? Der Host wird nur dann zum
|
||||
/// Neuzeichnen aufgefordert.
|
||||
veraendert: bool,
|
||||
}
|
||||
|
||||
impl Default for TextScreen {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextScreen {
|
||||
/// Bildschirm in Mindestgröße (80×25).
|
||||
pub fn new() -> Self {
|
||||
Self::with_size(MIN_COLS, MIN_ROWS)
|
||||
}
|
||||
|
||||
/// Bildschirm in gegebener Größe (wird auf die Mindestgröße angehoben).
|
||||
pub fn with_size(cols: usize, rows: usize) -> Self {
|
||||
let cols = cols.max(MIN_COLS);
|
||||
let rows = rows.max(MIN_ROWS);
|
||||
TextScreen {
|
||||
cols,
|
||||
rows,
|
||||
cells: vec![Cell::default(); cols * rows],
|
||||
cur_row: 0,
|
||||
cur_col: 0,
|
||||
cursor_visible: true,
|
||||
fg: 7,
|
||||
bg: 0,
|
||||
view_top: 0,
|
||||
view_bottom: rows - 1,
|
||||
view_full: true,
|
||||
belegt: vec![None; rows],
|
||||
veraendert: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
self.cols
|
||||
}
|
||||
pub fn rows(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
|
||||
/// An neue Terminalgröße anpassen: Inhalt bleibt oben links erhalten,
|
||||
/// neue Zellen sind leer. Unterhalb der Mindestgröße bleibt der Puffer
|
||||
/// bei 80×25 (das Widget zeigt dann den Zu-klein-Hinweis).
|
||||
pub fn resize(&mut self, cols: usize, rows: usize) {
|
||||
self.veraendert = true;
|
||||
let cols = cols.max(MIN_COLS);
|
||||
let rows = rows.max(MIN_ROWS);
|
||||
if cols == self.cols && rows == self.rows {
|
||||
return;
|
||||
}
|
||||
let mut cells =
|
||||
vec![Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false }; cols * rows];
|
||||
for row in 0..self.rows.min(rows) {
|
||||
for col in 0..self.cols.min(cols) {
|
||||
cells[row * cols + col] = self.cells[row * self.cols + col];
|
||||
}
|
||||
}
|
||||
self.cells = cells;
|
||||
let mut belegt = vec![None; rows];
|
||||
for r in 0..self.rows.min(rows) {
|
||||
belegt[r] = self.belegt[r].map(|c| c.min(cols - 1));
|
||||
}
|
||||
self.belegt = belegt;
|
||||
self.cols = cols;
|
||||
self.rows = rows;
|
||||
self.cur_row = self.cur_row.min(rows - 1);
|
||||
self.cur_col = self.cur_col.min(cols - 1);
|
||||
if self.view_full || self.view_top > rows - 1 {
|
||||
// Ohne eigenes VIEW PRINT folgt der Bereich dem Bildschirm; ein
|
||||
// Bereich, der ganz vom Schirm gefallen ist, wäre leer und fällt
|
||||
// auf den vollen Bildschirm zurück.
|
||||
self.view_top = 0;
|
||||
self.view_bottom = rows - 1;
|
||||
self.view_full = true;
|
||||
} else {
|
||||
self.view_bottom = self.view_bottom.min(rows - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `CLS`: Scrollbereich mit aktueller Hintergrundfarbe löschen,
|
||||
/// Cursor an den Anfang des Bereichs.
|
||||
pub fn cls(&mut self) {
|
||||
self.veraendert = true;
|
||||
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
|
||||
for row in self.view_top..=self.view_bottom {
|
||||
self.cells[row * self.cols..(row + 1) * self.cols].fill(blank);
|
||||
}
|
||||
self.cur_row = self.view_top;
|
||||
self.cur_col = 0;
|
||||
for r in self.view_top..=self.view_bottom {
|
||||
self.belegt[r] = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hat sich seit der letzten Quittung etwas geändert?
|
||||
pub fn ist_veraendert(&self) -> bool {
|
||||
self.veraendert
|
||||
}
|
||||
|
||||
/// Änderung quittieren (nach dem Anzeigen).
|
||||
pub fn veraenderung_quittieren(&mut self) {
|
||||
self.veraendert = false;
|
||||
}
|
||||
|
||||
/// Anzahl belegter Zeilen (1-basiert bis zur letzten beschriebenen),
|
||||
/// 0 wenn nichts ausgegeben wurde.
|
||||
pub fn belegte_zeilen(&self) -> usize {
|
||||
self.belegt
|
||||
.iter()
|
||||
.rposition(|b| b.is_some())
|
||||
.map_or(0, |i| i + 1)
|
||||
}
|
||||
|
||||
/// Breite der belegten Zeile `z` (1-basiert); 0 für unberührte Zeilen.
|
||||
pub fn belegte_breite(&self, z: usize) -> usize {
|
||||
self.belegt.get(z - 1).copied().flatten().map_or(0, |c| c + 1)
|
||||
}
|
||||
|
||||
/// `COLOR vg, hg` (vg 0–31, hg 0–7). Blinkende Vordergrundfarben
|
||||
/// (16–31) werden als „hell" simuliert (Entscheidung 2026-09-02):
|
||||
/// echtes Terminal-Blinken ist nicht überall verfügbar.
|
||||
pub fn set_color(&mut self, fg: u8, bg: u8) {
|
||||
self.veraendert = true;
|
||||
let fg = if fg >= 16 { (fg & 0x0F) | 8 } else { fg };
|
||||
self.fg = fg & 0x0F;
|
||||
self.bg = bg & 0x07;
|
||||
}
|
||||
|
||||
/// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5).
|
||||
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> {
|
||||
if row < 1 || row > self.rows || col < 1 || col > self.cols {
|
||||
return Err(());
|
||||
}
|
||||
self.cur_row = row - 1;
|
||||
// Auf eine Fortsetzungszelle zu zeigen bedeutet den Zeichenanfang.
|
||||
let mut c = col - 1;
|
||||
if c > 0 && self.cells[self.cur_row * self.cols + c].fortsetzung {
|
||||
c -= 1;
|
||||
}
|
||||
self.cur_col = c;
|
||||
self.veraendert = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CSRLIN` (1-basiert).
|
||||
pub fn csrlin(&self) -> usize {
|
||||
self.cur_row + 1
|
||||
}
|
||||
|
||||
/// `POS(0)` (1-basiert).
|
||||
pub fn pos(&self) -> usize {
|
||||
self.cur_col + 1
|
||||
}
|
||||
|
||||
/// `VIEW PRINT oben TO unten` (1-basiert).
|
||||
pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ()> {
|
||||
if top < 1 || bottom > self.rows || top > bottom {
|
||||
return Err(());
|
||||
}
|
||||
self.view_top = top - 1;
|
||||
self.view_bottom = bottom - 1;
|
||||
self.view_full = top == 1 && bottom == self.rows;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cell(&self, row: usize, col: usize) -> Cell {
|
||||
self.cells[(row - 1) * self.cols + (col - 1)]
|
||||
}
|
||||
|
||||
/// Text an der Cursorposition ausgeben: Umbruch am rechten Rand,
|
||||
/// Scrollen am unteren Rand des Scrollbereichs. `\n` bricht um,
|
||||
/// `\r` setzt an den Zeilenanfang.
|
||||
pub fn print(&mut self, text: &str) {
|
||||
self.veraendert = true;
|
||||
for ch in text.chars() {
|
||||
match ch {
|
||||
'\n' => self.newline(),
|
||||
'\r' => self.cur_col = 0,
|
||||
_ => self.zeichen_setzen(ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Zeichen an der Cursorposition setzen und den Cursor um seine
|
||||
/// Darstellungsbreite vorrücken. Ein breites Zeichen, das nicht mehr
|
||||
/// vollständig in die Zeile passt, wandert als Ganzes in die nächste;
|
||||
/// die letzte Spalte bleibt dann leer.
|
||||
fn zeichen_setzen(&mut self, ch: char) {
|
||||
// ponytail: Breite 0 (kombinierende Zeichen) wird wie 1 behandelt —
|
||||
// sie bekommen eine eigene Zelle statt sich an die vorige zu hängen.
|
||||
// Ceiling: echte Kombination erst, wenn ein Korpusprogramm sie braucht.
|
||||
let breite = ch.width().unwrap_or(1).max(1);
|
||||
if self.cur_col + breite > self.cols {
|
||||
self.newline();
|
||||
}
|
||||
let attr = (self.fg, self.bg);
|
||||
let start = self.cur_row * self.cols + self.cur_col;
|
||||
self.haelften_freimachen(self.cur_col, breite);
|
||||
self.cells[start] = Cell { ch, fg: attr.0, bg: attr.1, fortsetzung: false };
|
||||
for i in 1..breite {
|
||||
self.cells[start + i] =
|
||||
Cell { ch: ' ', fg: attr.0, bg: attr.1, fortsetzung: true };
|
||||
}
|
||||
let letzte = self.cur_col + breite - 1;
|
||||
let b = &mut self.belegt[self.cur_row];
|
||||
*b = Some(b.map_or(letzte, |c| c.max(letzte)));
|
||||
self.cur_col += breite;
|
||||
if self.cur_col >= self.cols {
|
||||
self.newline();
|
||||
}
|
||||
}
|
||||
|
||||
/// Vor dem Schreiben angeschnittene breite Zeichen auflösen: wird auf
|
||||
/// eine Fortsetzung geschrieben, verliert die Hälfte davor ihr Zeichen;
|
||||
/// überschreibt der neue Text den Anfang eines breiten Zeichens, wird
|
||||
/// dessen Fortsetzung geleert. Sonst blieben Geisterzeichen stehen.
|
||||
fn haelften_freimachen(&mut self, col: usize, breite: usize) {
|
||||
let zeilenanfang = self.cur_row * self.cols;
|
||||
let leer = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
|
||||
if col > 0 && self.cells[zeilenanfang + col].fortsetzung {
|
||||
self.cells[zeilenanfang + col - 1] = leer;
|
||||
}
|
||||
let hinter = col + breite;
|
||||
if hinter < self.cols && self.cells[zeilenanfang + hinter].fortsetzung {
|
||||
self.cells[zeilenanfang + hinter] = leer;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeilenumbruch inkl. Scrollen im Scrollbereich.
|
||||
fn newline(&mut self) {
|
||||
self.cur_col = 0;
|
||||
if self.cur_row >= self.view_bottom {
|
||||
self.scroll_up();
|
||||
self.cur_row = self.view_bottom;
|
||||
} else {
|
||||
self.cur_row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rückschritt: Cursor eine Spalte zurück und die Zelle leeren.
|
||||
/// Am Zeilenanfang passiert nichts (Vorbild).
|
||||
pub fn rueckschritt(&mut self) {
|
||||
if self.cur_col == 0 {
|
||||
return;
|
||||
}
|
||||
self.cur_col -= 1;
|
||||
// War das gelöschte eine Fortsetzung, gehört die Hälfte davor dazu.
|
||||
if self.cells[self.cur_row * self.cols + self.cur_col].fortsetzung && self.cur_col > 0 {
|
||||
self.cells[self.cur_row * self.cols + self.cur_col] =
|
||||
Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
|
||||
self.cur_col -= 1;
|
||||
}
|
||||
self.cells[self.cur_row * self.cols + self.cur_col] =
|
||||
Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
|
||||
// Die gelöschte Zelle gilt nicht mehr als belegt.
|
||||
let b = &mut self.belegt[self.cur_row];
|
||||
*b = match (self.cur_col, *b) {
|
||||
(0, _) => None,
|
||||
(c, Some(alt)) => Some(alt.min(c - 1)),
|
||||
(c, None) => Some(c - 1),
|
||||
};
|
||||
self.veraendert = true;
|
||||
}
|
||||
|
||||
/// Scrollbereich um eine Zeile nach oben schieben; unterste Zeile leeren.
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.veraendert = true;
|
||||
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
|
||||
for row in self.view_top..self.view_bottom {
|
||||
let (a, b) = self.cells.split_at_mut((row + 1) * self.cols);
|
||||
a[row * self.cols..].copy_from_slice(&b[..self.cols]);
|
||||
}
|
||||
self.cells[self.view_bottom * self.cols..(self.view_bottom + 1) * self.cols]
|
||||
.fill(blank);
|
||||
for row in self.view_top..self.view_bottom {
|
||||
self.belegt[row] = self.belegt[row + 1];
|
||||
}
|
||||
self.belegt[self.view_bottom] = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn print_schreibt_und_bewegt_cursor() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("AB");
|
||||
assert_eq!(s.cell(1, 1).ch, 'A');
|
||||
assert_eq!(s.cell(1, 2).ch, 'B');
|
||||
assert_eq!((s.csrlin(), s.pos()), (1, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_zeichen_belegen_eine_zelle() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("Ä☃");
|
||||
assert_eq!(s.cell(1, 1).ch, 'Ä');
|
||||
assert_eq!(s.cell(1, 2).ch, '☃');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breites_zeichen_belegt_zwei_zellen() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("中a");
|
||||
assert_eq!(s.cell(1, 1).ch, '中');
|
||||
assert!(s.cell(1, 2).fortsetzung);
|
||||
assert_eq!(s.cell(1, 3).ch, 'a');
|
||||
assert_eq!(s.pos(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breites_zeichen_am_rechten_rand_bricht_ganz_um() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print(&"x".repeat(79)); // Cursor auf Spalte 80
|
||||
assert_eq!(s.pos(), 80);
|
||||
s.print("中");
|
||||
// Letzte Spalte bleibt leer, das Zeichen steht in Zeile 2.
|
||||
assert_eq!(s.cell(1, 80).ch, ' ');
|
||||
assert_eq!(s.cell(2, 1).ch, '中');
|
||||
assert!(s.cell(2, 2).fortsetzung);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ueberschreiben_einer_haelfte_leert_die_andere() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("中");
|
||||
// Auf den Anfang schreiben → Fortsetzung muss weg.
|
||||
s.locate(1, 1).unwrap();
|
||||
s.print("a");
|
||||
assert_eq!(s.cell(1, 1).ch, 'a');
|
||||
assert!(!s.cell(1, 2).fortsetzung);
|
||||
assert_eq!(s.cell(1, 2).ch, ' ');
|
||||
|
||||
// Auf die Fortsetzung schreiben → Anfang muss weg.
|
||||
let mut s2 = TextScreen::new();
|
||||
s2.print("中");
|
||||
s2.locate(1, 2).unwrap(); // klemmt auf den Zeichenanfang
|
||||
assert_eq!(s2.pos(), 1);
|
||||
s2.locate(1, 3).unwrap();
|
||||
s2.print("b");
|
||||
assert_eq!(s2.cell(1, 3).ch, 'b');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_auf_fortsetzung_trifft_den_zeichenanfang() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("中");
|
||||
s.locate(1, 2).unwrap();
|
||||
assert_eq!(s.pos(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rueckschritt_loescht_ein_breites_zeichen_ganz() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("a中");
|
||||
assert_eq!(s.pos(), 4);
|
||||
s.rueckschritt();
|
||||
assert_eq!(s.pos(), 2);
|
||||
assert_eq!(s.cell(1, 2).ch, ' ');
|
||||
assert!(!s.cell(1, 2).fortsetzung);
|
||||
assert_eq!(s.cell(1, 3).ch, ' ');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn umbruch_am_rechten_rand() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print(&"x".repeat(81));
|
||||
assert_eq!(s.cell(1, 80).ch, 'x');
|
||||
assert_eq!(s.cell(2, 1).ch, 'x');
|
||||
assert_eq!((s.csrlin(), s.pos()), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollen_am_unteren_rand() {
|
||||
let mut s = TextScreen::new();
|
||||
s.locate(25, 1).unwrap();
|
||||
s.print("unten\n"); // erzwingt Scroll
|
||||
assert_eq!(s.cell(24, 1).ch, 'u');
|
||||
assert_eq!(s.cell(25, 1).ch, ' ');
|
||||
assert_eq!(s.csrlin(), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_print_begrenzt_scrollen() {
|
||||
let mut s = TextScreen::new();
|
||||
s.locate(1, 1).unwrap();
|
||||
s.print("kopf");
|
||||
s.view_print(3, 5).unwrap();
|
||||
s.locate(5, 1).unwrap();
|
||||
s.print("a\nb"); // scrollt nur Zeilen 3–5
|
||||
assert_eq!(s.cell(1, 1).ch, 'k');
|
||||
assert_eq!(s.cell(4, 1).ch, 'a');
|
||||
assert_eq!(s.cell(5, 1).ch, 'b');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_prueft_grenzen() {
|
||||
let mut s = TextScreen::new();
|
||||
assert!(s.locate(0, 1).is_err());
|
||||
assert!(s.locate(26, 1).is_err());
|
||||
assert!(s.locate(25, 80).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamische_groesse_und_resize() {
|
||||
let mut s = TextScreen::with_size(120, 40);
|
||||
assert_eq!((s.cols(), s.rows()), (120, 40));
|
||||
s.locate(40, 1).unwrap();
|
||||
s.print("!");
|
||||
assert_eq!(s.cell(40, 1).ch, '!');
|
||||
|
||||
// Verkleinern: Inhalt oben links bleibt, Cursor wird geklemmt
|
||||
s.locate(1, 1).unwrap();
|
||||
s.print("K");
|
||||
s.resize(100, 30);
|
||||
assert_eq!((s.cols(), s.rows()), (100, 30));
|
||||
assert_eq!(s.cell(1, 1).ch, 'K');
|
||||
|
||||
// Unter Minimum wird auf 80×25 geklemmt
|
||||
s.resize(10, 5);
|
||||
assert_eq!((s.cols(), s.rows()), (80, 25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blink_wird_als_hell_simuliert() {
|
||||
let mut s = TextScreen::new();
|
||||
s.set_color(17, 0); // blinkend Blau → helles Blau
|
||||
assert_eq!(s.fg, 9);
|
||||
s.set_color(31, 0); // blinkend Hellweiß → Hellweiß
|
||||
assert_eq!(s.fg, 15);
|
||||
s.set_color(7, 0);
|
||||
assert_eq!(s.fg, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_klemmt_cursor_und_scrollbereich() {
|
||||
let mut s = TextScreen::with_size(120, 40);
|
||||
s.locate(1, 1).unwrap();
|
||||
s.print("K");
|
||||
s.view_print(30, 38).unwrap();
|
||||
s.locate(38, 5).unwrap();
|
||||
|
||||
s.resize(80, 25);
|
||||
// Inhalt oben links bleibt.
|
||||
assert_eq!(s.cell(1, 1).ch, 'K');
|
||||
// Cursor liegt in den neuen Grenzen.
|
||||
assert!(s.csrlin() <= s.rows() && s.pos() <= s.cols());
|
||||
// Der Bereich lag ganz außerhalb → voller Bildschirm.
|
||||
s.locate(25, 1).unwrap();
|
||||
s.print("u\n");
|
||||
assert_eq!(s.cell(24, 1).ch, 'u');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_klemmt_nur_den_unteren_rand_wenn_der_bereich_bleibt() {
|
||||
let mut s = TextScreen::with_size(100, 40);
|
||||
s.view_print(5, 38).unwrap();
|
||||
s.resize(100, 30);
|
||||
// Oberer Rand bleibt bei 5, unterer klemmt auf 30.
|
||||
s.locate(30, 1).unwrap();
|
||||
s.print("a\nb");
|
||||
assert_eq!(s.cell(29, 1).ch, 'a');
|
||||
assert_eq!(s.cell(30, 1).ch, 'b');
|
||||
// Zeile 4 liegt außerhalb und bleibt unberührt.
|
||||
assert_eq!(s.cell(4, 1).ch, ' ');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_folgt_vollem_scrollbereich() {
|
||||
let mut s = TextScreen::new();
|
||||
s.resize(90, 40);
|
||||
s.locate(40, 1).unwrap();
|
||||
s.print("a\nb"); // Scroll am neuen unteren Rand
|
||||
assert_eq!(s.cell(39, 1).ch, 'a');
|
||||
assert_eq!(s.cell(40, 1).ch, 'b');
|
||||
}
|
||||
|
||||
}
|
||||
141
crates/tb-runtime/src/snapshot.rs
Normal file
141
crates/tb-runtime/src/snapshot.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Snapshot des Bildschirms als vergleichbarer Text.
|
||||
//!
|
||||
//! Grundlage des Kompatibilitäts-Korpus (Entscheidung 2026-09-03, D3):
|
||||
//! ein Byte-Strom könnte `LOCATE`/`COLOR` nicht abbilden, ein voller
|
||||
//! 80×25-Abzug wäre für ein `PRINT "hi"` fast nur Rauschen. Der Snapshot
|
||||
//! ist deshalb auf die belegte Fläche getrimmt — nachgestellte Leerzeichen,
|
||||
//! die `PRINT` selbst ausgibt, gehören dazu.
|
||||
//!
|
||||
//! Die Attributebene entsteht nur, wenn das Programm überhaupt Farben
|
||||
//! gesetzt hat; sonst wäre sie eine Fläche identischer Werte.
|
||||
|
||||
use crate::screen::{Cell, TextScreen};
|
||||
|
||||
/// Trennzeile vor der Attributebene.
|
||||
pub const ATTRIBUT_MARKE: &str = "--- Attribute (Vordergrund/Hintergrund je Zelle, hex) ---";
|
||||
|
||||
/// Standardattribut eines unberührten Bildschirms.
|
||||
fn ist_standard(c: &Cell) -> bool {
|
||||
c.fg == Cell::default().fg && c.bg == Cell::default().bg
|
||||
}
|
||||
|
||||
/// Enthält die belegte Fläche eine vom Standard abweichende Farbe?
|
||||
pub fn hat_farben(screen: &TextScreen) -> bool {
|
||||
(1..=screen.belegte_zeilen())
|
||||
.any(|r| (1..=screen.belegte_breite(r)).any(|c| !ist_standard(&screen.cell(r, c))))
|
||||
}
|
||||
|
||||
/// Textbild der belegten Fläche, je Zeile bis zu ihrer eigenen belegten
|
||||
/// Breite. Leerer String, wenn nichts ausgegeben wurde.
|
||||
pub fn text(screen: &TextScreen) -> String {
|
||||
let mut out = String::new();
|
||||
for r in 1..=screen.belegte_zeilen() {
|
||||
for c in 1..=screen.belegte_breite(r) {
|
||||
// Fortsetzungszellen gehören zum Zeichen davor und werden nicht
|
||||
// eigens ausgegeben — sonst stünde hinter jedem breiten Zeichen
|
||||
// ein Leerzeichen und die Spalten der Datei liefen auseinander.
|
||||
let z = screen.cell(r, c);
|
||||
if !z.fortsetzung {
|
||||
out.push(z.ch);
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Attributebene: je Zelle zwei Hexziffern (Vordergrund, Hintergrund),
|
||||
/// zeilenweise gleich breit wie das Textbild.
|
||||
pub fn attribute(screen: &TextScreen) -> String {
|
||||
let mut out = String::new();
|
||||
for r in 1..=screen.belegte_zeilen() {
|
||||
for c in 1..=screen.belegte_breite(r) {
|
||||
let cell = screen.cell(r, c);
|
||||
if cell.fortsetzung {
|
||||
continue; // gehört zum Zeichen davor (siehe `text`)
|
||||
}
|
||||
out.push(char::from_digit(cell.fg as u32, 16).unwrap_or('?'));
|
||||
out.push(char::from_digit(cell.bg as u32, 16).unwrap_or('?'));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Vollständiger Snapshot: Textbild, bei gesetzten Farben gefolgt von
|
||||
/// Marke und Attributebene.
|
||||
pub fn snapshot(screen: &TextScreen) -> String {
|
||||
let mut s = text(screen);
|
||||
if hat_farben(screen) {
|
||||
s.push_str(ATTRIBUT_MARKE);
|
||||
s.push('\n');
|
||||
s.push_str(&attribute(screen));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn getrimmt_auf_die_belegte_flaeche() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("hi\n");
|
||||
// Zwei Zeichen in Zeile 1 — kein 80×25-Abzug.
|
||||
assert_eq!(snapshot(&s), "hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nachgestellte_leerzeichen_bleiben_erhalten() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print(" 1 2 3 "); // wie PRINT 1; 2; 3
|
||||
assert_eq!(snapshot(&s), " 1 2 3 \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ohne_farben_keine_attributebene() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("abc");
|
||||
assert!(!snapshot(&s).contains(ATTRIBUT_MARKE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mit_farben_kommt_die_attributebene() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("a");
|
||||
s.set_color(14, 1);
|
||||
s.print("b");
|
||||
let snap = snapshot(&s);
|
||||
assert!(snap.starts_with("ab\n"));
|
||||
assert!(snap.contains(ATTRIBUT_MARKE));
|
||||
// Zelle 1 Standard (70), Zelle 2 gelb auf blau (e1)
|
||||
assert!(snap.ends_with("70e1\n"), "{snap:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_erweitert_die_flaeche() {
|
||||
let mut s = TextScreen::new();
|
||||
s.locate(3, 5).unwrap();
|
||||
s.print("x");
|
||||
let snap = snapshot(&s);
|
||||
let zeilen: Vec<&str> = snap.lines().collect();
|
||||
assert_eq!(zeilen.len(), 3);
|
||||
assert_eq!(zeilen[2], " x");
|
||||
// Unberührte Zeilen bleiben leer statt auf die Breite aufgefüllt.
|
||||
assert_eq!(zeilen[0], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breites_zeichen_erscheint_einmal() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("中a");
|
||||
// Ein Zeichen, kein nachgestelltes Leerzeichen für die Fortsetzung.
|
||||
assert_eq!(snapshot(&s), "中a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leerer_bildschirm_ergibt_leeren_snapshot() {
|
||||
assert_eq!(snapshot(&TextScreen::new()), "");
|
||||
}
|
||||
}
|
||||
487
crates/tb-runtime/src/using.rs
Normal file
487
crates/tb-runtime/src/using.rs
Normal file
@@ -0,0 +1,487 @@
|
||||
//! `PRINT USING`, `LPRINT USING` und `FORMAT$`: Formatzeichenketten des
|
||||
//! Vorbilds.
|
||||
//!
|
||||
//! Die Formatzeichenkette beschreibt Felder; jeder Wert füllt das nächste
|
||||
//! Feld. Sind noch Werte übrig, wenn die Zeichenkette zu Ende ist, beginnt
|
||||
//! sie von vorn. Passt ein Wert nicht in sein Feld, wird er vollständig
|
||||
//! ausgegeben und mit `%` gekennzeichnet.
|
||||
|
||||
use crate::errors::RuntimeError;
|
||||
use crate::value::{as_f64, Value};
|
||||
|
||||
/// Numerisches Feld.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
struct NumFeld {
|
||||
/// Ziffernstellen vor dem Dezimalpunkt (ohne die Stellen aus `$$`/`**`).
|
||||
vor: usize,
|
||||
/// Ziffernstellen nach dem Dezimalpunkt.
|
||||
nach: usize,
|
||||
/// Dezimalpunkt vorhanden?
|
||||
punkt: bool,
|
||||
/// Tausendertrennung (`,` vor dem Punkt).
|
||||
tausender: bool,
|
||||
/// `+` vorangestellt bzw. nachgestellt.
|
||||
plus_vorn: bool,
|
||||
plus_hinten: bool,
|
||||
/// `-` nachgestellt (nur für negative Werte).
|
||||
minus_hinten: bool,
|
||||
/// `$$` — mitlaufendes Währungszeichen.
|
||||
waehrung: bool,
|
||||
/// `**` — führende Stellen mit `*` füllen.
|
||||
sterne: bool,
|
||||
/// `^^^^` — Exponentialform.
|
||||
exponent: bool,
|
||||
}
|
||||
|
||||
/// Stringfeld.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum StrFeld {
|
||||
/// `!` — nur das erste Zeichen.
|
||||
Erstes,
|
||||
/// `\ \` — feste Länge (Anzahl Zeichen inklusive beider Schrägstriche).
|
||||
Fest(usize),
|
||||
/// `&` — der ganze String.
|
||||
Ganz,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Teil {
|
||||
Literal(String),
|
||||
Zahl(NumFeld),
|
||||
Text(StrFeld),
|
||||
}
|
||||
|
||||
/// Währungszeichen für `$$` (siehe `SetFormatCC`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Waehrung {
|
||||
pub zeichen: String,
|
||||
}
|
||||
|
||||
impl Default for Waehrung {
|
||||
fn default() -> Self {
|
||||
Waehrung { zeichen: "$".into() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Zerlegen der Formatzeichenkette ---------------------------------------
|
||||
|
||||
fn zerlege(fmt: &str) -> Vec<Teil> {
|
||||
let z: Vec<char> = fmt.chars().collect();
|
||||
let mut teile = Vec::new();
|
||||
let mut lit = String::new();
|
||||
let mut i = 0;
|
||||
let schiebe = |lit: &mut String, teile: &mut Vec<Teil>| {
|
||||
if !lit.is_empty() {
|
||||
teile.push(Teil::Literal(std::mem::take(lit)));
|
||||
}
|
||||
};
|
||||
|
||||
while i < z.len() {
|
||||
match z[i] {
|
||||
// `_` macht das Folgezeichen literal.
|
||||
'_' if i + 1 < z.len() => {
|
||||
lit.push(z[i + 1]);
|
||||
i += 2;
|
||||
}
|
||||
'!' => {
|
||||
schiebe(&mut lit, &mut teile);
|
||||
teile.push(Teil::Text(StrFeld::Erstes));
|
||||
i += 1;
|
||||
}
|
||||
'&' => {
|
||||
schiebe(&mut lit, &mut teile);
|
||||
teile.push(Teil::Text(StrFeld::Ganz));
|
||||
i += 1;
|
||||
}
|
||||
// `\ \` — Leerzeichen zwischen zwei Schrägstrichen.
|
||||
'\\' => {
|
||||
let mut j = i + 1;
|
||||
while j < z.len() && z[j] == ' ' {
|
||||
j += 1;
|
||||
}
|
||||
if j < z.len() && z[j] == '\\' {
|
||||
schiebe(&mut lit, &mut teile);
|
||||
teile.push(Teil::Text(StrFeld::Fest(j - i + 1)));
|
||||
i = j + 1;
|
||||
} else {
|
||||
lit.push('\\');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
'#' | '+' | '.' | '*' | '$' => {
|
||||
if let Some((feld, weiter)) = zahlfeld(&z, i) {
|
||||
schiebe(&mut lit, &mut teile);
|
||||
teile.push(Teil::Zahl(feld));
|
||||
i = weiter;
|
||||
} else {
|
||||
lit.push(z[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
c => {
|
||||
lit.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
schiebe(&mut lit, &mut teile);
|
||||
teile
|
||||
}
|
||||
|
||||
/// Versucht ab `start` ein numerisches Feld zu lesen.
|
||||
fn zahlfeld(z: &[char], start: usize) -> Option<(NumFeld, usize)> {
|
||||
let mut f = NumFeld::default();
|
||||
let mut i = start;
|
||||
|
||||
if z[i] == '+' {
|
||||
f.plus_vorn = true;
|
||||
i += 1;
|
||||
}
|
||||
// `**$`, `**`, `$$` belegen selbst Ziffernstellen.
|
||||
if i + 1 < z.len() && z[i] == '*' && z[i + 1] == '*' {
|
||||
f.sterne = true;
|
||||
f.vor += 2;
|
||||
i += 2;
|
||||
if i < z.len() && z[i] == '$' {
|
||||
f.waehrung = true;
|
||||
f.vor += 1;
|
||||
i += 1;
|
||||
}
|
||||
} else if i + 1 < z.len() && z[i] == '$' && z[i + 1] == '$' {
|
||||
f.waehrung = true;
|
||||
f.vor += 2;
|
||||
i += 2;
|
||||
}
|
||||
|
||||
let ziffern_start = i;
|
||||
while i < z.len() {
|
||||
match z[i] {
|
||||
'#' => {
|
||||
f.vor += 1;
|
||||
i += 1;
|
||||
}
|
||||
',' if i + 1 < z.len() && (z[i + 1] == '#' || z[i + 1] == '.') => {
|
||||
f.tausender = true;
|
||||
i += 1;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
// Ohne jede Ziffernstelle ist es kein Zahlfeld.
|
||||
if i == ziffern_start && !f.waehrung && !f.sterne {
|
||||
return None;
|
||||
}
|
||||
if i < z.len() && z[i] == '.' {
|
||||
f.punkt = true;
|
||||
i += 1;
|
||||
while i < z.len() && z[i] == '#' {
|
||||
f.nach += 1;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// Exponentialform: vier oder mehr `^`.
|
||||
let exp_start = i;
|
||||
while i < z.len() && z[i] == '^' {
|
||||
i += 1;
|
||||
}
|
||||
if i - exp_start >= 4 {
|
||||
f.exponent = true;
|
||||
} else {
|
||||
i = exp_start;
|
||||
}
|
||||
if i < z.len() && z[i] == '+' {
|
||||
f.plus_hinten = true;
|
||||
i += 1;
|
||||
} else if i < z.len() && z[i] == '-' {
|
||||
f.minus_hinten = true;
|
||||
i += 1;
|
||||
}
|
||||
Some((f, i))
|
||||
}
|
||||
|
||||
// ---- Werte formatieren ------------------------------------------------------
|
||||
|
||||
fn zahl_formatieren(f: &NumFeld, wert: f64, waehrung: &Waehrung) -> String {
|
||||
let negativ = wert < 0.0 || (wert == 0.0 && wert.is_sign_negative());
|
||||
let betrag = wert.abs();
|
||||
|
||||
if f.exponent {
|
||||
return exponent_formatieren(f, wert);
|
||||
}
|
||||
|
||||
// Ziffern runden (kaufmännisch zur geraden Zahl wie sonst auch).
|
||||
let gerundet = runde(betrag, f.nach);
|
||||
let mut ganz = format!("{:.0}", gerundet.trunc());
|
||||
let bruch = if f.punkt {
|
||||
let b = gerundet - gerundet.trunc();
|
||||
let s = format!("{:.*}", f.nach, b);
|
||||
// "0.25" → "25"
|
||||
s.split_once('.').map(|(_, r)| r.to_string()).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Die Tausendertrennung fügt Zeichen hinzu, die keine Ziffernstelle
|
||||
// belegen; der Überlauf entscheidet sich an der Ziffernzahl.
|
||||
let ziffern = ganz.chars().count();
|
||||
if f.tausender {
|
||||
ganz = tausender_gruppieren(&ganz);
|
||||
}
|
||||
|
||||
// Vorzeichen mit eigener Position: `+` vorn bzw. `+`/`-` hinten stehen
|
||||
// neben dem Zahlenfeld, nicht darin. Ohne Vorzeichenangabe teilt sich ein
|
||||
// `-` die Ziffernstellen.
|
||||
let (vorz_vorn, vorz_hinten, minus_im_feld) = if f.plus_vorn {
|
||||
(if negativ { "-" } else { "+" }.to_string(), String::new(), false)
|
||||
} else if f.plus_hinten {
|
||||
(String::new(), if negativ { "-" } else { "+" }.to_string(), false)
|
||||
} else if f.minus_hinten {
|
||||
(String::new(), if negativ { "-" } else { " " }.to_string(), false)
|
||||
} else {
|
||||
(String::new(), String::new(), negativ)
|
||||
};
|
||||
|
||||
// Inhalt der Ziffernstellen, rechtsbündig: Vorzeichen (falls im Feld),
|
||||
// Währungszeichen, Ziffern. `$$`/`**` haben ihre Stellen mitgebracht.
|
||||
let mut inhalt = String::new();
|
||||
if minus_im_feld {
|
||||
inhalt.push('-');
|
||||
}
|
||||
if f.waehrung {
|
||||
inhalt.push_str(&waehrung.zeichen);
|
||||
}
|
||||
inhalt.push_str(&ganz);
|
||||
|
||||
let ueberlauf = ziffern + usize::from(minus_im_feld) + usize::from(f.waehrung) > f.vor;
|
||||
let gefuellt = if ueberlauf {
|
||||
inhalt
|
||||
} else {
|
||||
// Trennzeichen belegen keine Ziffernstelle und können die Breite
|
||||
// überschreiten, ohne Überlauf zu sein.
|
||||
let fehlt = f.vor.saturating_sub(inhalt.chars().count());
|
||||
let fueller = if f.sterne { '*' } else { ' ' };
|
||||
let mut t: String = std::iter::repeat_n(fueller, fehlt).collect();
|
||||
t.push_str(&inhalt);
|
||||
t
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
if ueberlauf {
|
||||
out.push('%');
|
||||
}
|
||||
out.push_str(&vorz_vorn);
|
||||
out.push_str(&gefuellt);
|
||||
if f.punkt {
|
||||
out.push('.');
|
||||
out.push_str(&bruch);
|
||||
}
|
||||
out.push_str(&vorz_hinten);
|
||||
out
|
||||
}
|
||||
|
||||
/// Kaufmännische Rundung zur geraden Zahl auf `stellen` Nachkommastellen.
|
||||
fn runde(x: f64, stellen: usize) -> f64 {
|
||||
let faktor = 10f64.powi(stellen as i32);
|
||||
let skaliert = x * faktor;
|
||||
let ab = skaliert.floor();
|
||||
let rest = skaliert - ab;
|
||||
let gerundet = if (rest - 0.5).abs() < f64::EPSILON * skaliert.abs().max(1.0) {
|
||||
if (ab as i64) % 2 == 0 {
|
||||
ab
|
||||
} else {
|
||||
ab + 1.0
|
||||
}
|
||||
} else {
|
||||
skaliert.round()
|
||||
};
|
||||
gerundet / faktor
|
||||
}
|
||||
|
||||
fn tausender_gruppieren(s: &str) -> String {
|
||||
let z: Vec<char> = s.chars().collect();
|
||||
let mut out = String::new();
|
||||
for (i, c) in z.iter().enumerate() {
|
||||
if i > 0 && (z.len() - i) % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(*c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn exponent_formatieren(f: &NumFeld, wert: f64) -> String {
|
||||
let stellen = f.nach;
|
||||
let s = format!("{:.*E}", stellen, wert);
|
||||
// Rust liefert `1.5E3`; das Vorbild schreibt `1.5E+03`.
|
||||
match s.split_once('E') {
|
||||
Some((m, e)) => {
|
||||
let (vz, betrag) = match e.strip_prefix('-') {
|
||||
Some(r) => ('-', r),
|
||||
None => ('+', e.trim_start_matches('+')),
|
||||
};
|
||||
format!("{m}E{vz}{betrag:0>2}")
|
||||
}
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_formatieren(f: &StrFeld, s: &str) -> String {
|
||||
match f {
|
||||
StrFeld::Erstes => s.chars().take(1).collect(),
|
||||
StrFeld::Ganz => s.to_string(),
|
||||
StrFeld::Fest(n) => {
|
||||
let mut t: String = s.chars().take(*n).collect();
|
||||
let fehlt = n.saturating_sub(t.chars().count());
|
||||
t.extend(std::iter::repeat_n(' ', fehlt));
|
||||
t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Einstiegspunkt ---------------------------------------------------------
|
||||
|
||||
/// Werte nach `fmt` formatieren. Die Zeichenkette wird so oft wiederholt,
|
||||
/// wie Werte übrig sind; nach dem letzten Wert folgen noch die Literale bis
|
||||
/// zum nächsten Feld.
|
||||
pub fn using(fmt: &str, werte: &[Value], waehrung: &Waehrung) -> Result<String, RuntimeError> {
|
||||
let teile = zerlege(fmt);
|
||||
let hat_feld = teile
|
||||
.iter()
|
||||
.any(|t| matches!(t, Teil::Zahl(_) | Teil::Text(_)));
|
||||
if !hat_feld {
|
||||
if werte.is_empty() {
|
||||
return Ok(fmt.to_string());
|
||||
}
|
||||
// Werte ohne Feld sind ein Bedienfehler (Vorbild: Fehler 5).
|
||||
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let mut i = 0usize; // Index im Teile-Vektor
|
||||
let mut w = 0usize; // Index im Wertevektor
|
||||
// Schutz gegen Endlosläufe bei Formaten ohne Fortschritt.
|
||||
let mut runden = 0usize;
|
||||
|
||||
while w < werte.len() {
|
||||
if i >= teile.len() {
|
||||
i = 0;
|
||||
runden += 1;
|
||||
if runden > werte.len() + 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match &teile[i] {
|
||||
Teil::Literal(l) => out.push_str(l),
|
||||
Teil::Zahl(f) => {
|
||||
if matches!(werte[w], Value::Str(_)) {
|
||||
return Err(RuntimeError::TYPE_MISMATCH);
|
||||
}
|
||||
let v = as_f64(&werte[w]);
|
||||
out.push_str(&zahl_formatieren(f, v, waehrung));
|
||||
w += 1;
|
||||
}
|
||||
Teil::Text(f) => {
|
||||
let Value::Str(s) = &werte[w] else {
|
||||
return Err(RuntimeError::TYPE_MISMATCH);
|
||||
};
|
||||
out.push_str(&text_formatieren(f, s));
|
||||
w += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// Nachlaufende Literale bis zum nächsten Feld.
|
||||
while i < teile.len() {
|
||||
match &teile[i] {
|
||||
Teil::Literal(l) => out.push_str(l),
|
||||
_ => break,
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
fn u(fmt: &str, werte: &[Value]) -> String {
|
||||
using(fmt, werte, &Waehrung::default()).unwrap()
|
||||
}
|
||||
fn z(x: f64) -> Value {
|
||||
Value::Dbl(x)
|
||||
}
|
||||
fn s(x: &str) -> Value {
|
||||
Value::Str(Rc::from(x))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerisches_feld_mit_nachkommastellen() {
|
||||
assert_eq!(u("###.##", &[z(3.14159)]), " 3.14");
|
||||
assert_eq!(u("##.##", &[z(-1.5)]), "-1.50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feldueberlauf_bekommt_prozentzeichen() {
|
||||
let r = u("##", &[z(12345.0)]);
|
||||
assert!(r.starts_with('%'), "{r}");
|
||||
assert!(r.contains("12345"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_wird_wiederverwendet() {
|
||||
assert_eq!(u("## ", &[z(1.0), z(2.0), z(3.0)]), " 1 2 3 ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tausendertrennung() {
|
||||
assert_eq!(u("#######", &[z(1234567.0)]), "1234567");
|
||||
assert_eq!(u("##,#####", &[z(1234567.0)]), "1,234,567");
|
||||
// Mehr Ziffern als Stellen → Überlauf mit %-Präfix.
|
||||
assert!(u("##,####", &[z(1234567.0)]).starts_with('%'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vorzeichenfuehrung() {
|
||||
assert_eq!(u("+###", &[z(12.0)]), "+ 12");
|
||||
assert_eq!(u("###-", &[z(-12.0)]), " 12-");
|
||||
assert_eq!(u("###-", &[z(12.0)]), " 12 ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waehrung_und_sterne() {
|
||||
// `$$` bringt zwei Stellen mit, eine davon ist das Zeichen selbst.
|
||||
assert_eq!(u("$$###", &[z(12.0)]), " $12");
|
||||
assert_eq!(u("**###", &[z(12.0)]), "***12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stringfelder() {
|
||||
assert_eq!(u("!", &[s("abc")]), "a");
|
||||
assert_eq!(u("&", &[s("abc")]), "abc");
|
||||
assert_eq!(u("\\ \\", &[s("abcdef")]), "abcd");
|
||||
assert_eq!(u("\\ \\", &[s("ab")]), "ab ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literale_und_unterstrich() {
|
||||
assert_eq!(u("Wert: ##!", &[z(7.0), s("x")]), "Wert: 7x");
|
||||
assert_eq!(u("_###", &[z(7.0)]), "# 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exponentialform() {
|
||||
assert_eq!(u("#.##^^^^", &[z(1234.0)]), "1.23E+03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ohne_feld_aber_mit_werten_ist_fehler_5() {
|
||||
let e = using("nur Text", &[z(1.0)], &Waehrung::default()).unwrap_err();
|
||||
assert_eq!(e, RuntimeError::ILLEGAL_FUNCTION_CALL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ohne_feld_und_ohne_werte_bleibt_der_text() {
|
||||
assert_eq!(u("nur Text", &[]), "nur Text");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tb-runtime.workspace = true
|
||||
ratatui.workspace = true
|
||||
crossterm.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -9,7 +9,7 @@ use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, Key
|
||||
use crossterm::execute;
|
||||
use std::io::stdout;
|
||||
use std::time::Duration;
|
||||
use tb_ui::screen::TextScreen;
|
||||
use tb_ui::screen::{ScreenWidget, TextScreen};
|
||||
|
||||
fn testbild(s: &mut TextScreen) {
|
||||
s.set_color(15, 1);
|
||||
@@ -61,7 +61,7 @@ fn main() -> anyhow::Result<()> {
|
||||
status(&mut screen, "Bereit. Tasten/Maus testen, Esc beendet.");
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| f.render_widget(&screen, f.area()))?;
|
||||
terminal.draw(|f| f.render_widget(ScreenWidget(&screen), f.area()))?;
|
||||
if !event::poll(Duration::from_millis(100))? {
|
||||
continue;
|
||||
}
|
||||
|
||||
150
crates/tb-ui/src/host.rs
Normal file
150
crates/tb-ui/src/host.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
//! Terminal-Host: stellt den Zellenpuffer über Ratatui dar und liefert
|
||||
//! Tastatur- und Größenereignisse aus crossterm.
|
||||
//!
|
||||
//! Er lebt hier statt in `tb-runtime`, damit die Ausführungsschicht ohne
|
||||
//! Terminal-Abhängigkeit bleibt (Entscheidung 2026-09-03, D1/D2).
|
||||
|
||||
use std::io::{self, Stdout};
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{
|
||||
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
|
||||
KeyModifiers,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use tb_runtime::host::{taste, Ereignis, Host};
|
||||
use tb_runtime::screen::TextScreen;
|
||||
|
||||
use crate::screen::ScreenWidget;
|
||||
|
||||
/// Host für laufende Programme im Terminal.
|
||||
///
|
||||
/// Der Konstruktor schaltet in den Alternativschirm und den Rohmodus,
|
||||
/// `Drop` stellt beides wieder her — auch bei Panik oder Laufzeitfehler.
|
||||
pub struct TerminalHost {
|
||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
}
|
||||
|
||||
impl TerminalHost {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = io::stdout();
|
||||
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let terminal = Terminal::new(CrosstermBackend::new(out))?;
|
||||
Ok(TerminalHost { terminal })
|
||||
}
|
||||
|
||||
/// Aktuelle Terminalgröße, damit der Bildschirm sie von Beginn an nutzt.
|
||||
pub fn groesse(&self) -> io::Result<(usize, usize)> {
|
||||
let s = self.terminal.size()?;
|
||||
Ok((s.width as usize, s.height as usize))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalHost {
|
||||
fn drop(&mut self) {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(
|
||||
self.terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
let _ = self.terminal.show_cursor();
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for TerminalHost {
|
||||
fn present(&mut self, screen: &TextScreen) {
|
||||
let _ = self
|
||||
.terminal
|
||||
.draw(|f| f.render_widget(ScreenWidget(screen), f.area()));
|
||||
}
|
||||
|
||||
fn next_event(&mut self, blockierend: bool) -> Option<Ereignis> {
|
||||
loop {
|
||||
if !blockierend && !event::poll(Duration::ZERO).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
match event::read() {
|
||||
Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => {
|
||||
if let Some(e) = taste_zu_ereignis(k) {
|
||||
return Some(e);
|
||||
}
|
||||
}
|
||||
Ok(Event::Resize(cols, rows)) => {
|
||||
return Some(Ereignis::Groesse {
|
||||
cols: cols as usize,
|
||||
rows: rows as usize,
|
||||
})
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => return Some(Ereignis::Ende),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// crossterm-Taste → `INKEY$`-Form. `None` für Tasten ohne Entsprechung.
|
||||
fn taste_zu_ereignis(k: KeyEvent) -> Option<Ereignis> {
|
||||
// Strg+Untbr bzw. Strg+C: Abbruchwunsch.
|
||||
if k.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(k.code, KeyCode::Char('c') | KeyCode::Pause)
|
||||
{
|
||||
return Some(Ereignis::Abbruch);
|
||||
}
|
||||
let s = match k.code {
|
||||
KeyCode::Char(c) => c.to_string(),
|
||||
KeyCode::Enter => taste::ENTER.to_string(),
|
||||
KeyCode::Backspace => taste::BACKSPACE.to_string(),
|
||||
KeyCode::Esc => taste::ESC.to_string(),
|
||||
KeyCode::Tab => taste::TAB.to_string(),
|
||||
// Sondertasten in der Kodierung des Vorbilds: Nullzeichen + Kennung.
|
||||
KeyCode::F(n @ 1..=10) => taste::sonder(58 + n),
|
||||
KeyCode::Home => taste::sonder(71),
|
||||
KeyCode::Up => taste::sonder(72),
|
||||
KeyCode::PageUp => taste::sonder(73),
|
||||
KeyCode::Left => taste::sonder(75),
|
||||
KeyCode::Right => taste::sonder(77),
|
||||
KeyCode::End => taste::sonder(79),
|
||||
KeyCode::Down => taste::sonder(80),
|
||||
KeyCode::PageDown => taste::sonder(81),
|
||||
KeyCode::Insert => taste::sonder(82),
|
||||
KeyCode::Delete => taste::sonder(83),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Ereignis::Taste(s))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zeichentaste_wird_zu_inkey_form() {
|
||||
let k = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
|
||||
assert_eq!(taste_zu_ereignis(k), Some(Ereignis::Taste("a".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sondertaste_traegt_fuehrendes_nullzeichen() {
|
||||
let k = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
|
||||
let Some(Ereignis::Taste(s)) = taste_zu_ereignis(k) else {
|
||||
panic!("keine Taste");
|
||||
};
|
||||
assert_eq!(s.chars().count(), 2);
|
||||
assert_eq!(s.chars().next(), Some('\0'));
|
||||
assert_eq!(s.chars().nth(1), Some(59 as char)); // F1 = 59
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strg_c_ist_abbruch() {
|
||||
let k = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||
assert_eq!(taste_zu_ereignis(k), Some(Ereignis::Abbruch));
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@
|
||||
//! Tastatur- und Mausereignisse.
|
||||
|
||||
pub mod screen;
|
||||
pub mod host; // Terminal-Host: Anzeige + Tastatur-/Größenereignisse
|
||||
pub mod forms;
|
||||
pub mod events;
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
//! Textbildschirm-Emulation: Zellenpuffer (Unicode-Zeichen + Farbattribut),
|
||||
//! Cursor, Scrollen — gerendert als Ratatui-Widget.
|
||||
//! Darstellung des Textbildschirms als Ratatui-Widget.
|
||||
//!
|
||||
//! Entscheidungen (siehe PLAN.md):
|
||||
//! - durchgängig Unicode, keine CP437-Emulation
|
||||
//! - **dynamische Größe**: der Bildschirm folgt der Terminalgröße; unterhalb
|
||||
//! der Mindestgröße (80×25) wird nur ein Hinweis gerendert (btop-artig),
|
||||
//! größere Terminals werden voll genutzt.
|
||||
//!
|
||||
//! Das Zellenmodell ist strikt 1 Zeichen = 1 Zelle; Zeichen mit
|
||||
//! Darstellungsbreite ≠ 1 sind eine offene Frage (PLAN.md).
|
||||
//! Der Zellenpuffer selbst lebt in `tb_runtime::screen` (Entscheidung
|
||||
//! 2026-09-03) — hier bleibt nur die Anbindung an das Terminal: die
|
||||
//! Farbabbildung und das Widget. Da `Widget` und `TextScreen` beide fremd
|
||||
//! sind, trägt das Widget ein Newtype.
|
||||
|
||||
pub use tb_runtime::screen::{Cell, TextScreen, MIN_COLS, MIN_ROWS};
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
/// Mindestgröße; darunter wird nur ein Hinweis angezeigt.
|
||||
pub const MIN_COLS: usize = 80;
|
||||
pub const MIN_ROWS: usize = 25;
|
||||
|
||||
/// Eine Bildschirmzelle: Zeichen plus klassisches Farbattribut.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Cell {
|
||||
pub ch: char,
|
||||
/// Vordergrund 0–15 (klassische Palette).
|
||||
pub fg: u8,
|
||||
/// Hintergrund 0–7.
|
||||
pub bg: u8,
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
fn default() -> Self {
|
||||
Cell { ch: ' ', fg: 7, bg: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Abbildung der klassischen 16-Farben-Palette auf ANSI-Indexfarben.
|
||||
/// (Klassisch: 1 = Blau, 4 = Rot — ANSI: 1 = Rot, 4 = Blau usw.)
|
||||
pub fn basic_color(n: u8) -> Color {
|
||||
@@ -42,200 +19,14 @@ pub fn basic_color(n: u8) -> Color {
|
||||
Color::Indexed(MAP[(n & 0x0F) as usize])
|
||||
}
|
||||
|
||||
/// Der emulierte Textbildschirm mit dynamischer Größe.
|
||||
///
|
||||
/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile, Spalte),
|
||||
/// wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextScreen {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
cells: Vec<Cell>,
|
||||
/// Cursorposition, 0-basiert intern.
|
||||
cur_row: usize,
|
||||
cur_col: usize,
|
||||
pub cursor_visible: bool,
|
||||
/// Aktuelle Ausgabefarben (`COLOR`).
|
||||
pub fg: u8,
|
||||
pub bg: u8,
|
||||
/// Scrollbereich (`VIEW PRINT`), 0-basiert inklusiv.
|
||||
view_top: usize,
|
||||
view_bottom: usize,
|
||||
/// true, solange kein eigenes `VIEW PRINT` gesetzt ist — der
|
||||
/// Scrollbereich folgt dann der Bildschirmgröße.
|
||||
view_full: bool,
|
||||
}
|
||||
|
||||
impl Default for TextScreen {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextScreen {
|
||||
/// Bildschirm in Mindestgröße (80×25).
|
||||
pub fn new() -> Self {
|
||||
Self::with_size(MIN_COLS, MIN_ROWS)
|
||||
}
|
||||
|
||||
/// Bildschirm in gegebener Größe (wird auf die Mindestgröße angehoben).
|
||||
pub fn with_size(cols: usize, rows: usize) -> Self {
|
||||
let cols = cols.max(MIN_COLS);
|
||||
let rows = rows.max(MIN_ROWS);
|
||||
TextScreen {
|
||||
cols,
|
||||
rows,
|
||||
cells: vec![Cell::default(); cols * rows],
|
||||
cur_row: 0,
|
||||
cur_col: 0,
|
||||
cursor_visible: true,
|
||||
fg: 7,
|
||||
bg: 0,
|
||||
view_top: 0,
|
||||
view_bottom: rows - 1,
|
||||
view_full: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
self.cols
|
||||
}
|
||||
pub fn rows(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
|
||||
/// An neue Terminalgröße anpassen: Inhalt bleibt oben links erhalten,
|
||||
/// neue Zellen sind leer. Unterhalb der Mindestgröße bleibt der Puffer
|
||||
/// bei 80×25 (das Widget zeigt dann den Zu-klein-Hinweis).
|
||||
pub fn resize(&mut self, cols: usize, rows: usize) {
|
||||
let cols = cols.max(MIN_COLS);
|
||||
let rows = rows.max(MIN_ROWS);
|
||||
if cols == self.cols && rows == self.rows {
|
||||
return;
|
||||
}
|
||||
let mut cells = vec![Cell { ch: ' ', fg: self.fg, bg: self.bg }; cols * rows];
|
||||
for row in 0..self.rows.min(rows) {
|
||||
for col in 0..self.cols.min(cols) {
|
||||
cells[row * cols + col] = self.cells[row * self.cols + col];
|
||||
}
|
||||
}
|
||||
self.cells = cells;
|
||||
self.cols = cols;
|
||||
self.rows = rows;
|
||||
self.cur_row = self.cur_row.min(rows - 1);
|
||||
self.cur_col = self.cur_col.min(cols - 1);
|
||||
if self.view_full {
|
||||
self.view_top = 0;
|
||||
self.view_bottom = rows - 1;
|
||||
} else {
|
||||
self.view_top = self.view_top.min(rows - 1);
|
||||
self.view_bottom = self.view_bottom.min(rows - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `CLS`: Scrollbereich mit aktueller Hintergrundfarbe löschen,
|
||||
/// Cursor an den Anfang des Bereichs.
|
||||
pub fn cls(&mut self) {
|
||||
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg };
|
||||
for row in self.view_top..=self.view_bottom {
|
||||
self.cells[row * self.cols..(row + 1) * self.cols].fill(blank);
|
||||
}
|
||||
self.cur_row = self.view_top;
|
||||
self.cur_col = 0;
|
||||
}
|
||||
|
||||
/// `COLOR vg, hg` (vg 0–31, hg 0–7). Blinkende Vordergrundfarben
|
||||
/// (16–31) werden als „hell" simuliert (Entscheidung 2026-09-02):
|
||||
/// echtes Terminal-Blinken ist nicht überall verfügbar.
|
||||
pub fn set_color(&mut self, fg: u8, bg: u8) {
|
||||
let fg = if fg >= 16 { (fg & 0x0F) | 8 } else { fg };
|
||||
self.fg = fg & 0x0F;
|
||||
self.bg = bg & 0x07;
|
||||
}
|
||||
|
||||
/// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5).
|
||||
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> {
|
||||
if row < 1 || row > self.rows || col < 1 || col > self.cols {
|
||||
return Err(());
|
||||
}
|
||||
self.cur_row = row - 1;
|
||||
self.cur_col = col - 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `CSRLIN` (1-basiert).
|
||||
pub fn csrlin(&self) -> usize {
|
||||
self.cur_row + 1
|
||||
}
|
||||
|
||||
/// `POS(0)` (1-basiert).
|
||||
pub fn pos(&self) -> usize {
|
||||
self.cur_col + 1
|
||||
}
|
||||
|
||||
/// `VIEW PRINT oben TO unten` (1-basiert).
|
||||
pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ()> {
|
||||
if top < 1 || bottom > self.rows || top > bottom {
|
||||
return Err(());
|
||||
}
|
||||
self.view_top = top - 1;
|
||||
self.view_bottom = bottom - 1;
|
||||
self.view_full = top == 1 && bottom == self.rows;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cell(&self, row: usize, col: usize) -> Cell {
|
||||
self.cells[(row - 1) * self.cols + (col - 1)]
|
||||
}
|
||||
|
||||
/// Text an der Cursorposition ausgeben: Umbruch am rechten Rand,
|
||||
/// Scrollen am unteren Rand des Scrollbereichs. `\n` bricht um,
|
||||
/// `\r` setzt an den Zeilenanfang.
|
||||
pub fn print(&mut self, text: &str) {
|
||||
for ch in text.chars() {
|
||||
match ch {
|
||||
'\n' => self.newline(),
|
||||
'\r' => self.cur_col = 0,
|
||||
_ => {
|
||||
self.cells[self.cur_row * self.cols + self.cur_col] =
|
||||
Cell { ch, fg: self.fg, bg: self.bg };
|
||||
self.cur_col += 1;
|
||||
if self.cur_col >= self.cols {
|
||||
self.newline();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeilenumbruch inkl. Scrollen im Scrollbereich.
|
||||
fn newline(&mut self) {
|
||||
self.cur_col = 0;
|
||||
if self.cur_row >= self.view_bottom {
|
||||
self.scroll_up();
|
||||
self.cur_row = self.view_bottom;
|
||||
} else {
|
||||
self.cur_row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollbereich um eine Zeile nach oben schieben; unterste Zeile leeren.
|
||||
pub fn scroll_up(&mut self) {
|
||||
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg };
|
||||
for row in self.view_top..self.view_bottom {
|
||||
let (a, b) = self.cells.split_at_mut((row + 1) * self.cols);
|
||||
a[row * self.cols..].copy_from_slice(&b[..self.cols]);
|
||||
}
|
||||
self.cells[self.view_bottom * self.cols..(self.view_bottom + 1) * self.cols]
|
||||
.fill(blank);
|
||||
}
|
||||
}
|
||||
/// Zeichenbares Gegenstück zu [`TextScreen`].
|
||||
pub struct ScreenWidget<'a>(pub &'a TextScreen);
|
||||
|
||||
/// Rendert den Bildschirm oben links in die verfügbare Fläche. Ist das
|
||||
/// Terminal kleiner als die Mindestgröße, erscheint nur ein Hinweis
|
||||
/// (btop-artig); die Anwendung ruft bei Resize `TextScreen::resize` auf,
|
||||
/// damit Puffer und Terminal deckungsgleich bleiben.
|
||||
impl Widget for &TextScreen {
|
||||
impl Widget for ScreenWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if (area.width as usize) < MIN_COLS || (area.height as usize) < MIN_ROWS {
|
||||
let msg = format!(
|
||||
@@ -245,11 +36,16 @@ impl Widget for &TextScreen {
|
||||
buf.set_string(area.x, area.y, msg, Style::default().fg(Color::Red));
|
||||
return;
|
||||
}
|
||||
let cols = self.cols.min(area.width as usize);
|
||||
let rows = self.rows.min(area.height as usize);
|
||||
let cols = self.0.cols().min(area.width as usize);
|
||||
let rows = self.0.rows().min(area.height as usize);
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let c = self.cells[row * self.cols + col];
|
||||
let c = self.0.cell(row + 1, col + 1);
|
||||
if c.fortsetzung {
|
||||
// Die Zelle gehört zum breiten Zeichen davor; das Terminal
|
||||
// belegt sie beim Zeichnen selbst mit.
|
||||
continue;
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((area.x + col as u16, area.y + row as u16)) {
|
||||
let mut s = String::new();
|
||||
s.push(c.ch);
|
||||
@@ -262,8 +58,8 @@ impl Widget for &TextScreen {
|
||||
}
|
||||
// Cursor als invertierte Zelle darstellen (Terminal-Cursor wird in
|
||||
// der Forms-/Runtime-Schicht später gezielt gesteuert).
|
||||
if self.cursor_visible && self.cur_row < rows && self.cur_col < cols {
|
||||
let (cx, cy) = (area.x + self.cur_col as u16, area.y + self.cur_row as u16);
|
||||
if self.0.cursor_visible && self.0.csrlin() <= rows && self.0.pos() <= cols {
|
||||
let (cx, cy) = (area.x + (self.0.pos() - 1) as u16, area.y + (self.0.csrlin() - 1) as u16);
|
||||
if let Some(cell) = buf.cell_mut((cx, cy)) {
|
||||
cell.set_style(
|
||||
cell.style().add_modifier(ratatui::style::Modifier::REVERSED),
|
||||
@@ -277,104 +73,6 @@ impl Widget for &TextScreen {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn print_schreibt_und_bewegt_cursor() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("AB");
|
||||
assert_eq!(s.cell(1, 1).ch, 'A');
|
||||
assert_eq!(s.cell(1, 2).ch, 'B');
|
||||
assert_eq!((s.csrlin(), s.pos()), (1, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_zeichen_belegen_eine_zelle() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print("Ä☃");
|
||||
assert_eq!(s.cell(1, 1).ch, 'Ä');
|
||||
assert_eq!(s.cell(1, 2).ch, '☃');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn umbruch_am_rechten_rand() {
|
||||
let mut s = TextScreen::new();
|
||||
s.print(&"x".repeat(81));
|
||||
assert_eq!(s.cell(1, 80).ch, 'x');
|
||||
assert_eq!(s.cell(2, 1).ch, 'x');
|
||||
assert_eq!((s.csrlin(), s.pos()), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollen_am_unteren_rand() {
|
||||
let mut s = TextScreen::new();
|
||||
s.locate(25, 1).unwrap();
|
||||
s.print("unten\n"); // erzwingt Scroll
|
||||
assert_eq!(s.cell(24, 1).ch, 'u');
|
||||
assert_eq!(s.cell(25, 1).ch, ' ');
|
||||
assert_eq!(s.csrlin(), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_print_begrenzt_scrollen() {
|
||||
let mut s = TextScreen::new();
|
||||
s.locate(1, 1).unwrap();
|
||||
s.print("kopf");
|
||||
s.view_print(3, 5).unwrap();
|
||||
s.locate(5, 1).unwrap();
|
||||
s.print("a\nb"); // scrollt nur Zeilen 3–5
|
||||
assert_eq!(s.cell(1, 1).ch, 'k');
|
||||
assert_eq!(s.cell(4, 1).ch, 'a');
|
||||
assert_eq!(s.cell(5, 1).ch, 'b');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_prueft_grenzen() {
|
||||
let mut s = TextScreen::new();
|
||||
assert!(s.locate(0, 1).is_err());
|
||||
assert!(s.locate(26, 1).is_err());
|
||||
assert!(s.locate(25, 80).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamische_groesse_und_resize() {
|
||||
let mut s = TextScreen::with_size(120, 40);
|
||||
assert_eq!((s.cols(), s.rows()), (120, 40));
|
||||
s.locate(40, 1).unwrap();
|
||||
s.print("!");
|
||||
assert_eq!(s.cell(40, 1).ch, '!');
|
||||
|
||||
// Verkleinern: Inhalt oben links bleibt, Cursor wird geklemmt
|
||||
s.locate(1, 1).unwrap();
|
||||
s.print("K");
|
||||
s.resize(100, 30);
|
||||
assert_eq!((s.cols(), s.rows()), (100, 30));
|
||||
assert_eq!(s.cell(1, 1).ch, 'K');
|
||||
|
||||
// Unter Minimum wird auf 80×25 geklemmt
|
||||
s.resize(10, 5);
|
||||
assert_eq!((s.cols(), s.rows()), (80, 25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blink_wird_als_hell_simuliert() {
|
||||
let mut s = TextScreen::new();
|
||||
s.set_color(17, 0); // blinkend Blau → helles Blau
|
||||
assert_eq!(s.fg, 9);
|
||||
s.set_color(31, 0); // blinkend Hellweiß → Hellweiß
|
||||
assert_eq!(s.fg, 15);
|
||||
s.set_color(7, 0);
|
||||
assert_eq!(s.fg, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_folgt_vollem_scrollbereich() {
|
||||
let mut s = TextScreen::new();
|
||||
s.resize(90, 40);
|
||||
s.locate(40, 1).unwrap();
|
||||
s.print("a\nb"); // Scroll am neuen unteren Rand
|
||||
assert_eq!(s.cell(39, 1).ch, 'a');
|
||||
assert_eq!(s.cell(40, 1).ch, 'b');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn farbabbildung() {
|
||||
assert_eq!(basic_color(1), Color::Indexed(4)); // klassisch Blau
|
||||
|
||||
@@ -316,11 +316,18 @@ instrs! {
|
||||
0xC8 RaiseError; // Code vom Stack (ERROR n)
|
||||
0xC9 LoadErr;
|
||||
0xCA LoadErl;
|
||||
0xCB SetErr; // ERR = n (setzt den Code, löst nichts aus)
|
||||
|
||||
// 0xD0 — DATA und Eingabe
|
||||
0xD0 ReadData(a: u8); // nächstes DATA-Element; 0 = String, 1 = Zahl (DOUBLE)
|
||||
0xD1 Restore(a: u32);
|
||||
0xD2 Input(a: u8, b: bool, c: u16, d: bool); // argc, line_mode, prompt (0xFFFF=ohne), '?'
|
||||
0xD3 InputFile(a: u8, b: bool); // argc, line_mode — Dateinummer liegt unter den Referenzen
|
||||
// put?, mit Recordnummer?, Feldart (0 = ohne Variable, s. `Feldart`),
|
||||
// Zusatz (UDT-Index bzw. Länge fester Strings)
|
||||
0xD4 GetPut(a: bool, b: bool, c: u8, d: u16);
|
||||
0xD5 Field(a: u8); // Feldzahl; Stack: Dateinummer, dann (Länge, Referenz)*
|
||||
0xD6 LsetRset(a: bool); // rset?; Stack: Referenz, Wert // argc, line_mode — Dateinummer liegt unter den Referenzen
|
||||
}
|
||||
|
||||
// ---- Modulstruktur ------------------------------------------------------------
|
||||
|
||||
@@ -19,6 +19,7 @@ pub fn compile(hir: &HirModule) -> CompiledModule {
|
||||
strings: Vec::new(),
|
||||
string_ids: HashMap::new(),
|
||||
jump_tables: Vec::new(),
|
||||
modul_label_pc: Vec::new(),
|
||||
};
|
||||
let mut procs = Vec::new();
|
||||
for proc in &hir.procs {
|
||||
@@ -74,6 +75,10 @@ struct Codegen {
|
||||
strings: Vec<Rc<str>>,
|
||||
string_ids: HashMap<String, u16>,
|
||||
jump_tables: Vec<Vec<u32>>,
|
||||
/// Sprungziele des Modulrumpfs (Prozedur 0). Ein modulweites
|
||||
/// `ON ERROR GOTO` aus einer Prozedur zeigt dorthin; da Prozedur 0
|
||||
/// zuerst übersetzt wird, stehen die Positionen rechtzeitig fest.
|
||||
modul_label_pc: Vec<Option<u32>>,
|
||||
}
|
||||
|
||||
struct ProcCtx {
|
||||
@@ -137,9 +142,26 @@ impl Codegen {
|
||||
// Rumpfende
|
||||
self.emit_proc_exit(&mut ctx, proc);
|
||||
|
||||
// Prozedur 0 (Modulrumpf) gibt ihre Sprungziele weiter.
|
||||
if proc.kind == hir::HProcKind::Main {
|
||||
self.modul_label_pc = ctx.label_pc.clone();
|
||||
}
|
||||
|
||||
// Fixups patchen
|
||||
for (idx, label) in std::mem::take(&mut ctx.fixups) {
|
||||
let pc = ctx.label_pc[label as usize].expect("Label ohne Position");
|
||||
// Modulweites `ON ERROR GOTO` in einer Prozedur: das Label lebt
|
||||
// im Modulrumpf, nicht im eigenen.
|
||||
let modulweit = proc.kind != hir::HProcKind::Main
|
||||
&& matches!(ctx.code[idx], Instr::OnErrorGoto(_));
|
||||
let pc = if modulweit {
|
||||
self.modul_label_pc
|
||||
.get(label as usize)
|
||||
.copied()
|
||||
.flatten()
|
||||
.expect("Modul-Label ohne Position")
|
||||
} else {
|
||||
ctx.label_pc[label as usize].expect("Label ohne Position")
|
||||
};
|
||||
match &mut ctx.code[idx] {
|
||||
Instr::Jump(t)
|
||||
| Instr::JumpIfFalse(t)
|
||||
@@ -224,7 +246,57 @@ impl Codegen {
|
||||
ctx.emit(Instr::CallBuiltin(ids::PRINT_NEWLINE, 0));
|
||||
}
|
||||
}
|
||||
HStmtKind::Input { line_mode, prompt, question, targets } => {
|
||||
HStmtKind::SetErr(e) => {
|
||||
self.expr(ctx, e);
|
||||
ctx.emit(Instr::SetErr);
|
||||
}
|
||||
HStmtKind::Field { file, fields } => {
|
||||
self.expr(ctx, file);
|
||||
for (len, place) in fields {
|
||||
self.expr(ctx, len);
|
||||
self.make_ref(ctx, place);
|
||||
}
|
||||
ctx.emit(Instr::Field(fields.len() as u8));
|
||||
}
|
||||
HStmtKind::LsetRset { rset, target, value } => {
|
||||
self.make_ref(ctx, target);
|
||||
self.expr(ctx, value);
|
||||
ctx.emit(Instr::LsetRset(*rset));
|
||||
}
|
||||
HStmtKind::GetPut { put, file, recnum, var } => {
|
||||
self.expr(ctx, file);
|
||||
if let Some(r) = recnum {
|
||||
self.expr(ctx, r);
|
||||
}
|
||||
let (art, zusatz) = match var {
|
||||
None => (0u8, 0u16),
|
||||
Some(v) => {
|
||||
self.make_ref(ctx, v);
|
||||
match &v.ty {
|
||||
HTy::Num(NumTy::Int) => (1, 0),
|
||||
HTy::Num(NumTy::Lng) => (2, 0),
|
||||
HTy::Num(NumTy::Sng) => (3, 0),
|
||||
HTy::Num(NumTy::Dbl) => (4, 0),
|
||||
HTy::Num(NumTy::Cur) => (5, 0),
|
||||
HTy::FixedStr(n) => (6, *n as u16),
|
||||
HTy::Udt(i) => (7, *i),
|
||||
// Variable Strings: Länge erst zur Laufzeit.
|
||||
HTy::Str => (8, 0),
|
||||
}
|
||||
}
|
||||
};
|
||||
ctx.emit(Instr::GetPut(*put, recnum.is_some(), art, zusatz));
|
||||
}
|
||||
HStmtKind::Input { file, line_mode, prompt, question, targets } => {
|
||||
if let Some(f) = file {
|
||||
// Dateinummer zuerst, dann die Referenzen darüber.
|
||||
self.expr(ctx, f);
|
||||
for t in targets {
|
||||
self.make_ref(ctx, t);
|
||||
}
|
||||
ctx.emit(Instr::InputFile(targets.len() as u8, *line_mode));
|
||||
return;
|
||||
}
|
||||
for t in targets {
|
||||
self.make_ref(ctx, t);
|
||||
}
|
||||
@@ -901,9 +973,90 @@ fn builtin_id(b: Builtin) -> u16 {
|
||||
Builtin::PrintTab => ids::PRINT_TAB,
|
||||
Builtin::PrintSpc => ids::PRINT_SPC,
|
||||
Builtin::PrintNewline => ids::PRINT_NEWLINE,
|
||||
Builtin::PrintUsing => ids::PRINT_USING,
|
||||
Builtin::FormatS => ids::FORMAT_S,
|
||||
Builtin::SetFormatCc => ids::SET_FORMAT_CC,
|
||||
Builtin::Cls => ids::CLS,
|
||||
Builtin::Color => ids::COLOR,
|
||||
Builtin::Locate => ids::LOCATE,
|
||||
Builtin::Width => ids::WIDTH,
|
||||
Builtin::ViewPrint => ids::VIEW_PRINT,
|
||||
Builtin::ScreenStmt => ids::SCREEN_STMT,
|
||||
Builtin::KeyAssign => ids::KEY_ASSIGN,
|
||||
Builtin::KeyList => ids::KEY_LIST,
|
||||
Builtin::KeyDisplay => ids::KEY_DISPLAY,
|
||||
Builtin::Csrlin => ids::CSRLIN,
|
||||
Builtin::PosFn => ids::POS_FN,
|
||||
Builtin::ScreenFn => ids::SCREEN_FN,
|
||||
Builtin::InkeyS => ids::INKEY_S,
|
||||
Builtin::InputS => ids::INPUT_S,
|
||||
Builtin::EnvironS => ids::ENVIRON_S,
|
||||
Builtin::EnvironSet => ids::ENVIRON_SET,
|
||||
Builtin::Fre => ids::FRE,
|
||||
Builtin::Clear => ids::CLEAR,
|
||||
Builtin::Tron => ids::TRON,
|
||||
Builtin::Troff => ids::TROFF,
|
||||
Builtin::StackFn => ids::STACK_FN,
|
||||
Builtin::StackStmt => ids::STACK_STMT,
|
||||
Builtin::Erdev => ids::ERDEV,
|
||||
Builtin::ErdevS => ids::ERDEV_S,
|
||||
Builtin::Open => ids::OPEN,
|
||||
Builtin::Close => ids::CLOSE,
|
||||
Builtin::CloseAll => ids::CLOSE_ALL,
|
||||
Builtin::PrintZiel => ids::PRINT_ZIEL,
|
||||
Builtin::WriteFile => ids::WRITE_FILE,
|
||||
Builtin::EofF => ids::EOF_F,
|
||||
Builtin::LofF => ids::LOF_F,
|
||||
Builtin::LocF => ids::LOC_F,
|
||||
Builtin::SeekF => ids::SEEK_F,
|
||||
Builtin::SeekStmt => ids::SEEK_STMT,
|
||||
Builtin::Freefile => ids::FREEFILE,
|
||||
Builtin::Fileattr => ids::FILEATTR,
|
||||
Builtin::LockStmt => ids::LOCK_STMT,
|
||||
Builtin::Kill => ids::KILL,
|
||||
Builtin::NameStmt => ids::NAME_STMT,
|
||||
Builtin::Files => ids::FILES,
|
||||
Builtin::Chdir => ids::CHDIR,
|
||||
Builtin::Chdrive => ids::CHDRIVE,
|
||||
Builtin::Mkdir => ids::MKDIR,
|
||||
Builtin::Rmdir => ids::RMDIR,
|
||||
Builtin::CurdirS => ids::CURDIR_S,
|
||||
Builtin::DirS => ids::DIR_S,
|
||||
Builtin::Lpos => ids::LPOS,
|
||||
Builtin::ShellStmt => ids::SHELL_STMT,
|
||||
Builtin::ShellFn => ids::SHELL_FN,
|
||||
Builtin::MkS => ids::MK_S,
|
||||
Builtin::CvF => ids::CV_F,
|
||||
Builtin::Fv => ids::FV,
|
||||
Builtin::Pv => ids::PV,
|
||||
Builtin::Pmt => ids::PMT,
|
||||
Builtin::NPer => ids::NPER,
|
||||
Builtin::IPmt => ids::IPMT,
|
||||
Builtin::PPmt => ids::PPMT,
|
||||
Builtin::Rate => ids::RATE,
|
||||
Builtin::Npv => ids::NPV,
|
||||
Builtin::Irr => ids::IRR,
|
||||
Builtin::Mirr => ids::MIRR,
|
||||
Builtin::Sln => ids::SLN,
|
||||
Builtin::Syd => ids::SYD,
|
||||
Builtin::Ddb => ids::DDB,
|
||||
Builtin::Timer => ids::TIMER,
|
||||
Builtin::DateS => ids::DATE_S,
|
||||
Builtin::TimeS => ids::TIME_S,
|
||||
Builtin::DateSet => ids::DATE_SET,
|
||||
Builtin::TimeSet => ids::TIME_SET,
|
||||
Builtin::Now => ids::NOW,
|
||||
Builtin::DateSerial => ids::DATE_SERIAL,
|
||||
Builtin::TimeSerial => ids::TIME_SERIAL,
|
||||
Builtin::DateValue => ids::DATE_VALUE,
|
||||
Builtin::TimeValue => ids::TIME_VALUE,
|
||||
Builtin::DayF => ids::DAY_F,
|
||||
Builtin::MonthF => ids::MONTH_F,
|
||||
Builtin::YearF => ids::YEAR_F,
|
||||
Builtin::WeekdayF => ids::WEEKDAY_F,
|
||||
Builtin::HourF => ids::HOUR_F,
|
||||
Builtin::MinuteF => ids::MINUTE_F,
|
||||
Builtin::SecondF => ids::SECOND_F,
|
||||
Builtin::CommandS => ids::COMMAND_S,
|
||||
Builtin::Doevents => ids::DOEVENTS,
|
||||
Builtin::Sleep => ids::SLEEP,
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct Vm {
|
||||
resume_pc: usize,
|
||||
// Steuerung
|
||||
flags: u32,
|
||||
/// Zählt Anweisungsgrenzen für die regelmäßige Ereignisabholung.
|
||||
tick_zaehler: u32,
|
||||
breakpoints: HashSet<u32>,
|
||||
data_ptr: usize,
|
||||
}
|
||||
@@ -99,6 +101,7 @@ impl Vm {
|
||||
in_handler: false,
|
||||
resume_pc: 0,
|
||||
flags: 0,
|
||||
tick_zaehler: 0,
|
||||
breakpoints: HashSet::new(),
|
||||
data_ptr: 0,
|
||||
module,
|
||||
@@ -508,6 +511,15 @@ impl Vm {
|
||||
let f = self.frames.last_mut().unwrap();
|
||||
f.line = line;
|
||||
f.last_stmt_pc = pc;
|
||||
// Zustellpunkt: anzeigen, wenn sich der Bildschirm geändert
|
||||
// hat, und regelmäßig Ereignisse abholen. Das ist keine
|
||||
// Debugger-Funktion — ohne sie sähe niemand die Ausgabe und
|
||||
// Größenänderungen kämen nie an.
|
||||
self.tick_zaehler = self.tick_zaehler.wrapping_add(1);
|
||||
if self.rt.screen.ist_veraendert() || self.tick_zaehler % 1024 == 0 {
|
||||
self.rt.tick(host);
|
||||
self.rt.screen.veraenderung_quittieren();
|
||||
}
|
||||
if self.flags != 0 {
|
||||
if self.flags & F_STEP != 0 {
|
||||
return Ok(Flow::Event(RunEvent::Stepped { line }));
|
||||
@@ -515,7 +527,7 @@ impl Vm {
|
||||
if self.flags & F_BREAK != 0 && self.breakpoints.contains(&line) {
|
||||
return Ok(Flow::Event(RunEvent::Breakpoint { line }));
|
||||
}
|
||||
if self.flags & F_POLL != 0 && host.interrupted() {
|
||||
if self.flags & F_POLL != 0 && self.rt.abbruch {
|
||||
return Ok(Flow::Event(RunEvent::Interrupted { line }));
|
||||
}
|
||||
}
|
||||
@@ -1204,6 +1216,30 @@ impl Vm {
|
||||
self.do_input(argc, line_mode, prompt, question, host)?;
|
||||
Ok(Flow::Normal)
|
||||
}
|
||||
I::InputFile(argc, line_mode) => {
|
||||
self.do_input_file(argc, line_mode)?;
|
||||
Ok(Flow::Normal)
|
||||
}
|
||||
I::GetPut(put, mit_nr, art, zusatz) => {
|
||||
self.do_get_put(put, mit_nr, art, zusatz)?;
|
||||
Ok(Flow::Normal)
|
||||
}
|
||||
I::SetErr => {
|
||||
let n = self.pop_i32()?;
|
||||
if !(0..=32767).contains(&n) {
|
||||
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
|
||||
}
|
||||
self.err = n as u16;
|
||||
Ok(Flow::Normal)
|
||||
}
|
||||
I::Field(anzahl) => {
|
||||
self.do_field(anzahl)?;
|
||||
Ok(Flow::Normal)
|
||||
}
|
||||
I::LsetRset(rset) => {
|
||||
self.do_lset_rset(rset)?;
|
||||
Ok(Flow::Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,15 +1283,14 @@ impl Vm {
|
||||
loop {
|
||||
if prompt != 0xFFFF {
|
||||
let p = self.module.strings[prompt as usize].clone();
|
||||
self.rt.print.write(host, &p);
|
||||
self.rt.screen.print(&p);
|
||||
}
|
||||
if question {
|
||||
self.rt.print.write(host, "? ");
|
||||
self.rt.screen.print("? ");
|
||||
}
|
||||
let Some(mut line) = host.read_line() else {
|
||||
let Some(mut line) = self.rt.read_line(host) else {
|
||||
return Err(RuntimeError(62)); // Input past end of file
|
||||
};
|
||||
self.rt.print.col = 0;
|
||||
if line_mode {
|
||||
let r = refs.first().ok_or(RuntimeError(51))?;
|
||||
self.write_ref(r, Value::Str(Rc::from(line.as_str())))?;
|
||||
@@ -1264,7 +1299,7 @@ impl Vm {
|
||||
// Felder trennen (Quotes respektieren).
|
||||
let fields = split_input_fields(&line);
|
||||
if fields.len() != refs.len() {
|
||||
self.rt.print.write(host, "Redo from start\n");
|
||||
self.rt.screen.print("Redo from start\n");
|
||||
line.clear();
|
||||
continue;
|
||||
}
|
||||
@@ -1282,7 +1317,7 @@ impl Vm {
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
self.rt.print.write(host, "Redo from start\n");
|
||||
self.rt.screen.print("Redo from start\n");
|
||||
continue;
|
||||
}
|
||||
for (r, v) in refs.iter().zip(vals) {
|
||||
@@ -1292,6 +1327,230 @@ impl Vm {
|
||||
}
|
||||
}
|
||||
|
||||
/// `FIELD #n, laenge AS var$, …` — Recordpuffer aufteilen. Die
|
||||
/// Feldvariablen werden gemerkt: `GET` frischt sie aus dem Puffer auf,
|
||||
/// `LSET`/`RSET` schreiben zugleich in den Puffer.
|
||||
fn do_field(&mut self, anzahl: u8) -> Result<(), RuntimeError> {
|
||||
let mut paare = Vec::with_capacity(anzahl as usize);
|
||||
for _ in 0..anzahl {
|
||||
let r = match self.pop()? {
|
||||
Value::Ref(r) => r,
|
||||
_ => return Err(RuntimeError::TYPE_MISMATCH),
|
||||
};
|
||||
let laenge = self.pop_i32()?;
|
||||
paare.push((laenge, r));
|
||||
}
|
||||
paare.reverse();
|
||||
let nummer = self.pop_i32()?;
|
||||
let datei = self.rt.dateien.get(nummer)?;
|
||||
datei.felder.clear();
|
||||
let mut start = 0usize;
|
||||
for (laenge, r) in paare {
|
||||
if laenge < 0 {
|
||||
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
|
||||
}
|
||||
let n = laenge as usize;
|
||||
if start + n * tb_runtime::fileio::UTF32_BREITE > datei.puffer.len() {
|
||||
return Err(RuntimeError(50)); // FIELD overflow
|
||||
}
|
||||
datei.felder.push(tb_runtime::fileio::Feld {
|
||||
referenz: r,
|
||||
start,
|
||||
laenge: n,
|
||||
});
|
||||
start += n * tb_runtime::fileio::UTF32_BREITE;
|
||||
}
|
||||
// Feldvariablen sofort aus dem Puffer belegen.
|
||||
self.felder_auffrischen(nummer)
|
||||
}
|
||||
|
||||
/// Feldvariablen einer Datei aus dem Recordpuffer neu belegen.
|
||||
fn felder_auffrischen(&mut self, nummer: i32) -> Result<(), RuntimeError> {
|
||||
let datei = self.rt.dateien.get(nummer)?;
|
||||
let werte: Vec<(VarRef, String)> = datei
|
||||
.felder
|
||||
.iter()
|
||||
.map(|f| {
|
||||
(
|
||||
f.referenz.clone(),
|
||||
tb_runtime::fileio::feld_lesen(&datei.puffer, f.start, f.laenge),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (r, t) in werte {
|
||||
self.write_ref(&r, Value::Str(Rc::from(t.as_str())))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `LSET`/`RSET` — bündig zuweisen; bei einem `FIELD`-Feld zusätzlich in
|
||||
/// den Recordpuffer schreiben.
|
||||
fn do_lset_rset(&mut self, rset: bool) -> Result<(), RuntimeError> {
|
||||
let wert = self.pop()?;
|
||||
let ziel = match self.pop()? {
|
||||
Value::Ref(r) => r,
|
||||
_ => return Err(RuntimeError::TYPE_MISMATCH),
|
||||
};
|
||||
let Value::Str(text) = &wert else {
|
||||
// UDT-Zuweisung: unverändert durchreichen (Vorbild).
|
||||
self.write_ref(&ziel, wert)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Gehört das Ziel zu einem FIELD-Puffer?
|
||||
let treffer = self.rt.dateien.feld_finden(&ziel);
|
||||
match treffer {
|
||||
Some((nummer, start, laenge)) => {
|
||||
let datei = self.rt.dateien.get(nummer)?;
|
||||
tb_runtime::fileio::feld_setzen(
|
||||
&mut datei.puffer,
|
||||
start,
|
||||
laenge,
|
||||
text,
|
||||
rset,
|
||||
);
|
||||
let neu = tb_runtime::fileio::feld_lesen(&datei.puffer, start, laenge);
|
||||
self.write_ref(&ziel, Value::Str(Rc::from(neu.as_str())))?;
|
||||
}
|
||||
None => {
|
||||
// Ohne Feldbindung wirkt LSET/RSET auf die Stringlänge des
|
||||
// bisherigen Werts (Vorbild).
|
||||
let alt = match self.read_ref(&ziel)? {
|
||||
Value::Str(s) => s.chars().count(),
|
||||
_ => text.chars().count(),
|
||||
};
|
||||
let mut puffer = vec![0u8; alt * tb_runtime::fileio::UTF32_BREITE];
|
||||
tb_runtime::fileio::feld_setzen(&mut puffer, 0, alt, text, rset);
|
||||
let neu = tb_runtime::fileio::feld_lesen(&puffer, 0, alt);
|
||||
self.write_ref(&ziel, Value::Str(Rc::from(neu.as_str())))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `GET`/`PUT` — Datensatz (RANDOM) bzw. Bytes (BINARY) übertragen.
|
||||
fn do_get_put(
|
||||
&mut self,
|
||||
put: bool,
|
||||
mit_nr: bool,
|
||||
art: u8,
|
||||
zusatz: u16,
|
||||
) -> Result<(), RuntimeError> {
|
||||
use tb_runtime::fileio as fio;
|
||||
let ziel = if art != 0 {
|
||||
match self.pop()? {
|
||||
Value::Ref(r) => Some(r),
|
||||
_ => return Err(RuntimeError::TYPE_MISMATCH),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let nr = if mit_nr { Some(self.pop_i32()?) } else { None };
|
||||
let nummer = self.pop_i32()?;
|
||||
|
||||
let udts = self.module.udts.clone();
|
||||
let datei = self.rt.dateien.get(nummer)?;
|
||||
let binaer = datei.modus == fio::Modus::Binary;
|
||||
// Ohne Nummer gilt die Position nach der letzten Operation.
|
||||
let pos = match nr {
|
||||
Some(n) if n >= 1 => n as u64,
|
||||
Some(_) => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
|
||||
None => datei.position,
|
||||
};
|
||||
|
||||
// Typ der Zielvariablen bestimmt die übertragene Länge.
|
||||
let typ = match art {
|
||||
0 => None,
|
||||
1 => Some(TypeInit::Int),
|
||||
2 => Some(TypeInit::Lng),
|
||||
3 => Some(TypeInit::Sng),
|
||||
4 => Some(TypeInit::Dbl),
|
||||
5 => Some(TypeInit::Cur),
|
||||
6 => Some(TypeInit::FixedStr(zusatz as u32)),
|
||||
7 => Some(TypeInit::Udt(zusatz)),
|
||||
// Variabler String: aktuelle Zeichenzahl bestimmt die Länge.
|
||||
_ => {
|
||||
let laenge = match self.read_ref(ziel.as_ref().unwrap())? {
|
||||
Value::Str(s) => s.chars().count() as u32,
|
||||
_ => return Err(RuntimeError::TYPE_MISMATCH),
|
||||
};
|
||||
Some(TypeInit::FixedStr(laenge))
|
||||
}
|
||||
};
|
||||
|
||||
if binaer {
|
||||
let Some(t) = typ else {
|
||||
// Ohne Variable überträgt BINARY nichts.
|
||||
return Ok(());
|
||||
};
|
||||
let n = fio::breite(&t, &udts).ok_or(RuntimeError::TYPE_MISMATCH)?;
|
||||
if put {
|
||||
let wert = self.read_ref(ziel.as_ref().unwrap())?;
|
||||
let mut puffer = vec![0u8; n];
|
||||
fio::wert_schreiben(&mut puffer, 0, &wert, &t, &udts)?;
|
||||
self.rt.dateien.get(nummer)?.bytes_schreiben(pos, &puffer)?;
|
||||
} else {
|
||||
let puffer = self.rt.dateien.get(nummer)?.bytes_lesen(pos, n)?;
|
||||
let (v, _) = fio::wert_lesen(&puffer, 0, &t, &udts)?;
|
||||
self.write_ref(ziel.as_ref().unwrap(), v)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// RANDOM: über den Recordpuffer.
|
||||
if put {
|
||||
if let (Some(r), Some(t)) = (&ziel, &typ) {
|
||||
let wert = self.read_ref(r)?;
|
||||
let datei = self.rt.dateien.get(nummer)?;
|
||||
let mut puffer = std::mem::take(&mut datei.puffer);
|
||||
puffer.iter_mut().for_each(|b| *b = b' ');
|
||||
let ergebnis = fio::wert_schreiben(&mut puffer, 0, &wert, t, &udts);
|
||||
self.rt.dateien.get(nummer)?.puffer = puffer;
|
||||
ergebnis?;
|
||||
}
|
||||
self.rt.dateien.get(nummer)?.record_schreiben(pos)?;
|
||||
} else {
|
||||
self.rt.dateien.get(nummer)?.record_lesen(pos)?;
|
||||
self.felder_auffrischen(nummer)?;
|
||||
if let (Some(r), Some(t)) = (&ziel, &typ) {
|
||||
let puffer = self.rt.dateien.get(nummer)?.puffer.clone();
|
||||
let (v, _) = fio::wert_lesen(&puffer, 0, t, &udts)?;
|
||||
self.write_ref(r, v)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `INPUT #n` / `LINE INPUT #n` — Werte aus einer sequenziellen Datei.
|
||||
fn do_input_file(&mut self, argc: u8, line_mode: bool) -> Result<(), RuntimeError> {
|
||||
let mut refs = Vec::with_capacity(argc as usize);
|
||||
for _ in 0..argc {
|
||||
match self.pop()? {
|
||||
Value::Ref(r) => refs.push(r),
|
||||
_ => return Err(RuntimeError::TYPE_MISMATCH),
|
||||
}
|
||||
}
|
||||
refs.reverse();
|
||||
let nummer = self.pop_i32()?;
|
||||
|
||||
let Some(zeile) = self.rt.dateien.get(nummer)?.zeile_lesen()? else {
|
||||
return Err(RuntimeError(62)); // Input past end of file
|
||||
};
|
||||
if line_mode {
|
||||
let r = refs.first().ok_or(RuntimeError(51))?;
|
||||
self.write_ref(r, Value::Str(Rc::from(zeile.as_str())))?;
|
||||
return Ok(());
|
||||
}
|
||||
let felder = tb_runtime::fileio::felder_trennen(&zeile);
|
||||
for (i, r) in refs.iter().enumerate() {
|
||||
let text = felder.get(i).map(String::as_str).unwrap_or("");
|
||||
let ziel = self.read_ref(r)?;
|
||||
let wert = input_value(&ziel, text).ok_or(RuntimeError(64))?; // Bad file name
|
||||
self.write_ref(r, wert)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Typisierte Binäroperationen mit Überlaufprüfung.
|
||||
fn bin_i16(&mut self, f: impl Fn(i16, i16) -> Option<i16>) -> Result<Flow, RuntimeError> {
|
||||
let b = self.pop_i16()?;
|
||||
@@ -1473,3 +1732,4 @@ fn strict_number(t: &str) -> Option<f64> {
|
||||
let cleaned = t.replace(['d', 'D'], "E").replace('e', "E");
|
||||
cleaned.parse::<f64>().ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ fn run_with_input(src: &str, input: &[&str]) -> (RunEvent, String) {
|
||||
let mut vm = Vm::new(module);
|
||||
let mut host = CaptureHost::with_input(input);
|
||||
let ev = vm.run(&mut host);
|
||||
(ev, host.output)
|
||||
// Die Tests vergleichen Textausgabe; der Bildschirm-Snapshot ist der
|
||||
// Nachfolger des früheren Byte-Stroms (Entscheidung 2026-09-03, D3).
|
||||
(ev, tb_runtime::snapshot::text(&vm.rt.screen))
|
||||
}
|
||||
|
||||
fn out(src: &str) -> String {
|
||||
@@ -366,7 +368,7 @@ fn breakpoint_haelt_an_und_setzt_fort() {
|
||||
// Fortsetzen (Breakpoint entfernen, sonst hält Zeile 3 erneut).
|
||||
vm.remove_breakpoint(3);
|
||||
assert_eq!(vm.run(&mut host), RunEvent::Ended);
|
||||
assert_eq!(host.output, " 6 \n");
|
||||
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), " 6 \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,7 +401,7 @@ fn stop_liefert_zeile_und_ist_fortsetzbar() {
|
||||
}
|
||||
// IDE-Semantik: CONT = weiterlaufen.
|
||||
assert_eq!(vm.run(&mut host), RunEvent::Ended);
|
||||
assert_eq!(host.output, "a\nb\n");
|
||||
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), "a\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -444,8 +446,9 @@ fn print_zonen_und_tab() {
|
||||
|
||||
#[test]
|
||||
fn unsupported_feature_fehler_73() {
|
||||
// Dokumentiert, aber Phase 3: Datei-E/A → Laufzeitfehler 73.
|
||||
let (ev, _) = run("OPEN \"x.txt\" FOR INPUT AS #1");
|
||||
// Dokumentiert, aber noch offen: ISAM kommt mit `phase-3-isam`,
|
||||
// die Ereignissteuerung mit Phase 4 → Laufzeitfehler 73.
|
||||
let (ev, _) = run("TYPE T\n f AS INTEGER\nEND TYPE\nOPEN \"db\" FOR ISAM T \"tab\" AS #1");
|
||||
match ev {
|
||||
RunEvent::Error { code, message, .. } => {
|
||||
assert_eq!(code, 73);
|
||||
@@ -455,3 +458,205 @@ fn unsupported_feature_fehler_73() {
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Zustellpunkte des Hosts (Spec konsolen-basisbibliothek) ---------------
|
||||
|
||||
/// Vor blockierender Eingabe wird der Bildschirm angezeigt — sonst stünde
|
||||
/// die Eingabeaufforderung unsichtbar da.
|
||||
#[test]
|
||||
fn ausgabe_erscheint_vor_der_eingabe() {
|
||||
use tb_runtime::host::{CaptureHost, Host};
|
||||
let module =
|
||||
tb_vm::compile_source("TEST", "PRINT \"Frage:\"\nINPUT a%\nPRINT a%\n").unwrap();
|
||||
let mut vm = Vm::new(module);
|
||||
let mut host = CaptureHost::with_input(&["7"]);
|
||||
assert_eq!(vm.run(&mut host), RunEvent::Ended);
|
||||
|
||||
// Der Host hat den Bildschirm mehrfach gesehen, und schon beim ersten
|
||||
// Mal stand der Text da.
|
||||
assert!(host.presents > 0, "Host wurde nie zur Anzeige aufgefordert");
|
||||
let text = tb_runtime::snapshot::text(&vm.rt.screen);
|
||||
assert!(text.starts_with("Frage:\n"), "{text:?}");
|
||||
assert!(text.contains(" 7 "), "{text:?}");
|
||||
}
|
||||
|
||||
/// Eingegangene Zeichen werden auf dem Bildschirm wiederholt (Echo), und
|
||||
/// Rückschritt löscht das zuletzt getippte Zeichen.
|
||||
#[test]
|
||||
fn eingabe_wird_geechot_und_rueckschritt_loescht() {
|
||||
use tb_runtime::host::{taste, CaptureHost, Ereignis};
|
||||
let module = tb_vm::compile_source("TEST", "INPUT a$\nPRINT a$\n").unwrap();
|
||||
let mut vm = Vm::new(module);
|
||||
let mut host = CaptureHost::default();
|
||||
for c in ['a', 'b', 'x'] {
|
||||
host.ereignis(Ereignis::Taste(c.to_string()));
|
||||
}
|
||||
host.ereignis(Ereignis::Taste(taste::BACKSPACE.to_string()));
|
||||
host.ereignis(Ereignis::Taste(taste::ENTER.to_string()));
|
||||
assert_eq!(vm.run(&mut host), RunEvent::Ended);
|
||||
let text = tb_runtime::snapshot::text(&vm.rt.screen);
|
||||
// Erste Zeile: das Echo ohne das gelöschte Zeichen.
|
||||
assert_eq!(text.lines().next(), Some("? ab"));
|
||||
assert!(text.contains("ab\n"), "{text:?}");
|
||||
}
|
||||
|
||||
/// Größenänderung während des Laufs wirkt auf den Bildschirm.
|
||||
#[test]
|
||||
fn groessenaenderung_wirkt_zur_laufzeit() {
|
||||
use tb_runtime::host::{CaptureHost, Ereignis};
|
||||
let module = tb_vm::compile_source("TEST", "PRINT \"a\"\nFOR i% = 1 TO 50\nNEXT i%\n").unwrap();
|
||||
let mut vm = Vm::new(module);
|
||||
let mut host = CaptureHost::default();
|
||||
host.ereignis(Ereignis::Groesse { cols: 120, rows: 40 });
|
||||
assert_eq!(vm.run(&mut host), RunEvent::Ended);
|
||||
assert_eq!((vm.rt.screen.cols(), vm.rt.screen.rows()), (120, 40));
|
||||
// Inhalt bleibt oben links erhalten.
|
||||
assert_eq!(vm.rt.screen.cell(1, 1).ch, 'a');
|
||||
}
|
||||
|
||||
// ---- Bildschirm und Tastatur (Spec textbildschirm) -------------------------
|
||||
|
||||
#[test]
|
||||
fn locate_ausserhalb_loest_fehler_5_aus() {
|
||||
assert_eq!(err_code("LOCATE 26, 1"), 5);
|
||||
assert_eq!(err_code("LOCATE 1, 81"), 5);
|
||||
assert_eq!(err_code("LOCATE 0, 1"), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_funktion_ausserhalb_loest_fehler_5_aus() {
|
||||
assert_eq!(err_code("x% = SCREEN(30, 1)"), 5);
|
||||
assert_eq!(err_code("x% = SCREEN(1, 200)"), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_prueft_wertebereich() {
|
||||
assert_eq!(err_code("COLOR 32, 0"), 5);
|
||||
assert_eq!(err_code("COLOR 7, 8"), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_anweisung_nur_textmodus() {
|
||||
// Literaler Grafikmodus schon zur Compile-Zeit.
|
||||
let d = tb_vm::compile_source("T", "SCREEN 1").unwrap_err();
|
||||
assert!(
|
||||
d.iter().any(|x| x.message.contains("Feature unavailable")),
|
||||
"{d:?}"
|
||||
);
|
||||
// Textmodus ist folgenlos zulässig.
|
||||
assert_eq!(out("SCREEN 0\nPRINT \"ok\""), "ok\n");
|
||||
// Berechneter Modus erst zur Laufzeit.
|
||||
assert_eq!(err_code("m% = 2\nSCREEN m%"), 73);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inkey_ohne_taste_ist_leer() {
|
||||
assert_eq!(out("a$ = INKEY$\nPRINT \"[\"; a$; \"]\""), "[]\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inkey_liefert_zeichen_und_sondertaste() {
|
||||
use tb_runtime::host::{taste, CaptureHost, Ereignis};
|
||||
let module = tb_vm::compile_source(
|
||||
"T",
|
||||
"a$ = INKEY$\nb$ = INKEY$\nPRINT a$; LEN(b$); ASC(b$)\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut vm = Vm::new(module);
|
||||
let mut host = CaptureHost::default();
|
||||
host.ereignis(Ereignis::Taste("q".into()));
|
||||
host.ereignis(Ereignis::Taste(taste::sonder(59))); // F1
|
||||
assert_eq!(vm.run(&mut host), RunEvent::Ended);
|
||||
let t = tb_runtime::snapshot::text(&vm.rt.screen);
|
||||
// "q", dann Länge 2 und führendes Nullzeichen.
|
||||
assert_eq!(t, "q 2 0 \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_s_liest_genau_n_zeichen_ohne_echo() {
|
||||
use tb_runtime::host::{CaptureHost, Ereignis};
|
||||
let module = tb_vm::compile_source("T", "a$ = INPUT$(3)\nPRINT a$\n").unwrap();
|
||||
let mut vm = Vm::new(module);
|
||||
let mut host = CaptureHost::default();
|
||||
for c in ['x', 'y', 'z', 'w'] {
|
||||
host.ereignis(Ereignis::Taste(c.to_string()));
|
||||
}
|
||||
assert_eq!(vm.run(&mut host), RunEvent::Ended);
|
||||
// Nur das PRINT erscheint — INPUT$ echot nicht.
|
||||
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), "xyz\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cls_setzt_cursor_zurueck() {
|
||||
assert_eq!(out("PRINT \"weg\"\nCLS\nPRINT \"neu\""), "neu\n");
|
||||
assert_eq!(out("PRINT \"weg\"\nCLS\nPRINT CSRLIN; POS(0)"), " 1 4 \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_hebt_die_groesse_an() {
|
||||
assert_eq!(
|
||||
out("WIDTH 120, 40\nPRINT SCREEN(40, 120)"),
|
||||
" 32 \n"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ON ERROR-Scoping (Spec sprach-frontend) -------------------------------
|
||||
|
||||
/// Ein modulweiter Handler ist auch aus einer Prozedur heraus ansprechbar;
|
||||
/// `RESUME NEXT` setzt dann hinter dem Prozeduraufruf fort.
|
||||
#[test]
|
||||
fn modulweites_on_error_aus_prozedur() {
|
||||
let src = "DECLARE SUB T ()\n\
|
||||
ON ERROR GOTO Handler\n\
|
||||
T\n\
|
||||
PRINT \"zurueck\"\n\
|
||||
END\n\
|
||||
Handler:\n\
|
||||
PRINT \"Handler\"; ERR\n\
|
||||
RESUME NEXT\n\
|
||||
SUB T\n\
|
||||
ON ERROR GOTO Handler\n\
|
||||
x = 1 / 0\n\
|
||||
END SUB\n";
|
||||
assert_eq!(out(src), "Handler 11 \nzurueck\n");
|
||||
}
|
||||
|
||||
/// `ON LOCAL ERROR` verlangt weiterhin ein Label im eigenen Rumpf.
|
||||
#[test]
|
||||
fn on_local_error_bleibt_prozedurlokal() {
|
||||
let src = "DECLARE SUB T ()\n\
|
||||
ON ERROR GOTO Handler\n\
|
||||
T\n\
|
||||
END\n\
|
||||
Handler:\n\
|
||||
RESUME NEXT\n\
|
||||
SUB T\n\
|
||||
ON LOCAL ERROR GOTO Handler\n\
|
||||
END SUB\n";
|
||||
let d = tb_vm::compile_source("T", src).unwrap_err();
|
||||
assert!(
|
||||
d.iter().any(|x| x.message.contains("Label not defined")),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Ein prozedurlokaler Handler fängt vor dem modulweiten.
|
||||
#[test]
|
||||
fn lokaler_handler_hat_vorrang() {
|
||||
let src = "DECLARE SUB T ()\n\
|
||||
ON ERROR GOTO Modul\n\
|
||||
T\n\
|
||||
END\n\
|
||||
Modul:\n\
|
||||
PRINT \"Modul\"\n\
|
||||
RESUME NEXT\n\
|
||||
SUB T\n\
|
||||
ON LOCAL ERROR GOTO Lokal\n\
|
||||
x = 1 / 0\n\
|
||||
EXIT SUB\n\
|
||||
Lokal:\n\
|
||||
PRINT \"Lokal\"\n\
|
||||
RESUME NEXT\n\
|
||||
END SUB\n";
|
||||
assert_eq!(out(src), "Lokal\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user