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:
2026-09-04 06:54:19 +02:00
parent a6f005d023
commit 52ccbb5848
94 changed files with 10421 additions and 792 deletions

View File

@@ -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()
}
}

View File

@@ -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());
}
}
}

View File

@@ -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),

View File

@@ -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;
}
}

View File

@@ -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::*;

View File

@@ -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()));
}

View 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:?}"
);
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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 112, 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);
}
}

View File

@@ -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);
}
}

View 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}");
}
}

View File

@@ -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);
}
}

View File

@@ -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 …

View 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 015 (klassische Palette).
pub fg: u8,
/// Hintergrund 07.
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 031, hg 07). Blinkende Vordergrundfarben
/// (1631) 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 35
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');
}
}

View 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()), "");
}
}

View 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");
}
}

View File

@@ -7,6 +7,7 @@ license.workspace = true
authors.workspace = true
[dependencies]
tb-runtime.workspace = true
ratatui.workspace = true
crossterm.workspace = true
thiserror.workspace = true

View File

@@ -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
View 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));
}
}

View File

@@ -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;

View File

@@ -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 015 (klassische Palette).
pub fg: u8,
/// Hintergrund 07.
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 031, hg 07). Blinkende Vordergrundfarben
/// (1631) 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 35
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

View File

@@ -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 ------------------------------------------------------------

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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");
}