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

2
Cargo.lock generated
View File

@@ -489,6 +489,7 @@ version = "0.1.0"
dependencies = [
"log",
"thiserror",
"unicode-width 0.2.0",
]
[[package]]
@@ -499,6 +500,7 @@ dependencies = [
"crossterm",
"log",
"ratatui",
"tb-runtime",
"thiserror",
]

View File

@@ -21,6 +21,9 @@ authors = ["Georg Bauer"]
ratatui = "0.29"
crossterm = "0.28"
# Text
unicode-width = "0.2"
# Allgemein
anyhow = "1"
thiserror = "2"

161
PLAN.md
View File

@@ -245,54 +245,115 @@ Vorgezogen aus Phase 3 (Meilenstein-Voraussetzung): `Host`-Trait
(Konsole/Capture), Builtin-Dispatch-Tabelle, PRINT-Formatierung mit
Druckzonen sowie die Korpus-Stringfunktionen (`tb-runtime`).
## Phase 3 — Laufzeitbibliothek (`tb-runtime`) und Bildschirm (`tb-ui::screen`)
## Phase 3 — Laufzeitbibliothek (`tb-runtime`) und Bildschirm
**Status: Kern abgeschlossen (2026-09-04), ISAM offen.** Umgesetzt im
OpenSpec-Change `phase-3-runtime-bildschirm`; der Rest steckt im Change
`phase-3-isam`, mit dem Phase 3 endet.
Ziel ist die **vollständige** Standardbibliothek des Vorbilds (siehe
Leitplanke Vollständigkeit); die Aufzählungen unten sind Beispiele.
Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste.
- [ ] Vollständigkeits-Inventar erstellen: maschinenlesbare Liste aller
Anweisungen/Funktionen des Vorbilds aus der Original-Hilfe
(dos-help.soulsphere.org, Topic-Listen) mit Status
implementiert/offen/Non-Feature → `docs/inventar.md`; ab dann
Abdeckung je Phase fortschreiben
- [ ] Strings: `LEFT$`, `MID$` (auch als Anweisung), `INSTR`, `STR$`/`VAL`,
`SPACE$`, `STRING$`, `LTRIM$`/`RTRIM$`, `UCASE$`/`LCASE$`
- [ ] Zahlenformatierung: `PRINT`-Zonen, `PRINT USING` (vollständig;
dabei offene Detailfrage klären: `%`-Präfix bei Feldüberlauf),
Banker's Rounding, `CINT`/`CLNG`/`CSNG`/`CDBL`/`CCUR`
- [ ] Mathematik: `RND`/`RANDOMIZE` (kompatibler PRNG!), trigonometrische
Funktionen, Integer-Überlaufverhalten (Fehler 6)
- [ ] Datei-E/A: `OPEN` (sequenziell/random/binär), `INPUT#`/`LINE INPUT#`,
`PRINT#`/`WRITE#`, `GET`/`PUT` mit Record-Typen, `EOF`/`LOF`/`SEEK`,
Pfadsemantik plattformübergreifend. Record-Layout: feste Strings als
**UTF-32** (Entscheidung 2026-09-02 — 4 Bytes/Zeichen, feste
Record-Länge; bewusst inkompatibel zu Vorbild-Dateien)
- [ ] ISAM-Dateiunterstützung (Entscheidung 2026-09-02: wird implementiert,
nicht Non-Feature): Anweisungen/Funktionen der Professional Edition
(`OPEN … FOR ISAM`, Tabellen/Indizes, `SEEKGT`-Familie …) — Umfang
aus der Original-Hilfe inventarisieren, dann implementieren
- [ ] Breite Unicode-Zeichen (Emoji, CJK): belegen **zwei Zellen**
(Entscheidung 2026-09-02) — Zellenmodell und `LOCATE`/`POS`-Semantik
entsprechend umsetzen (unicode-width), Verhalten dokumentieren
- [ ] Bildschirm: `PRINT`, `LOCATE`, `COLOR`, `CLS`, `INPUT`, `INKEY$`,
`CSRLIN`/`POS`, `WIDTH`, `VIEW PRINT` auf dem Zellenpuffer
- [ ] Offene Detailfrage klären: Umfang der `KEY n`-Funktionstasten-Makros
(`KEY LIST`/`ON`/`OFF`) und in docs/sprachreferenz.md festhalten
- [ ] Aus Phase 2 verlagert — `ON ERROR GOTO`-Scoping des Vorbilds im
Frontend abbilden: Handler-Label auf Modulebene ist auch aus
Prozeduren heraus ansprechbar (derzeit wird nicht-lokales
`ON ERROR` in einer Prozedur wie `ON LOCAL ERROR` behandelt,
siehe sema.rs, lower_stmt/OnError)
- [ ] Aus Phase 2 verlagert — DATA-Rohtext erhalten: unquotierte
DATA-Elemente verlieren derzeit die Groß-/Kleinschreibung, weil
der Lexer Bezeichner normalisiert; Rohtext-Erfassung im Lexer,
Korpustest ergänzen
- [ ] Aus Phase 2 verlagert — `TODO verify`-Zellen der
Konvertierungsmatrix (docs/tbvm-design.md) gegen die
Original-Hilfe auflösen: CURRENCY-Promotion mit Gleitkomma,
`^`-Ergebnistyp, Exponentialform-Schwellen der Textdarstellung,
CURRENCY-Ausgabeformat; zugehörige Korpustests nachschärfen
- [ ] Meilenstein: klassische Konsolenprogramme laufen unverändert
- [x] Vollständigkeits-Inventar → [docs/inventar.md](docs/inventar.md):
285 Einträge aus den Themenlisten von `bas7advr.hlp` und
`qb45advr.hlp` mit Status und Fundstelle. Ein Test
(`crates/tb-frontend/tests/inventar.rs`) hält die Tabelle in beide
Richtungen gegen den Code — der Abdeckungsstand kann nicht mehr
veralten. **Stand: 195 implementiert · 37 offen · 53 Non-Feature.**
Die 37 offenen sind 22 ISAM (`phase-3-isam`), 13 Forms und
Ereignissteuerung (Phase 4), `RUN` und `$INCLUDE` (Phase 5).
- [x] **Bildschirmpuffer in die Laufzeit** (Entscheidung 2026-09-03):
`Cell`/`TextScreen` liegen ratatui-frei in `tb-runtime::screen`;
`tb-ui` behält Farbabbildung, Widget und den Terminal-Host. Damit
erreicht die Bibliothek den Puffer, und Bildschirmverhalten ist ohne
Terminal prüfbar.
- [x] **`Host` neu geschnitten** (BREAKING): statt Zeichenstrom nun
Anzeige des Zellenpuffers plus Ereignisse (Taste, Größenänderung,
Abbruch). `write`/`read_line` konnten `LOCATE`, `COLOR`, `CSRLIN`
und das Zurücklesen per `SCREEN(z, s)` nicht transportieren.
- [x] **Korpusvergleich auf Bildschirm-Snapshot** (BREAKING): getrimmt auf
die belegte Fläche, Attributebene nur bei gesetzten Farben. Die acht
bestehenden Sollausgaben blieben dabei unverändert.
- [x] **80×25 ist nirgends eine feste Grenze** (Vorgabe 2026-09-03):
alle Bildschirmpfade rechnen mit der aktuellen Größe;
Größenänderungen während der Ausführung werden zugestellt (Inhalt
oben links erhalten, Cursor und `VIEW PRINT` geklemmt). Der Korpus
weist es nach: dasselbe Programm bei 80×25 und 120×40 mit je eigener
Sollausgabe, plus ein Programm mit Größenwechsel mitten im Lauf.
- [x] Breite Unicode-Zeichen belegen **zwei Zellen** (Emoji, CJK):
Cursorfortschritt, `POS`, Umbruch am rechten Rand und das Leeren der
Gegenhälfte beim Überschreiben.
- [x] Bildschirm: `CLS`, `COLOR`, `LOCATE`, `WIDTH`, `VIEW PRINT`,
`SCREEN` (Anweisung und Funktion), `CSRLIN`, `POS`, `TAB`, `SPC`
- [x] Tastatur: `INKEY$` (nicht blockierend, Sondertasten als
`CHR$(0)+code`), `INPUT$`, Funktionstasten-Makros `KEY n, text$` /
`KEY LIST` / `KEY ON|OFF` (Umfang aus der Original-Hilfe geklärt)
- [x] Strings vollständig; Randfälle (`VAL`-Präfix, `STR$`-Vorzeichenspalte,
Fehler 5 bei Startposition < 1) im Korpus verankert
- [x] Zahlenformatierung: Druckzonen, `PRINT USING`/`LPRINT USING`/
`FORMAT$` vollständig inklusive `%`-Präfix bei Feldüberlauf und
`SetFormatCC`; Banker's Rounding; `CINT`/`CLNG`/`CSNG`/`CDBL`/`CCUR`
- [x] Mathematik inklusive **kompatiblem PRNG**: gleiche Saat ergibt
dieselbe Folge, `RND(0)` wiederholt, `RANDOMIZE` ohne Argument
erfragt den Startwert; Algorithmus dokumentiert
- [x] **Finanzmathematik** (Befund des Inventars — im ursprünglichen
Umfang übersehen): `FV#`, `PV#`, `Pmt#`, `IPmt#`, `PPmt#`, `NPer#`,
`Rate#`, `NPV#`, `IRR#`, `MIRR#`, `SLN#`, `SYD#`, `DDB#`
- [x] Datum und Zeit: Serienwerte, `DATESERIAL`/`TIMESERIAL`,
`DATEVALUE`/`TIMEVALUE`, `DAY``SECOND`, `NOW`, `DATE$`/`TIME$` als
Funktion und Anweisung
- [x] Datei-E/A: `OPEN` in beiden Syntaxen (sequenziell, `RANDOM`,
`BINARY`), `PRINT #`/`WRITE #`/`INPUT #`/`LINE INPUT #`,
`GET`/`PUT` mit Recordpuffer **und** mit UDT-Variablen,
`FIELD`/`LSET`/`RSET`, `EOF`/`LOF`/`LOC`/`SEEK`/`FREEFILE`/
`FILEATTR`, `LOCK`/`UNLOCK`, `LPRINT`/`LPOS`, die
Record-Konvertierungsfunktionen (`MK…$`/`CV…`) und die
Dateisystem-Anweisungen. Feste Strings als **UTF-32**.
- [x] System: `ENVIRON`/`ENVIRON$`, `COMMAND$`, `FRE`, `CLEAR`,
`TRON`/`TROFF`, `STACK`, `ERDEV`/`ERDEV$`, `ERR`-Anweisung
- [x] Aus Phase 2 verlagert — `ON ERROR GOTO`-Scoping: ein modulweiter
Handler ist jetzt aus Prozeduren heraus ansprechbar (Sprungziel im
Modulrumpf, prozedurübergreifender Fixup im Codegenerator); nur
`ON LOCAL ERROR` bleibt prozedurlokal
- [x] Aus Phase 2 verlagert — DATA-Rohtext: der Lexer liefert den
Anweisungstext unverändert, unquotierte Elemente behalten Groß-/
Kleinschreibung und inneren Leerraum
- [x] Aus Phase 2 verlagert — die vier `TODO verify`-Zellen der
Konvertierungsmatrix sind aufgelöst. Die Original-Hilfe schweigt zu
allen vieren; sie sind daher als datierte **Projektentscheidung**
begründet dokumentiert und in `konvertierung.bas` verankert.
- [x] **Meilenstein: klassische Konsolenprogramme laufen unverändert**
alle 22 Korpusprogramme laufen ohne Laufzeitfehler 73; der Fehler
tritt nur noch bei bewusst offenen Elementen auf (ISAM, Forms,
Ereignissteuerung).
- [ ] **ISAM** (Change `phase-3-isam`): `OPEN … FOR ISAM`, Indizes,
Cursor, Satzoperationen, Transaktionen. Speicherschicht `redb`
(Entscheidung 2026-09-03). Damit endet Phase 3.
**Befunde dieser Phase, die den Plan korrigiert haben**
- Das Inventar deckte am ersten Tag zwölf Non-Features auf, die der
Compiler entgegen der Phase-1-Spec **nicht** abwies (`CALLS`, `SSEG`,
`POINT`, `VIEW`, `COM`, `PEN`, `STRIG`, `STICK`, die
`String*`-Routinen sowie `LINE` und `OPEN "COMn:"` an ihrer Syntax).
Alle geschlossen.
- Drei ganze Gruppen fehlten im ursprünglichen Umfang: Finanzmathematik,
Record-Konvertierung und ein Rest an Dateisystemfunktionen.
- `bas7advr.hlp` allein ist **keine** vollständige Quelle: `SGN`, `SQR`,
`SPACE$`, `STRING$`, `CSRLIN`, `BEEP` und `DATE$` als Funktion fehlen
dort. Das Inventar bildet deshalb die Vereinigung mit `qb45advr.hlp`.
**Offene Entscheidungen, die in spätere Phasen zeigen**
- Die Uhr lief zunächst in **UTC**, weil Ortszeit eine
Zeitzonenbibliothek gebraucht hätte. Entscheidung 2026-09-04: die
Bibliothek kommt, die Abweichung wird zurückgezogen → Change
`phase-3-ortszeit`.
- `LPRINT` schreibt am Programmende nach `LPT1.TXT` im Arbeitsverzeichnis;
einen plattformübergreifenden Druckerkanal gibt es nicht.
- Neue Abhängigkeit `unicode-width` (Doppelzellen). Für ISAM kommt `redb`
hinzu.
## Phase 4 — Forms-Engine (`tb-ui::forms`)
- [ ] Eingangsaufgabe (aus Phase 0 übernommen): Ereignisschleifen-
@@ -370,10 +431,14 @@ bleibt gültig. Noch nichts davon ist beschlossen; Sammlung wächst:
mit Record-Buffer-Datentypen, auf denen `SELECT`/`UPDATE`/`INSERT`/
`DELETE` arbeiten (natürliche Weiterentwicklung der ISAM-Idee auf
SQLite als Speicher)
- **Runtime-Bibliotheken als BASIC-Bibliotheken** (2026-09-02): in der
Runtime integrierte Rust-Bibliotheken (z. B. crossterm für direkte
Terminalsteuerung) als eigene, importierbare Bibliotheken für
BASIC-Programme bereitstellen
- **Runtime-Bibliotheken als BASIC-Bibliotheken** (2026-09-02, erweitert
2026-09-04): in der Runtime integrierte Rust-Bibliotheken als eigene,
importierbare Bibliotheken für BASIC-Programme bereitstellen —
- **crossterm** für direkte Terminalsteuerung
- **die Zeitzonenbibliothek** (ab dem Change `phase-3-ortszeit` in der
Laufzeit): Zonennamen, Versatz zu einem Zeitpunkt, Umrechnung
zwischen Zonen. Der Kernstandard braucht davon nur den Versatz für
die Ortszeit; alles Weitere wäre eine Erweiterung.
- Verteilung: `tbc build --exe` als Single-File-Tool-Baukasten
---

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

304
docs/bibliothek.md Normal file
View File

@@ -0,0 +1,304 @@
# Bibliotheksreferenz
Die Laufzeitbibliothek **so wie implementiert**. Der Umfang wird von
[docs/inventar.md](inventar.md) bestimmt; dort steht auch, was noch offen
ist. Abweichungen vom Vorbild sind in
[docs/sprachreferenz.md](sprachreferenz.md) unter „Abweichungen"
gesammelt.
Ein automatischer Test hält das Inventar gegen den Code; diese Referenz
beschreibt das Verhalten dahinter.
## Bildschirm
| Element | Art |
|---|---|
| `CLS` | Anweisung |
| `COLOR` | Anweisung |
| `CSRLIN` | Funktion |
| `INPUT` | Anweisung |
| `LINE INPUT` | Anweisung |
| `LOCATE` | Anweisung |
| `POS` | Funktion |
| `PRINT` | Anweisung |
| `SCREEN` | Anweisung |
| `SCREEN` | Funktion |
| `SPC` | Funktion |
| `TAB` | Funktion |
| `VIEW PRINT` | Anweisung |
| `WIDTH` | Anweisung |
Alle Anweisungen wirken auf den Zellenpuffer der aktuellen
Bildschirmgröße — 80×25 ist nirgends eine feste Grenze. Ausgelassene
Argumente von `LOCATE` und `COLOR` lassen den bisherigen Wert stehen.
`LOCATE` außerhalb der Grenzen und `SCREEN(zeile, spalte)` außerhalb
lösen Fehler 5 aus. Die Funktionsform `SCREEN` liefert ohne drittes
Argument den Zeichencode, mit drittem das Farbattribut
(Hintergrund·16 + Vordergrund). `WIDTH` hebt den Puffer höchstens an.
`VIEW PRINT` ohne Argumente setzt den Scrollbereich auf den vollen
Bildschirm zurück.
## Datum/Zeit
| Element | Art |
|---|---|
| `DATE$` | Anweisung |
| `DATE$` | Funktion |
| `DATESERIAL` | Funktion |
| `DATEVALUE` | Funktion |
| `DAY` | Funktion |
| `HOUR` | Funktion |
| `MINUTE` | Funktion |
| `MONTH` | Funktion |
| `NOW` | Funktion |
| `SECOND` | Funktion |
| `TIME$` | Anweisung |
| `TIME$` | Funktion |
| `TIMER` | Funktion |
| `TIMESERIAL` | Funktion |
| `TIMEVALUE` | Funktion |
| `WEEKDAY` | Funktion |
| `YEAR` | Funktion |
Serielle Werte zählen Tage ab dem 30.12.1899 (Serie 2 = 1.1.1900);
der Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1 für Sonntag.
Ungültige Datums- oder Zeitangaben lösen Fehler 5 aus.
Die Uhr läuft in **UTC** (dokumentierte Abweichung). `DATE$ = …` und
`TIME$ = …` stellen nicht die Systemuhr, sondern einen
programmeigenen Versatz, den alle Zeitfunktionen mitrechnen.
## Deklaration
| Element | Art |
|---|---|
| `COMMON` | Anweisung |
| `CONST` | Anweisung |
| `DATA` | Anweisung |
| `DEFtype` | Anweisung |
| `DIM` | Anweisung |
| `ERASE` | Anweisung |
| `LBOUND` | Funktion |
| `LET` | Anweisung |
| `OPTION BASE` | Anweisung |
| `READ` | Anweisung |
| `REDIM` | Anweisung |
| `REM` | Anweisung |
| `RESTORE` | Anweisung |
| `SHARED` | Anweisung |
| `STATIC` | Anweisung |
| `SWAP` | Anweisung |
| `TYPE` | Anweisung |
| `UBOUND` | Funktion |
## Ereignisse
| Element | Art |
|---|---|
| `DOEVENTS` | Funktion |
## Fehlerbehandlung
| Element | Art |
|---|---|
| `ERL` | Funktion |
| `ERR` | Funktion |
| `ERROR` | Anweisung |
| `ON ERROR` | Anweisung |
| `RESUME` | Anweisung |
## Finanzen
| Element | Art |
|---|---|
| `DDB#` | Funktion |
| `FV#` | Funktion |
| `IPMT#` | Funktion |
| `IRR#` | Funktion |
| `MIRR#` | Funktion |
| `NPER#` | Funktion |
| `NPV#` | Funktion |
| `PMT#` | Funktion |
| `PPMT#` | Funktion |
| `PV#` | Funktion |
| `RATE#` | Funktion |
| `SLN#` | Funktion |
| `SYD#` | Funktion |
Alle Funktionen rechnen in DOUBLE. Vorzeichenkonvention wie beim
Vorbild: ausgehende Zahlungen sind negativ, eingehende positiv. Das
letzte Argument der Renten-Funktionen ist die Fälligkeit — 0
nachschüssig, 1 vorschüssig.
`Rate#` und `IRR#` nähern sich mit dem Sekantenverfahren.
**Abbruchbedingung:** höchstens 100 Schritte; erreicht wird die Lösung,
wenn der Betrag der Restabweichung unter 1e-9 fällt. Bleibt sie über
1e-6, gilt die Näherung als erfolglos und es tritt Fehler 5 auf —
ein stiller Ersatzwert wird nie geliefert. Eine Zahlungsreihe ohne
Vorzeichenwechsel weist `IRR#` sofort mit Fehler 5 ab.
Weitere Fehler 5: Periode außerhalb der Laufzeit bei
`IPmt#`/`PPmt#`/`SYD#`/`DDB#`, Nutzungsdauer null bei `SLN#`,
Perioden null bei `Pmt#`, Zinssatz ≤ 1.
## Formatierung
| Element | Art |
|---|---|
| `FORMAT$` | Funktion |
| `PRINT USING` | Anweisung |
| `SetFormatCC` | Routine |
`PRINT USING`, `LPRINT USING` und `FORMAT$` teilen dieselbe
Formatzeichenkette. Numerische Platzhalter: `#` Ziffernstelle,
`.` Dezimalpunkt, `,` Tausendertrennung, `+`/`-` Vorzeichenführung
(vorn oder hinten, mit eigener Stelle), `$$` mitlaufendes
Währungszeichen und `**` Sternfüllung (beide bringen zwei Stellen
mit), `^^^^` Exponentialform. String-Platzhalter: `!` erstes Zeichen,
`&` ganzer String, `\ \` feste Länge. `_` macht das Folgezeichen
literal.
Passt ein Wert nicht in sein Feld, wird er vollständig ausgegeben und
mit `%` gekennzeichnet. Die Formatzeichenkette wird wiederholt,
solange Werte übrig sind. Eine Zeichenkette ohne Feld bei vorhandenen
Werten löst Fehler 5 aus. `SetFormatCC` setzt das Währungszeichen
über einen Ländercode; nicht aufgeführte Codes behalten `$`.
## Klang
| Element | Art |
|---|---|
| `BEEP` | Anweisung |
## Mathematik
| Element | Art |
|---|---|
| `ABS` | Funktion |
| `ATN` | Funktion |
| `CCUR` | Funktion |
| `CDBL` | Funktion |
| `CINT` | Funktion |
| `CLNG` | Funktion |
| `COS` | Funktion |
| `CSNG` | Funktion |
| `EXP` | Funktion |
| `FIX` | Funktion |
| `INT` | Funktion |
| `LOG` | Funktion |
| `RANDOMIZE` | Anweisung |
| `RND` | Funktion |
| `SGN` | Funktion |
| `SIN` | Funktion |
| `SQR` | Funktion |
| `TAN` | Funktion |
Rundung ist kaufmännisch-symmetrisch zur geraden Zahl
(`CINT(0.5)` = 0, `CINT(1.5)` = 2, `CINT(2.5)` = 2). `INT` rundet ab,
`FIX` in Richtung null. Überschreitet ein Ergebnis den Zieltyp, tritt
Fehler 6 auf; `SQR`/`LOG` mit unzulässigem Argument lösen Fehler 5
aus, Division durch null Fehler 11.
Der Zufallsgenerator ist der 24-Bit-LCG des Vorbilds
(`zustand = (zustand · &HFD43FD + &HC39EC3) MOD 2^24`). `RND(0)`
wiederholt den letzten Wert, `RND(n)` mit `n < 0` sät neu aus dem
Argument. `RANDOMIZE saat` liefert bei gleicher Saat stets dieselbe
Folge, unabhängig von vorherigen `RND`-Aufrufen; ohne Argument wird
der Startwert abgefragt.
## Metabefehl
| Element | Art |
|---|---|
| `$DYNAMIC` | Metabefehl |
| `$STATIC` | Metabefehl |
## Prozeduren
| Element | Art |
|---|---|
| `CALL` | Anweisung |
| `DECLARE` | Anweisung |
| `DEF FN` | Anweisung |
| `FUNCTION` | Anweisung |
| `SUB` | Anweisung |
## Steuerfluss
| Element | Art |
|---|---|
| `DO...LOOP` | Anweisung |
| `END` | Anweisung |
| `EXIT` | Anweisung |
| `FOR...NEXT` | Anweisung |
| `GOSUB` | Anweisung |
| `GOTO` | Anweisung |
| `IF...THEN...ELSE` | Anweisung |
| `ON...GOSUB` | Anweisung |
| `ON...GOTO` | Anweisung |
| `RETURN` | Anweisung |
| `SELECT CASE` | Anweisung |
| `STOP` | Anweisung |
| `WHILE...WEND` | Anweisung |
## Strings
| Element | Art |
|---|---|
| `ASC` | Funktion |
| `CHR$` | Funktion |
| `HEX$` | Funktion |
| `INSTR` | Funktion |
| `LCASE$` | Funktion |
| `LEFT$` | Funktion |
| `LEN` | Funktion |
| `LTRIM$` | Funktion |
| `MID$` | Anweisung |
| `MID$` | Funktion |
| `OCT$` | Funktion |
| `RIGHT$` | Funktion |
| `RTRIM$` | Funktion |
| `SPACE$` | Funktion |
| `STR$` | Funktion |
| `STRING$` | Funktion |
| `UCASE$` | Funktion |
| `VAL` | Funktion |
Zeichen sind Unicode-Codepoints; `LEN` zählt Zeichen, nicht Bytes
(dokumentierte Abweichung). Randfälle: `VAL` liest das führende
Zahlenpräfix und überspringt Leerraum, versteht `&H`/`&O`;
`STR$` stellt nicht-negativen Zahlen ein Leerzeichen voran;
`LEFT$`/`RIGHT$` mit Länge über der Stringlänge liefern den ganzen
String; `ASC("")` sowie eine Startposition kleiner 1 bei
`MID$`/`INSTR` lösen Fehler 5 aus. `STRING$` nimmt einen Zeichencode
oder einen String (dessen erstes Zeichen zählt).
## System
| Element | Art |
|---|---|
| `COMMAND$` | Funktion |
| `SLEEP` | Anweisung |
| `SYSTEM` | Anweisung |
`ENVIRON$` liest Umgebungsvariablen nach Namen, `COMMAND$` liefert die
Aufrufargumente ohne Programmnamen. Nicht abbildbare Größen liefern
einen dokumentierten Ersatzwert statt still zu variieren.
## Tastatur
| Element | Art |
|---|---|
| `INKEY$` | Funktion |
| `INPUT$` | Funktion |
| `KEY` | Anweisung |
`INKEY$` blockiert nicht: leerer String bei leerem Puffer, ein Zeichen
bei einer Zeichentaste, zwei Zeichen mit führendem Nullzeichen bei
einer Sondertaste. `INPUT$(n)` liest genau `n` Zeichen und blockiert
dabei, ohne sie am Bildschirm zu wiederholen.
`KEY n, text$` belegt ein Funktionstasten-Makro (n = 110 sowie 30/31,
höchstens 15 Zeichen), `KEY LIST` gibt alle zwölf untereinander aus,
`KEY ON`/`KEY OFF` blendet die Softkey-Zeile ein und aus.

312
docs/inventar.md Normal file
View File

@@ -0,0 +1,312 @@
# Vollständigkeits-Inventar
Sollseite der Leitplanke „Vollständigkeit ist das Soll": jedes in der
Original-Hilfe dokumentierte Sprachelement mit Abdeckungsstatus. Diese
Tabelle ist Abnahmekriterium für Phase 6 und wird vom Test
`inventar_stimmt_mit_code_ueberein` gegen den Code geprüft — sie kann
nicht falsch werden, ohne dass die Testsuite rot wird.
**Quellen.** `bas7advr` = Themenliste von `bas7advr.hlp` (BASIC 7
Advisor). `qb45advr` = Gegenprobe über `qb45advr.hlp`, nötig weil die
erste Liste nachweislich unvollständig ist. `sprachreferenz` =
docs/sprachreferenz.md für Elemente des Forms-Teils, die keine der beiden
Themenlisten führt. Erhebung siehe
`openspec/changes/phase-3-runtime-bildschirm/rohliste-original-hilfe.md`.
**Status.** `implementiert` = Frontend senkt ab und die Laufzeit führt
aus. `offen` = dokumentiert, noch nicht verfügbar; die Verwendung wird
namentlich abgewiesen. `Non-Feature` = abgelehnt, gelistet unter
„Abweichungen" der Sprachreferenz.
**Fundstelle.** Bei `implementiert` das Modul, bei `Non-Feature` der
Abschnitt der Sprachreferenz, bei `offen` ein Strich.
**Abdeckung.** implementiert 195 · offen 37 · Non-Feature 53 · gesamt 285
| Name | Art | Gruppe | Status | Fundstelle | Quelle |
|---|---|---|---|---|---|
| `CLS` | Anweisung | Bildschirm | implementiert | tb-runtime::screen | bas7advr |
| `COLOR` | Anweisung | Bildschirm | implementiert | tb-runtime::screen | bas7advr |
| `CSRLIN` | Funktion | Bildschirm | implementiert | tb-runtime::screen | qb45advr |
| `INPUT` | Anweisung | Bildschirm | implementiert | tb-vm::interp | bas7advr |
| `LINE INPUT` | Anweisung | Bildschirm | implementiert | tb-vm::interp | bas7advr |
| `LOCATE` | Anweisung | Bildschirm | implementiert | tb-runtime::screen | bas7advr |
| `POS` | Funktion | Bildschirm | implementiert | tb-runtime::screen | bas7advr |
| `PRINT` | Anweisung | Bildschirm | implementiert | tb-runtime::console | bas7advr |
| `SCREEN` | Anweisung | Bildschirm | implementiert | tb-runtime::builtins | bas7advr |
| `SCREEN` | Funktion | Bildschirm | implementiert | tb-runtime::screen | bas7advr |
| `SPC` | Funktion | Bildschirm | implementiert | tb-runtime::console | bas7advr |
| `TAB` | Funktion | Bildschirm | implementiert | tb-runtime::console | bas7advr |
| `VIEW PRINT` | Anweisung | Bildschirm | implementiert | tb-runtime::screen | bas7advr |
| `WIDTH` | Anweisung | Bildschirm | implementiert | tb-runtime::screen | bas7advr |
| `CHDIR` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CHDRIVE` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CLOSE` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CURDIR$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CVC` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CVD` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CVDMBF` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CVI` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CVL` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CVS` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `CVSMBF` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `DIR$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `EOF` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `FIELD` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `FILEATTR` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `FILES` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `FREEFILE` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `GET` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `INPUT #` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `KILL` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LINE INPUT #` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LOC` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LOCK` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LOF` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LPOS` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LPRINT` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LPRINT USING` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `LSET` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKC$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKD$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKDIR` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKDMBF$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKI$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKL$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKS$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `MKSMBF$` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `NAME` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `OPEN` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `PRINT #` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `PUT` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `RESET` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `RMDIR` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `RSET` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `SEEK` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `SEEK` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `SHELL` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `SHELL` | Funktion | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `UNLOCK` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `WRITE` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `WRITE #` | Anweisung | Datei-E/A | implementiert | tb-runtime::fileio | bas7advr |
| `DATE$` | Anweisung | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `DATE$` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | qb45advr |
| `DATESERIAL` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `DATEVALUE` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `DAY` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `HOUR` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `MINUTE` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `MONTH` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `NOW` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `SECOND` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `TIME$` | Anweisung | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `TIME$` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `TIMER` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `TIMESERIAL` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `TIMEVALUE` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `WEEKDAY` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `YEAR` | Funktion | Datum/Zeit | implementiert | tb-runtime::datetime | bas7advr |
| `COMMON` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `CONST` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `DATA` | Anweisung | Deklaration | implementiert | tb-vm::interp | bas7advr |
| `DEFtype` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `DIM` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `ERASE` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `LBOUND` | Funktion | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `LET` | Anweisung | Deklaration | implementiert | tb-vm::codegen | bas7advr |
| `OPTION BASE` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `READ` | Anweisung | Deklaration | implementiert | tb-vm::interp | bas7advr |
| `REDIM` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `REM` | Anweisung | Deklaration | implementiert | tb-frontend::lexer | bas7advr |
| `RESTORE` | Anweisung | Deklaration | implementiert | tb-vm::interp | bas7advr |
| `SHARED` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `STATIC` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `SWAP` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `TYPE` | Anweisung | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `UBOUND` | Funktion | Deklaration | implementiert | tb-frontend::sema | bas7advr |
| `DOEVENTS` | Funktion | Ereignisse | implementiert | tb-runtime::builtins | sprachreferenz |
| `EVENT` | Anweisung | Ereignisse | offen | - | bas7advr |
| `KEY (Ereignis)` | Anweisung | Ereignisse | offen | - | bas7advr |
| `ON KEY` | Anweisung | Ereignisse | offen | - | bas7advr |
| `ON PEN` | Anweisung | Ereignisse | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `ON PLAY` | Anweisung | Ereignisse | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `ON SIGNAL` | Anweisung | Ereignisse | offen | - | bas7advr |
| `ON STRIG` | Anweisung | Ereignisse | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `ON TIMER` | Anweisung | Ereignisse | offen | - | bas7advr |
| `ON UEVENT` | Anweisung | Ereignisse | offen | - | bas7advr |
| `PEN` | Anweisung | Ereignisse | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PLAY (Ereignis)` | Anweisung | Ereignisse | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `SetUEvent` | Routine | Ereignisse | offen | - | bas7advr |
| `SIGNAL` | Anweisung | Ereignisse | offen | - | bas7advr |
| `STRIG` | Anweisung | Ereignisse | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `TIMER` | Anweisung | Ereignisse | offen | - | bas7advr |
| `UEVENT` | Anweisung | Ereignisse | offen | - | bas7advr |
| `ERL` | Funktion | Fehlerbehandlung | implementiert | tb-vm::interp | bas7advr |
| `ERR` | Anweisung | Fehlerbehandlung | implementiert | tb-vm::interp | bas7advr |
| `ERR` | Funktion | Fehlerbehandlung | implementiert | tb-vm::interp | bas7advr |
| `ERROR` | Anweisung | Fehlerbehandlung | implementiert | tb-vm::interp | bas7advr |
| `ON ERROR` | Anweisung | Fehlerbehandlung | implementiert | tb-vm::interp | bas7advr |
| `RESUME` | Anweisung | Fehlerbehandlung | implementiert | tb-vm::interp | bas7advr |
| `DDB#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `FV#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `IPMT#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `IRR#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `MIRR#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `NPER#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `NPV#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `PMT#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `PPMT#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `PV#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `RATE#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `SLN#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `SYD#` | Funktion | Finanzen | implementiert | tb-runtime::finanz | bas7advr |
| `FORMAT$` | Funktion | Formatierung | implementiert | tb-runtime::using | bas7advr |
| `PRINT USING` | Anweisung | Formatierung | implementiert | tb-runtime::using | bas7advr |
| `SetFormatCC` | Routine | Formatierung | implementiert | tb-runtime::using | bas7advr |
| `INPUTBOX$` | Funktion | Forms | offen | - | sprachreferenz |
| `MSGBOX` | Anweisung | Forms | offen | - | sprachreferenz |
| `MSGBOX` | Funktion | Forms | offen | - | sprachreferenz |
| `CIRCLE` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `DRAW` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `GET (Grafik)` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `LINE` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PAINT` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PALETTE` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PCOPY` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PMAP` | Funktion | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `POINT` | Funktion | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PRESET` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PSET` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PUT (Grafik)` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `VIEW` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `WINDOW` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `ABSOLUTE` | Routine | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `BLOAD` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `BSAVE` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `DEF SEG` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `INP` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `INTERRUPT` | Routine | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `INTERRUPTX` | Routine | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `IOCTL` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `IOCTL$` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `OUT` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PEEK` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `PEN` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `POKE` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `STICK` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `STRIG` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `VARPTR` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `VARPTR$` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `VARSEG` | Funktion | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `WAIT` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `BEGINTRANS` | Anweisung | ISAM | offen | - | bas7advr |
| `BOF` | Funktion | ISAM | offen | - | bas7advr |
| `COMMITTRANS` | Anweisung | ISAM | offen | - | bas7advr |
| `CREATEINDEX` | Anweisung | ISAM | offen | - | bas7advr |
| `DELETE` | Anweisung | ISAM | offen | - | bas7advr |
| `DELETEINDEX` | Anweisung | ISAM | offen | - | bas7advr |
| `DELETETABLE` | Anweisung | ISAM | offen | - | bas7advr |
| `GETINDEX$` | Funktion | ISAM | offen | - | bas7advr |
| `INSERT` | Anweisung | ISAM | offen | - | bas7advr |
| `MOVEFIRST` | Anweisung | ISAM | offen | - | bas7advr |
| `MOVELAST` | Anweisung | ISAM | offen | - | bas7advr |
| `MOVENEXT` | Anweisung | ISAM | offen | - | bas7advr |
| `MOVEPREVIOUS` | Anweisung | ISAM | offen | - | bas7advr |
| `RETRIEVE` | Anweisung | ISAM | offen | - | bas7advr |
| `ROLLBACK` | Anweisung | ISAM | offen | - | bas7advr |
| `SAVEPOINT` | Funktion | ISAM | offen | - | bas7advr |
| `SEEKEQ` | Anweisung | ISAM | offen | - | bas7advr |
| `SEEKGE` | Anweisung | ISAM | offen | - | bas7advr |
| `SEEKGT` | Anweisung | ISAM | offen | - | bas7advr |
| `SETINDEX` | Anweisung | ISAM | offen | - | bas7advr |
| `SETMEM` | Funktion | ISAM | offen | - | bas7advr |
| `UPDATE` | Anweisung | ISAM | offen | - | bas7advr |
| `CALLS` | Anweisung | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `SADD` | Funktion | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `SSEG` | Funktion | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `SSEGADD` | Funktion | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `StringAddress` | Routine | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `StringAssign` | Routine | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `StringLength` | Routine | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `StringRelease` | Routine | Interlanguage | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `BEEP` | Anweisung | Klang | implementiert | tb-runtime::builtins | qb45advr |
| `PLAY` | Anweisung | Klang | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `SOUND` | Anweisung | Klang | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `COM` | Anweisung | Kommunikation | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `ON COM` | Anweisung | Kommunikation | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `OPEN COM` | Anweisung | Kommunikation | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `ABS` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `ATN` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `CCUR` | Funktion | Mathematik | implementiert | tb-frontend::sema | bas7advr |
| `CDBL` | Funktion | Mathematik | implementiert | tb-frontend::sema | bas7advr |
| `CINT` | Funktion | Mathematik | implementiert | tb-frontend::sema | bas7advr |
| `CLNG` | Funktion | Mathematik | implementiert | tb-frontend::sema | bas7advr |
| `COS` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `CSNG` | Funktion | Mathematik | implementiert | tb-frontend::sema | bas7advr |
| `EXP` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `FIX` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `INT` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `LOG` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `RANDOMIZE` | Anweisung | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `RND` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `SGN` | Funktion | Mathematik | implementiert | tb-runtime::math | qb45advr |
| `SIN` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `SQR` | Funktion | Mathematik | implementiert | tb-runtime::math | qb45advr |
| `TAN` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr |
| `$DYNAMIC` | Metabefehl | Metabefehl | implementiert | tb-frontend::lexer | bas7advr |
| `$INCLUDE` | Metabefehl | Metabefehl | offen | - | bas7advr |
| `$STATIC` | Metabefehl | Metabefehl | implementiert | tb-frontend::lexer | bas7advr |
| `CALL` | Anweisung | Prozeduren | implementiert | tb-frontend::sema | bas7advr |
| `DECLARE` | Anweisung | Prozeduren | implementiert | tb-frontend::sema | bas7advr |
| `DEF FN` | Anweisung | Prozeduren | implementiert | tb-vm::interp | bas7advr |
| `FUNCTION` | Anweisung | Prozeduren | implementiert | tb-frontend::sema | bas7advr |
| `SUB` | Anweisung | Prozeduren | implementiert | tb-frontend::sema | bas7advr |
| `DO...LOOP` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `END` | Anweisung | Steuerfluss | implementiert | tb-vm::interp | bas7advr |
| `EXIT` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `FOR...NEXT` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `GOSUB` | Anweisung | Steuerfluss | implementiert | tb-vm::interp | bas7advr |
| `GOTO` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `IF...THEN...ELSE` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `ON...GOSUB` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `ON...GOTO` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `RETURN` | Anweisung | Steuerfluss | implementiert | tb-vm::interp | bas7advr |
| `SELECT CASE` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `STOP` | Anweisung | Steuerfluss | implementiert | tb-vm::interp | bas7advr |
| `WHILE...WEND` | Anweisung | Steuerfluss | implementiert | tb-vm::codegen | bas7advr |
| `ASC` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `CHR$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `HEX$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `INSTR` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `LCASE$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `LEFT$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `LEN` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `LTRIM$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `MID$` | Anweisung | Strings | implementiert | tb-runtime::strings | bas7advr |
| `MID$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `OCT$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `RIGHT$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `RTRIM$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `SPACE$` | Funktion | Strings | implementiert | tb-runtime::strings | qb45advr |
| `STR$` | Funktion | Strings | implementiert | tb-runtime::format | bas7advr |
| `STRING$` | Funktion | Strings | implementiert | tb-runtime::strings | qb45advr |
| `UCASE$` | Funktion | Strings | implementiert | tb-runtime::strings | bas7advr |
| `VAL` | Funktion | Strings | implementiert | tb-runtime::format | bas7advr |
| `CHAIN` | Anweisung | System | Non-Feature | sprachreferenz.md#abweichungen | bas7advr |
| `CLEAR` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr |
| `COMMAND$` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr |
| `ENVIRON` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr |
| `ENVIRON$` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr |
| `ERDEV` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr |
| `ERDEV$` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr |
| `FRE` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr |
| `RUN` | Anweisung | System | offen | - | bas7advr |
| `SLEEP` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr |
| `STACK` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr |
| `STACK` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr |
| `SYSTEM` | Anweisung | System | implementiert | tb-vm::interp | bas7advr |
| `TROFF` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr |
| `TRON` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr |
| `INKEY$` | Funktion | Tastatur | implementiert | tb-runtime::builtins | bas7advr |
| `INPUT$` | Funktion | Tastatur | implementiert | tb-runtime::builtins | bas7advr |
| `KEY` | Anweisung | Tastatur | implementiert | tb-runtime::builtins | bas7advr |

View File

@@ -2,14 +2,17 @@
Rekonstruierte Referenz des Dialekts (DOS-BASIC, Stand 1992: prozedurale
BASIC-Familie mit Forms-Erweiterung). Dieses Dokument ist die verbindliche
Grundlage für Frontend und Runtime. Unklare Detailfragen sind mit `TODO`
markiert und werden per Testkorpus geklärt bzw. entschieden.
Grundlage für Frontend und Runtime. Offene Detailfragen sind ausgeräumt
(Stand 2026-09-04); wo die Original-Dokumentation schweigt, steht an Ort
und Stelle eine datierte Entscheidung mit Begründung.
**Primärquellen:** Original-Hilfedateien des Vorbilds als HTML unter
https://dos-help.soulsphere.org/ (`qck.hlp` Quick Reference, `advr.hlp`
Details, `ener.hlp` Fehlermeldungen; Vergleich PDS 7.1: `bas7qck.hlp`)
sowie das Original-README der Professional Edition. Aussagen ohne
TODO-Markierung sind daraus belegt.
ausgewiesene Entscheidung sind daraus belegt; die Themenlisten von
`bas7advr.hlp` und `qb45advr.hlp` bilden zusammen das
Vollständigkeits-Inventar (docs/inventar.md).
**Guiding Principle:** Die Original-Dokumentation führt. Jedes dort
dokumentierte Sprachelement wird unterstützt oder — nur nach expliziter
@@ -94,7 +97,9 @@ stehen am Ende dieses Dokuments; nur diese Liste rechtfertigt Abweisungen.
- `DIM [SHARED] var[(dims)] [AS typ]``AS`-Klausel: INTEGER, LONG, SINGLE,
DOUBLE, STRING, STRING * n, CURRENCY, benutzerdefinierter Typ.
- `COMMON [SHARED] [/blockname/] liste` — modulübergreifend (Kette
CHAIN-kompatibel im Vorbild; TODO: Relevanz ohne CHAIN klären).
CHAIN-kompatibel im Vorbild; ohne `CHAIN` bleibt `COMMON` die
Verknüpfung mehrerer Module eines Projekts — geklärt mit der
Projektverwaltung in Phase 5).
- `SHARED` (in Prozedur): Zugriff auf Modulebene-Variablen.
- `STATIC` (in Prozedur): Variablen behalten Werte zwischen Aufrufen;
`STATIC`-Attribut an `SUB`/`FUNCTION` macht alle lokalen Variablen statisch.
@@ -152,7 +157,8 @@ INTEGER 1 (wahr) / 0 (falsch).
oder `DEF…`-Regel.
- **Parameterübergabe standardmäßig BYREF.** Klammern um ein Argument
(`CALL f((x))`) erzwingen Wertübergabe. `BYVAL` nur in `DECLARE` für
externe Routinen (entfällt bei uns; TODO: `BYVAL` allgemein erlauben?).
externe Routinen (entfällt bei uns; `BYVAL` allgemein zu erlauben wäre
eine Erweiterung über den Kernstandard hinaus → Stufe 2, PLAN.md).
- Arrays werden mit `name()` übergeben, TYPEs BYREF.
- Rekursion erlaubt (außer bei `STATIC`-Semantik-Konflikten).
- `DEF FNname (args) = ausdruck` und Block-`DEF FN … END DEF` (mit
@@ -194,8 +200,21 @@ solange ein Menü-Control den Fokus hat. Für Terminal Basic relevant:
position, `-` nachgestelltes Minus, `$$` führendes $, `**` Füllsterne,
`**$` kombiniert, `^^^^` Exponent; String `&` ganz, `!` erstes Zeichen,
`\ \` n Zeichen (Leerzeichen + 2); `_` Literal-Escape; alle anderen
Zeichen literal. TODO: `%`-Präfix bei Feldüberlauf (QB-Verhalten) in der
Vorbild-Hilfe nicht gelistet — per Test klären, wir übernehmen es vorerst.
Zeichen literal.
- **Feldüberlauf** (geklärt 2026-09-04): Passt ein Wert nicht in sein
Feld, wird er vollständig ausgegeben und die Ausgabe mit `%`
eingeleitet (`PRINT USING "##"; 12345``%12345`). Die Original-Hilfe
listet das nicht; wir übernehmen das Verhalten der QB-Familie, weil ein
stilles Abschneiden Zahlen verfälschen würde.
- Vorzeichenangaben (`+` vorn, `+`/`-` hinten) belegen eine **eigene**
Position neben den Ziffernstellen; `$$` und `**` bringen ihre Stellen
dagegen mit.
- Die Formatzeichenkette wird wiederholt, solange Werte übrig sind. Eine
Zeichenkette ohne Feld bei vorhandenen Werten löst Fehler 5 aus.
- `SetFormatCC(ländercode)` setzt das Zeichen für `$$`. Nur wenige Codes
sind belegt (1 = `$`, 33 = `F`, 39 = `L`, 44 = `£`, 49 = `DM`,
81 = `¥`); alle übrigen behalten `$` — die Tabelle des Vorbilds ist
nicht überliefert (dokumentierte Abweichung).
- `INPUT ["prompt"{;|,}] var, …` (mit `;` vor Prompt: kein „? ");
`LINE INPUT` (ganze Zeile in String).
- `INKEY$` (nicht blockierend; "" wenn leer; erweiterte Tasten:
@@ -205,13 +224,26 @@ solange ein Menü-Control den Fokus hat. Für Terminal Basic relevant:
— arbeiten auf der tatsächlichen Bildschirmgröße (dynamisch, s. Abweichungen).
- `COLOR [vg][,hg]` (vg 031: 1631 = blinkend — bei uns als „hell"
simuliert, Entscheidung 2026-09-02; hg 07), `CLS`,
`WIDTH` (im Vorbild 80/40 — bei uns wirkungslos, s. Abweichungen),
`WIDTH` (hebt den Puffer höchstens an, s. Abweichungen),
`VIEW PRINT oben TO unten` (Scrollbereich).
- `TAB(n)`, `SPC(n)` in PRINT-Listen.
- `BEEP`, `SOUND freq, dauer` (Terminal-Bell / Nicht-Ziel, s. Abweichungen),
`PLAY` (Nicht-Ziel).
- `KEY n, text$` / `KEY LIST` / `KEY ON/OFF` (Funktionstasten-Makros +
Statuszeile) — TODO: Umfang klären.
- **Funktionstasten-Makros** (Umfang geklärt 2026-09-03 aus der
Original-Hilfe):
- `KEY n, text$` belegt ein Makro. `n` ist 110 für F1F10 sowie 30 und
31 für F11/F12; andere Werte lösen Fehler 5 aus. `text$` wird nach
**15 Zeichen** abgeschnitten.
- `KEY LIST` gibt alle zwölf Makros untereinander aus.
- `KEY ON` blendet die Softkey-Zeile in der untersten Bildschirmzeile ein
(Tastennummer plus die ersten sechs Zeichen des Makros), `KEY OFF`
blendet sie aus. Solange sie sichtbar ist, endet der Scrollbereich
darüber, damit die Ausgabe sie nicht überschreibt; ein danach gesetztes
`VIEW PRINT` hat Vorrang.
- `KEY(n) ON|OFF|STOP` **mit** Index ist Ereignissteuerung und kommt mit
der Forms-Engine (Phase 4).
- Abweichung: ein kompiliertes Programm startet mit **leeren** Makros;
die Vorbelegung des Vorbilds gehörte zu dessen Interpreter-Oberfläche.
## 10. Datei-E/A
@@ -240,8 +272,20 @@ solange ein Menü-Control den Fokus hat. Für Terminal Basic relevant:
`HEX$`, `OCT$`, `LSET`/`RSET` (Anweisungen).
**Mathematik:** `ABS`, `SGN`, `INT` (abrunden), `FIX` (Richtung 0), `SQR`,
`EXP`, `LOG`, `SIN`, `COS`, `TAN`, `ATN`, `RND[(n)]`, `RANDOMIZE [saat]`
(kompatibler PRNG! → Testkorpus), `CINT`, `CLNG`, `CSNG`, `CDBL`, `CCUR`.
`EXP`, `LOG`, `SIN`, `COS`, `TAN`, `ATN`, `RND[(n)]`, `RANDOMIZE [saat]`,
`CINT`, `CLNG`, `CSNG`, `CDBL`, `CCUR`.
**Zufallszahlen.** Der Generator ist der lineare Kongruenzgenerator des
Vorbilds auf 24 Bit:
`zustand = (zustand · &H FD43FD + &H C39EC3) MOD 2^24`, Ergebnis
`zustand / 2^24` als SINGLE im Bereich [0, 1).
`RND` und `RND(n)` mit `n > 0` liefern die nächste Zahl, `RND(0)`
wiederholt die zuletzt gelieferte, `RND(n)` mit `n < 0` setzt den Zustand
aus dem Argument. `RANDOMIZE saat` bildet aus der Saat 16 Bit, die die
Bits 823 des Zustands besetzen; das niederwertige Byte ist fest — damit
liefert dieselbe Saat stets dieselbe Folge, unabhängig von vorherigen
`RND`-Aufrufen. `RANDOMIZE` ohne Argument fragt den Startwert mit
`Random Number Seed (-32768 to 32767)?` ab.
**Datum/Zeit:** `DATE$` (Funktion und Anweisung — Setzen: Nicht-Ziel),
`TIME$`, `TIMER` (Sekunden seit Mitternacht, SINGLE); dazu die nativen
@@ -287,7 +331,13 @@ Dialoge:
- Der Bildschirmpuffer speichert Unicode-Zeichen; Rahmen werden mit
Unicode-Box-Drawing gezeichnet.
- Zeichen mit Darstellungsbreite 2 (Emoji, CJK) **belegen zwei Zellen**
(Entscheidung 2026-09-02; Umsetzung Phase 3).
(Entscheidung 2026-09-02, umgesetzt): der Cursor rückt um zwei Spalten
vor, `POS` nennt die Anfangsspalte, `LOCATE` auf die zweite Hälfte
wirkt auf den Zeichenanfang. Passt ein breites Zeichen nicht mehr in
die Zeile, wandert es als Ganzes in die nächste und die letzte Spalte
bleibt leer. Wird eine Hälfte überschrieben, verschwindet die andere.
Zeichen der Breite 0 (kombinierende Zeichen) belegen vorerst eine
eigene Zelle.
- `GET`/`PUT`-Records: feste Strings (`STRING * n`) werden als **UTF-32**
gespeichert (4 Bytes/Zeichen, feste Record-Länge) — Binärdateien sind
dadurch bewusst inkompatibel zum Vorbild (Entscheidung 2026-09-02).
@@ -295,20 +345,60 @@ Dialoge:
`OUT`/`WAIT`/`BLOAD`/`BSAVE`/`VARPTR`/`VARSEG`/`SADD`/`CALL ABSOLUTE`/
`CALL INTERRUPT`/`IOCTL` werden bereits **zur Compile-Zeit** mit der
Meldung „Feature unavailable" abgewiesen (kein Laufzeitfehler).
3. **`INKEY$` für erweiterte Tasten** liefert weiterhin
`CHR$(0) + code`-Sequenzen mit den klassischen Scancodes (F1 = `CHR$(0)+";"`
usw.), damit bestehender Code funktioniert. Zusätzliche moderne Tasten:
TODO.
Ebenso abgewiesen: die Interlanguage-Schnittstelle (`CALLS`, `SSEG`,
`SSEGADD`, `StringAddress`/`StringAssign`/`StringLength`/
`StringRelease`), die Eingabegeräte `PEN`, `STRIG`, `STICK` und die
serielle Schnittstelle (`COM`, `ON COM`, `OPEN "COMn:"`).
3. **`INKEY$` für erweiterte Tasten** liefert `CHR$(0) + code`-Sequenzen
mit den klassischen Scancodes, damit bestehender Code funktioniert
(festgelegt 2026-09-04): F1F10 = 5968, Pos1 = 71, ↑ = 72, Bild↑ = 73,
← = 75, → = 77, Ende = 79, ↓ = 80, Bild↓ = 81, Einfg = 82, Entf = 83.
Zeichentasten liefern ein Zeichen, Eingabe `CHR$(13)`, Rückschritt
`CHR$(8)`, Esc `CHR$(27)`, Tabulator `CHR$(9)`. Strg+C und Strg+Untbr
sind Abbruch und erscheinen nicht in `INKEY$`. Tasten heutiger
Tastaturen ohne Entsprechung im Vorbild (etwa F11/F12 oder Meta-
Kombinationen) liefern nichts — eine eigene Kodierung dafür wäre eine
Erweiterung und gehört in Stufe 2.
4. **Kein `CHAIN`/Overlay-Mechanismus**; `SHELL` startet die System-Shell.
5. **Grafik-Anweisungen** (`SCREEN n>0`, `PSET`, `LINE`, `CIRCLE`, `PAINT`,
`DRAW`, Grafik-`GET`/`PUT`, `PALETTE`, `WINDOW`/`VIEW`, `PMAP`, `PCOPY`)
sind Nicht-Ziel (Fehler 73). Hinweis: das Vorbild **hatte** die
5. **Grafik-Anweisungen** (`SCREEN n>0`, `PSET`, `PRESET`, `LINE`,
`CIRCLE`, `PAINT`, `DRAW`, Grafik-`GET`/`PUT`, `PALETTE`, `WINDOW`,
`VIEW`, `PMAP`, `PCOPY`, `POINT`) sind Nicht-Ziel und werden **zur
Compile-Zeit** mit „Feature unavailable" abgewiesen. Die Formen, die
sich ein Schlüsselwort mit einer unterstützten Anweisung teilen, erkennt
der Compiler an ihrer Syntax: `LINE` ohne `INPUT`, `GET`/`PUT` mit
`(`, `VIEW` ohne `PRINT`. Hinweis: das Vorbild **hatte** die
Grafikmodi 013, aber nicht gleichzeitig mit Forms (dort Fehler 272
„Invalid when forms are showing") — wir übernehmen nur den
Forms-/Textmodus-Zweig.
6. **`PLAY`/`SOUND`**: `BEEP` = Terminal-Bell; Rest Nicht-Ziel (Fehler 73).
5a. **`LPRINT` schreibt in eine Datei** (2026-09-03): einen
plattformübergreifenden Druckerkanal gibt es nicht. Der Druckerpuffer
geht am Programmende in `LPT1.TXT` im aktuellen Verzeichnis; `LPOS`
nennt die Spalte in diesem Puffer.
5b. **Satzsperren** (`LOCK`/`UNLOCK`) prüfen nur, ob die Datei offen ist;
eine echte Sperre bietet die Plattform nicht einheitlich. `FILEATTR`
liefert nur die Modus-Kennzahl (Argument 1); das
Betriebssystem-Handle bildet der Dialekt nicht ab und liefert 0.
5c. **`CHDRIVE`** ist außerhalb von Windows folgenlos — Laufwerksbuchstaben
gibt es dort nicht.
6a. **Uhr läuft in UTC** (2026-09-03): `DATE$`, `TIME$`, `TIMER` und `NOW`
liefern koordinierte Weltzeit, nicht Ortszeit — die Standardbibliothek
von Rust kennt keine Zeitzonen, und eine Zeitzonenbibliothek wäre die
erste schwere Abhängigkeit der Laufzeit. `DATE$ = …` und `TIME$ = …`
stellen nicht die Systemuhr, sondern einen programmeigenen Versatz;
alle Zeitfunktionen rechnen ihn mit ein.
6b. **Serielle Datumswerte** zählen Tage ab dem 30.12.1899 (Serie 2 =
1.1.1900), der Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1
für Sonntag. Zweistellige Jahre gelten in `DATESERIAL` als 19xx, in
`DATEVALUE` als 20xx unter 80 und sonst als 19xx.
7. **Dynamische Bildschirmgröße** (2026-09-02): kein festes 80×25 — der
Bildschirm folgt der Terminalgröße (Mindestgröße 80×25, darunter nur
ein Hinweis). `CSRLIN`/`POS`/`LOCATE`/`VIEW PRINT` und das
`SCREEN`-Objekt arbeiten auf der tatsächlichen Größe. `WIDTH 40/80`
wird akzeptiert, ändert aber nichts (die Größe bestimmt das Terminal).
80×25 ist überall nur die **untere Schranke der Darstellung**, nie eine
Bildschirmgröße im Verhalten des Programms. Ändert sich die Größe
**während der Ausführung**, wird das zugestellt: der Inhalt bleibt oben
links erhalten, der Cursor wird in die neuen Grenzen geklemmt, und ein
`VIEW PRINT`-Bereich wird am unteren Rand geklemmt bzw. — wenn er ganz
vom Schirm fällt — auf den vollen Bildschirm zurückgesetzt.

View File

@@ -41,11 +41,17 @@ enum Value {
BYREF-Parameter als Referenz-Slots (Handle auf Variablen-Slot).
- GOSUB: **kein** Frame — Rücksprungadresse auf separatem GOSUB-Stack im
aktuellen Frame (RETURN prüft diesen zuerst).
- **Fehlerbehandlung:** Pro Frame ein Handler-Zustand (`ON ERROR GOTO x`).
Laufzeitfehler → VM sucht aktiven Handler im aktuellen Modulkontext
(Vorbild: Handler sind modul-/prozedurlokal, TODO: exakte Scoping-Regel
testen), setzt `ERR`/`ERL`, springt. `RESUME` nutzt gemerkten
Anweisungs-Offset.
- **Fehlerbehandlung:** Zwei Ebenen (Scoping geklärt 2026-09-04).
`ON LOCAL ERROR GOTO x` setzt einen Handler im aktuellen Frame; sein
Sprungziel muss im selben Rumpf liegen. `ON ERROR GOTO x` **ohne**
`LOCAL` setzt den modulweiten Handler — auch aus einer Prozedur heraus,
und sein Sprungziel liegt dann im Modulrumpf (der Codegenerator löst es
über Prozedur 0 auf, die zuerst übersetzt wird).
Ein Laufzeitfehler sucht zuerst lokale Handler die Aufrufkette hinauf,
dann den modulweiten; beim modulweiten wird bis Frame 0 abgewickelt,
weshalb `RESUME NEXT` dort hinter dem Prozeduraufruf fortsetzt. Der
Handler setzt `ERR`/`ERL`; `RESUME` nutzt den gemerkten
Anweisungs-Offset. Ein Fehler im Handler ist fatal (keine Kaskaden).
## Ausführungsmodell / Unterbrechbarkeit
@@ -136,6 +142,20 @@ Windows 11, `cargo bench -p tb-vm`):**
- DOUBLE-Schleife: ≈ 5,2 Mio Iterationen/s
- SUB-Aufrufe mit BYREF: ≈ 5,2 Mio Aufrufe/s
- String-Lasten (MID$/INSTR/Verkettung): ≈ 0,8 Mio Runden/s
**Nachmessung nach Phase 3 (2026-09-04, macOS, Apple Silicon,
`cargo bench -p tb-vm`)** — die Zustellpunkte des neuen `Host` (Anzeige
nur bei geändertem Bildschirm, Ereignisabholung alle 1024 Anweisungen)
kosten nichts Messbares:
- Compile-Budget: Einzelmodul 508 Zeilen **0,77 ms**; Projekt 49 760
Zeilen **53 ms** (≈ 950 000 Zeilen/s)
- VM-Durchsatz: INTEGER-Schleife **11,0 Mio/s**, DOUBLE **12,2 Mio/s**,
SUB-Aufrufe **10,7 Mio/s**, String-Lasten **2,4 Mio Runden/s**
(Die höheren Zahlen gegenüber 2026-09-02 gehen auf die andere Maschine
zurück, nicht auf eine Optimierung — gemessen wurde beide Male derselbe
Benchmark.)
- Einordnung: bei ≈ 13 Instruktionen je Schleifeniteration entspricht
das ≈ 60 Mio Instruktionen/s — Vorbild-Programme auf 1992er-Hardware
werden um weit mehr als den Faktor 100 übertroffen; für 80×25-
@@ -145,14 +165,18 @@ Windows 11, `cargo bench -p tb-vm`):**
## Zahlenkonvertierungs-Matrix (Feindesign, 2026-09-02)
Grundlage: Original-Hilfe (Topics „Arithmetic Operators", „CINT/CLNG",
„MOD", „Numeric Types"). Zellen ohne belastbare Fundstelle sind mit
`TODO verify` markiert; der Korpustest `konvertierung.bas` verankert die
Matrix ausführbar.
„MOD", „Numeric Types"). Vier Punkte sind dort **nicht** geregelt; sie
wurden am 2026-09-04 entschieden und sind unten als *Projektentscheidung*
gekennzeichnet — sie gelten damit als bewusste Festlegung, nicht als
rekonstruiertes Vorbildverhalten. Der Korpustest `konvertierung.bas`
verankert die Matrix ausführbar.
**Rangfolge für Promotion:** INTEGER < LONG < CURRENCY < SINGLE < DOUBLE.
Ausnahme: CURRENCY gemischt mit SINGLE oder DOUBLE ergibt **DOUBLE**
(nicht SINGLE), damit keine Festkomma-Präzision in f32 verloren geht
(`TODO verify` gegen PDS-Hilfe; plausibelste Lesart).
(nicht SINGLE). *Projektentscheidung 2026-09-04:* Die Original-Hilfe
regelt diesen Fall nicht. CURRENCY trägt 19 signifikante Stellen, SINGLE
nur 7 — eine Promotion nach SINGLE zerstörte genau die Genauigkeit, für
die es den Typ gibt. DOUBLE (16 Stellen) ist die nächstbeste Wahl.
**Ergebnistyp binärer Operatoren** (l op r; T = promoteter Typ):
@@ -161,7 +185,7 @@ Ausnahme: CURRENCY gemischt mit SINGLE oder DOUBLE ergibt **DOUBLE**
| `+ - *` | T; gleiche Typen bleiben erhalten | Ganzzahl-/CURRENCY-Überlauf → Fehler 6 (auch INTEGER+INTEGER promotet **nicht**) |
| `/` | DOUBLE, wenn ein Operand DOUBLE oder CURRENCY, sonst SINGLE | Division durch 0 → Fehler 11 |
| `\` `MOD` | INTEGER, wenn beide Operanden INTEGER, sonst LONG | Operanden werden **vorher** kaufmännisch (banker's) auf Ganzzahl gerundet; außerhalb LONG → Fehler 6; Divisor 0 → Fehler 11; `MOD`-Vorzeichen wie Dividend |
| `^` | DOUBLE, wenn ein Operand DOUBLE oder CURRENCY, sonst SINGLE (`TODO verify`) | Rechnung intern in DOUBLE; 0 ^ negativ → Fehler 11; negative Basis mit nicht-ganzem Exponent → Fehler 5 |
| `^` | DOUBLE, wenn ein Operand DOUBLE oder CURRENCY, sonst SINGLE (*Projektentscheidung 2026-09-04*) | Rechnung intern in DOUBLE; 0 ^ negativ → Fehler 11; negative Basis mit nicht-ganzem Exponent → Fehler 5 |
| `= <> < <= > >=` | INTEGER (1/0) | Vergleich im promoteten Typ; Strings codepoint-weise |
| `NOT AND OR XOR EQV IMP` | INTEGER, wenn alle Operanden INTEGER, sonst LONG | bitweise; Operanden vorher banker's-gerundet auf Ganzzahl, außerhalb LONG → Fehler 6 |
| unäres `-` | Typ des Operanden | (32768) INTEGER → Fehler 6 |
@@ -188,13 +212,21 @@ via `CINT`/`CLNG`/`CSNG`/`CDBL`/`CCUR` — identische Semantik):
- INTEGER/LONG: dezimal ohne Dezimalpunkt.
- SINGLE: bis 7 signifikante Stellen, kürzeste Darstellung, keine
führende Null vor dem Dezimalpunkt (`.5`), keine nachgestellten
Nullen; außerhalb des Festformat-Bereichs Exponentialform `mE±xx`
(exakte Schwelle `TODO verify`; umgesetzt: Exponentialform, wenn der
Dezimalexponent < 7 oder ≥ 7 ist).
Nullen; außerhalb des Festformat-Bereichs Exponentialform `mE±xx`.
*Projektentscheidung 2026-09-04:* Die Original-Hilfe nennt keine
Schwelle. Gewählt ist der Dezimalexponent < 7 oder ≥ 7 — damit bleibt
jeder Wert im Festformat, der sich mit den 7 signifikanten Stellen des
Typs verlustfrei schreiben lässt (`9999999` fest, `1E+07`
exponentiell; `.0000001` fest, `1E-08` exponentiell).
- DOUBLE: wie SINGLE mit bis 16 signifikanten Stellen und `D±xx` als
Exponentmarke.
- CURRENCY: Festformat mit bis zu 4 Nachkommastellen, nachgestellte
Nullen entfallen (`TODO verify`).
Nullen entfallen (`2.5000@``2.5`, `1.10@``1.1`).
*Projektentscheidung 2026-09-04:* Die Original-Hilfe nennt kein
Ausgabeformat. Gewählt ist dieselbe Regel wie bei den übrigen
Zahlentypen — nachgestellte Nullen entfallen —, damit `PRINT` über alle
Typen einheitlich wirkt. Wer feste Nachkommastellen braucht, nutzt
`PRINT USING`.
- `PRINT` stellt nicht-negativen Zahlen ein Leerzeichen, negativen ein
`-` voran und hängt stets ein Leerzeichen an; `STR$` nur das führende
Leerzeichen/`-`, kein nachgestelltes.
@@ -266,6 +298,9 @@ als Vergleichs-/Sprungkette abgesenkt.
## Offene Punkte
Keine — die Punkte aus Phase 0 (Opcode-Satz, Konvertierungsmatrix,
`STOP`/`CONT` im Runner) sind oben ausgearbeitet; verbleibende
Detailunsicherheiten sind als `TODO verify` direkt an den betroffenen
Zellen markiert und über den Testkorpus abgesichert.
`STOP`/`CONT` im Runner) sind oben ausgearbeitet. Die vier Stellen, zu
denen die Original-Hilfe schweigt, sind am 2026-09-04 als
*Projektentscheidung* festgelegt, an Ort und Stelle begründet und über
`tests/compat/konvertierung.bas` ausführbar verankert. Sollte je eine
belastbare Fundstelle auftauchen, die ihnen widerspricht, gilt sie —
die Entscheidungen sind Lückenfüller, keine Setzungen gegen das Vorbild.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-03

View File

@@ -0,0 +1,209 @@
## Context
Siehe proposal.md — Why. Technisch relevant ist der Zustand nach
`phase-3-runtime-bildschirm`:
- Die Dateitabelle, die Recordpuffer und die UTF-32-Serialisierung fester
Textfelder existieren (Capability `datei-eio`) und sind die Grundlage,
auf der ISAM die Sätze ablegt.
- Die Laufzeitfehler 8189 sind in `tb_runtime::errors` bereits
vorhanden; dieser Change liefert ihre Auslöser.
- Vom ISAM-Sprachumfang existiert heute nur die Parser-Klausel
`OPEN … FOR ISAM` (`parser.rs`). Die ISAM-Anweisungen haben weder
Grammatik noch Signatur — sie enden in „unbekannter Bezeichner".
- Das Sprachinventar (`docs/inventar.md`) führt die ISAM-Elemente mit
Status `offen` und ist der maßgebliche Umfangsnachweis.
## Goals / Non-Goals
**Goals:**
- Der volle dokumentierte ISAM-Sprachumfang läuft, nicht eine Auswahl.
- Die Cursor-Semantik des Vorbilds ist exakt abgebildet — sie ist der
Teil, den keine Fremdbibliothek liefert.
- Speicher, Indizes und Transaktionen kommen aus einer erprobten
Bibliothek statt aus Eigenbau.
- Phase 3 ist mit diesem Change abgeschlossen.
**Non-Goals:**
- Leistungsoptimierung des Satzzugriffs — ISAM-Programme des Vorbilds
arbeiten auf Datenmengen, die jede heutige Speicherschicht mühelos
trägt.
- Nebenläufiger Zugriff mehrerer Prozesse auf dieselbe Datenbankdatei
über die Satzsperren aus `datei-eio` hinaus.
- Lesen von Datenbankdateien des Vorbilds.
## Decisions
### D1: `redb` als Speicherschicht
Die Datenbankdatei ist eine `redb`-Datei. Sätze und Indexeinträge liegen
darin als Key-Value-Paare; `redb` liefert B-Baum, Transaktionen und
Crash-Sicherheit.
*Warum nicht Eigenbau:* Seitenverwaltung, B-Baum und crash-sichere
Transaktionen sind gelöste Probleme mit langen Fehlerkatalogen. Der
projektspezifische Teil ist die ISAM-Semantik, nicht der Baum darunter.
*Warum nicht SQLite:* Zöge eine C-Bibliothek in jedes von `tbc build
--exe` erzeugte Binary. Der eigentliche Grund ist aber semantisch: ISAM
hat einen *stabilen, benannten Cursor*, der Änderungen überlebt und sich
vorwärts wie rückwärts bewegt — ein SQL-Cursor ist ein
Ergebnismengen-Iterator. Die Abbildung wäre aufwendiger als der Gewinn.
Die für Stufe 2 geplante SQL-Erweiterung bleibt davon unberührt; sie ist
eine eigene Sprachebene und kann später ihren eigenen Speicher wählen.
*Was `redb` nicht liefert und wir bauen:* Sekundärindizes,
Savepoints innerhalb einer Transaktion, die Cursor-Semantik.
### D2: Datenmodell — Sätze und Indizes als getrennte Tabellen
Je Tabelle eine Satztabelle `satz-id → Satzbytes` und je Index eine
Tabelle `Schlüsselbytes → satz-id`. Satz-IDs sind monoton vergebene
Zahlen und werden nie wiederverwendet.
*Warum getrennt:* `SETINDEX` wechselt die Durchlaufordnung zur Laufzeit;
mit Indextabellen ist das ein Wechsel der zu lesenden Tabelle, ohne die
Sätze anzufassen. `MOVEPREVIOUS` und die `SEEK`-Familie sind dann
Bereichsabfragen auf einer geordneten Tabelle.
*Warum IDs nicht wiederverwendet werden:* Der Cursor merkt sich eine
Satz-ID. Würde sie nach `DELETE` neu vergeben, könnte ein Cursor still
auf einen fremden Satz zeigen.
### D3: Schlüsselkodierung muss ordnungserhaltend sein
Indexschlüssel werden so kodiert, dass die Byte-Reihenfolge der Kodierung
der fachlichen Reihenfolge entspricht — Zahlen mit vorzeichenrichtiger
Kodierung fester Breite, Texte nach der in D4 festgelegten Ordnung,
mehrspaltige Schlüssel als Verkettung mit eindeutigem Trenner.
Absteigende Spalten werden durch Invertierung der Bytes abgebildet.
*Warum das die kritische Stelle ist:* `redb` ordnet nach Bytes. Jede
Ordnungsverletzung in der Kodierung äußert sich als falsche
Satzreihenfolge — ein Fehler, der erst bei bestimmten Datenwerten
sichtbar wird. Ein Eigenschaftstest über zufällige Wertepaare
(Kodierungsvergleich = fachlicher Vergleich) gehört deshalb zur
Umsetzung.
*Mehrspaltige Präfixsuche* fällt aus dieser Kodierung ab: Suche mit
weniger Werten als Spalten ist eine Bereichsabfrage über das
Schlüsselpräfix.
### D4: Sortierordnung explizit festlegen
Texte werden nach Unicode-Codepoints geordnet, Groß- und Kleinschreibung
unterschieden. Es ist dieselbe Ordnung, die der Vergleichsoperator des
Dialekts auf Strings anwendet.
`TEXTCOMP` gibt es in der Original-Hilfe nicht (Befund aus Aufgabe 1.1,
2026-09-03); die Ordnung braucht deshalb keine eigene Vergleichsfunktion,
sondern muss nur mit dem Stringvergleich der Sprache übereinstimmen.
*Warum nicht sprachabhängige Sortierung:* Sie machte die Reihenfolge
plattform- und locale-abhängig und damit die Korpustests unreproduzierbar.
Das Vorbild ordnete nach Zeichencodes einer festen Codepage; Codepoints
sind die direkte Entsprechung unter der bereits getroffenen
Unicode-Entscheidung. Die Ordnung wird in docs/sprachreferenz.md
festgehalten, weil sie beobachtbar ist.
### D5: Cursor als Wert, nicht als Bibliotheks-Iterator
Der Cursor je Dateinummer ist ein eigener Zustand: aktiver Index,
zuletzt besuchte Satz-ID und ein Kennzeichen „unpositioniert". Jede
Bewegung ist eine frische Bereichsabfrage ab dem gemerkten Schlüssel.
*Warum keine gehaltenen Iteratoren:* Der ISAM-Cursor überlebt
Satzänderungen und Transaktionsgrenzen; ein gehaltener Iterator über eine
Transaktion tut das nicht. Ein Cursor als Wert bildet außerdem
„unpositioniert" (Fehler 85) direkt ab, statt es aus einem
Iteratorzustand ableiten zu müssen.
*Preis:* Jede Bewegung kostet eine Suche statt eines Schritts. Bei den
Datenmengen dieser Programme irrelevant; siehe Non-Goals.
### D6: Savepoints als Rücknahmeprotokoll
`BEGINTRANS` öffnet eine Schreibtransaktion. Innerhalb davon führt die
Laufzeit ein Protokoll der Änderungen; die Funktion `SAVEPOINT` merkt
sich eine Position darin und liefert deren Kennung, `ROLLBACK` mit dieser
Kennung spielt das Protokoll bis dorthin rückwärts ab. `ROLLBACK ALL` und
`COMMITTRANS` nutzen die Transaktion der Bibliothek direkt.
*Warum ein eigenes Protokoll:* Verschachtelte Rücknahmepunkte gehören
nicht zum Funktionsumfang von `redb`. Das Protokoll ist der kleinste
Weg dorthin und bleibt auf den Umfang einer laufenden Transaktion
begrenzt.
### D7: Fehler 89 und `SETMEM` bekommen eine echte Grenze
`SETMEM` ist eine Funktion: sie ändert die Obergrenze des ISAM-Puffers um
den übergebenen Betrag und liefert die danach verfügbare Größe.
Überschreiten der Grenze löst Fehler 89 aus. Ohne Grenze wäre der Code nie
erreichbar und die Anweisung eine Attrappe.
*Abweichung:* Die Bedeutung in Bytes entspricht nicht dem
DOS-Speichermodell des Vorbilds und wird in docs/sprachreferenz.md
dokumentiert.
### D8: Reihenfolge der Umsetzung
```
1. Frontend: Grammatik + Signaturen -> Elemente werden benannt
2. Speicher: Datei, Satztabelle, Schluesselkodierung (+ Eigenschaftstest)
3. Indizes: CREATEINDEX/SETINDEX/GETINDEX$/DELETEINDEX
4. Cursor: MOVE-Familie, SEEK-Familie, EOF
5. Saetze: INSERT/RETRIEVE/UPDATE/DELETE, Eindeutigkeit
6. Transaktionen: BEGINTRANS/COMMITTRANS/ROLLBACK[ ALL]/SAVEPOINT
7. Rest: SETMEM, DELETETABLE, BOF, Formatversion
```
Schritt 1 zuerst, weil er allein schon den Guiding-Principle-Verstoß
behebt: ISAM-Programme werden ab da namentlich abgewiesen statt mit
„unbekannter Bezeichner". Schritt 2 vor allem anderen, weil die
Schlüsselkodierung jede spätere Ordnung bestimmt.
## Risks / Trade-offs
- **Fehlerhafte Schlüsselkodierung fällt erst bei bestimmten Daten auf**
→ Eigenschaftstest über zufällige Wertepaare je Spaltentyp:
Byte-Vergleich der Kodierung MUSS dem fachlichen Vergleich entsprechen.
Läuft vor allem Weiteren.
- **Der genaue ISAM-Sprachumfang stammt aus der Original-Hilfe und ist
bislang nur aus zweiter Hand belegt** → Das Inventar aus
`phase-3-runtime-bildschirm` ist die maßgebliche Liste; findet die
Erhebung dort weitere Elemente, gehören sie in diesen Change. Die
Aufzählung im Proposal ist ein Mindestumfang, keine Obergrenze.
- **Cursorverhalten an den Rändern ist die häufigste Fehlerquelle**
(`EOF` nach `MOVENEXT` am Ende, Cursor nach `DELETE`, nach `SETINDEX`,
nach `ROLLBACK`) → je ein Korpustest pro Randfall, nicht ein
Sammeltest.
- **Neue Abhängigkeit in `tb-runtime`, die bisher keine hatte** →
bewusst; die Alternative wäre ein eigener transaktionaler B-Baum.
Die Abhängigkeit bleibt auf das ISAM-Modul beschränkt.
- **Das Rücknahmeprotokoll kann bei langen Transaktionen wachsen** →
begrenzt auf die laufende Transaktion; `COMMITTRANS` beendet sie. Falls
eine Grenze nötig wird, ist Fehler 89 der dafür vorgesehene Ausgang.
- **Eigenes Dateiformat heißt: keine Vorbild-Datenbanken lesbar** →
bewusst entschieden, dieselbe Linie wie UTF-32-Records; steht in
docs/dateiformate.md und in der Sprachreferenz.
## Migration Plan
Rein additiv — es gibt heute keine ISAM-Funktionalität und keine
Datenbankdateien im Feld. Die einzige sichtbare Änderung an bestehendem
Verhalten: `OPEN … FOR ISAM` löst nicht mehr Laufzeitfehler 73 aus,
sondern arbeitet.
Die Datenbankdatei trägt von der ersten Fassung an eine Formatversion,
damit spätere Änderungen abgewiesen statt fehlinterpretiert werden.
## Open Questions
- Genaue Argumentformen einzelner ISAM-Anweisungen (etwa die Schreibweise
der Spaltenliste bei `CREATEINDEX`): bei der Umsetzung aus der
Original-Hilfe zu übernehmen. Berührt weder Datenmodell noch
Aufgabenschnitt.
- Konkreter Vorgabewert der `SETMEM`-Grenze: bei der Umsetzung zu wählen
und zu dokumentieren.

View File

@@ -0,0 +1,104 @@
# Phase 3 (Abschluss) — ISAM-Datenbankunterstützung
## Why
Die Leitplanke „Vollständigkeit ist das Soll" und die Entscheidung vom
2026-09-02 legen fest: ISAM wird implementiert, es ist kein Non-Feature.
Heute existiert davon nur eine Parser-Klausel — `OPEN … FOR ISAM typname
tabellenname AS #n` wird erkannt und auf den „nicht verfügbar"-Marker
abgesenkt. Die eigentlichen ISAM-Anweisungen und -Funktionen
(`CREATEINDEX`, `SETINDEX`, `INSERT`, `RETRIEVE`, `UPDATE`, `DELETE`, die
`MOVE`- und `SEEK`-Familien, die Transaktionsanweisungen) besitzen im
Frontend **keine Signatur**. Sie enden damit in „unbekannter Bezeichner"
bzw. einem generischen Syntaxfehler — genau der Fall, den der Guiding
Principle als Bug definiert: ein dokumentiertes Feature wird still und
ohne Namensnennung abgewiesen.
Die Laufzeitfehler 8189 des ISAM-Bereichs sind bereits als
`tb_runtime::errors` implementiert und warten auf ihre Auslöser.
Dieser Change schließt Phase 3 ab. Der vorausgehende Change
`phase-3-runtime-bildschirm` liefert die Datei-E/A-Grundlage
(Dateitabelle, Recordpuffer, UTF-32-Festtextfelder), auf der ISAM
aufsetzt, sowie das Inventar, in dem die ISAM-Elemente ihren Status
führen.
## What Changes
- **ISAM-Elemente erhalten Frontend-Signaturen**: Sämtliche in der
Original-Hilfe dokumentierten ISAM-Anweisungen und -Funktionen werden
in Grammatik und Signaturprüfung aufgenommen, damit ihre Verwendung
namentlich diagnostiziert statt als unbekannter Bezeichner behandelt
wird. Den maßgeblichen Umfang bestimmt das Sprachinventar aus
`phase-3-runtime-bildschirm`; nach heutigem Stand umfasst er
mindestens `CREATEINDEX`, `DELETEINDEX`, `SETINDEX`, `GETINDEX$`,
`INSERT`, `RETRIEVE`, `UPDATE`, `DELETE`, `DELETETABLE`, `MOVEFIRST`,
`MOVELAST`, `MOVENEXT`, `MOVEPREVIOUS`, `SEEKEQ`, `SEEKGT`, `SEEKGE`,
`BEGINTRANS`, `COMMITTRANS`, `ROLLBACK`/`ROLLBACK ALL`, `SAVEPOINT`
(Funktion), `SETMEM` (Funktion), `BOF` sowie das ISAM-Verhalten von
`EOF`, `DELETE`, `LOF` und `LOC`.
- **ISAM-Datenbanken und Tabellen**: `OPEN … FOR ISAM typname
tabellenname AS #n` bindet eine Tabelle einer Datenbankdatei an eine
Dateinummer; das Satzlayout ergibt sich aus dem benannten
benutzerdefinierten Typ. Tabellen entstehen beim ersten Öffnen,
`DELETETABLE` entfernt sie.
- **Indizes**: benannte Indizes über eine oder mehrere Spalten, eindeutig
oder mehrdeutig, mit auf- und absteigender Ordnung; `SETINDEX` wählt
den aktiven Index und bestimmt damit die Satzreihenfolge, `GETINDEX$`
liefert ihn zurück.
- **Satzoperationen und Cursor**: `INSERT`, `RETRIEVE`, `UPDATE`,
`DELETE` arbeiten auf dem aktuellen Satz; die `MOVE`-Familie bewegt den
Cursor in der Ordnung des aktiven Index, die `SEEK`-Familie positioniert
über Schlüsselvergleiche (gleich, größer, größer-gleich). `EOF` und
`BOF` melden das Überlaufen des Cursors am Ende bzw. am Anfang.
- **Transaktionen**: `BEGINTRANS`, `COMMITTRANS`, `ROLLBACK` und
`ROLLBACK ALL` mit Rücknahme aller Änderungen seit Transaktionsbeginn
bzw. seit einem Sicherungspunkt; `SAVEPOINT` ist eine **Funktion** und
liefert die Kennung des gesetzten Sicherungspunkts.
- **Vergleichsreihenfolge**: Die Sortierordnung der Indizes wird unter
Unicode festgelegt und dokumentiert.
- **ISAM-Fehlersemantik**: Die vorhandenen Fehlercodes 8189 werden von
den entsprechenden Situationen ausgelöst (ungültiger Name, Tabelle
nicht gefunden, Index nicht gefunden, ungültige Spalte, kein aktueller
Satz, Verstoß gegen einen eindeutigen Index, ungültige Operation auf
NULL-Index, inkonsistente Datenbank, zu wenig Puffer).
- **Eigenes Datenbankdateiformat**: Die Datenbankdatei ist bewusst nicht
binärkompatibel zum Vorbild — dieselbe Linie wie bei den UTF-32-
Records. Das Format wird in `docs/dateiformate.md` beschrieben.
**Non-Goals:** Ein Konvertierungswerkzeug für Datenbankdateien des
Vorbilds; die eingebettete SQL-Erweiterung samt `WHILE SELECT`
(Stufe 2, Ideenspeicher); Mehrprozess-Zugriff über
Betriebssystemgrenzen hinweg jenseits der Satzsperren aus `datei-eio`.
## Capabilities
### New Capabilities
- `isam-datenbank`: indizierter Satzzugriff — Datenbank- und
Tabellenbindung an Dateinummern, Indexverwaltung, Cursorbewegung und
Schlüsselsuche, Satzoperationen, Transaktionen, Vergleichsreihenfolge
und die ISAM-Fehlersemantik (Codes 8189).
### Modified Capabilities
- `sprach-frontend`: Die Grammatik und die Signaturprüfung SHALL die
ISAM-Anweisungen und -Funktionen umfassen, sodass ihre Verwendung
namentlich diagnostiziert wird statt als unbekannter Bezeichner.
## Impact
- `crates/tb-frontend`: neue Anweisungsgrammatik und Signaturen für die
ISAM-Elemente; die bisherige `Unsupported`-Absenkung von `OPEN … FOR
ISAM` entfällt.
- `crates/tb-runtime`: neues ISAM-Modul (Speicher, Indizes, Cursor,
Transaktionen) auf der Dateitabelle und den Recordpuffern aus
`phase-3-runtime-bildschirm`.
- `crates/tb-vm`: neue Builtin-Indizes für die ISAM-Anweisungen.
- `tests/compat`: neue Korpusprogramme für Indexordnung, Schlüsselsuche,
Eindeutigkeitsverletzung, Cursorenden und Transaktionsrücknahme.
- `docs/`: `docs/dateiformate.md` um das Datenbankdateiformat erweitert;
`docs/bibliothek.md` um die ISAM-Elemente; `docs/inventar.md` wechselt
deren Status auf `implementiert`; `docs/sprachreferenz.md` hält die
Sortierordnung und die Inkompatibilität des Dateiformats fest.
- PLAN.md: Phase 3 gilt mit diesem Change als abgeschlossen.

View File

@@ -0,0 +1,187 @@
## Purpose
Die ISAM-Unterstützung bietet indizierten Satzzugriff auf Tabellen einer
Datenbankdatei: Sätze werden über benannte Indizes geordnet durchlaufen
und über Schlüsselvergleiche gesucht, Änderungen laufen in Transaktionen
und Verstöße melden die ISAM-Fehlercodes des Vorbilds.
## ADDED Requirements
### Requirement: Datenbank- und Tabellenbindung an eine Dateinummer
`OPEN datenbank$ FOR ISAM typname tabellenname AS #n` SHALL eine Tabelle
einer Datenbankdatei an eine Dateinummer binden. Das Satzlayout SHALL
sich aus dem benannten benutzerdefinierten Typ ergeben; dessen Felder
sind die Spalten der Tabelle. Existiert die Tabelle noch nicht, SHALL sie
mit diesem Layout angelegt werden; existiert sie mit abweichendem Layout,
MUST Laufzeitfehler 88 „ISAM - Database inconsistent" auftreten. Ein
ungültiger Tabellen- oder Datenbankname MUST Fehler 81 „ISAM - Invalid
name" auslösen. `CLOSE` SHALL die Bindung lösen und ausstehende
Änderungen festschreiben. `DELETETABLE` SHALL eine Tabelle samt ihren
Indizes entfernen; eine nicht vorhandene Tabelle MUST Fehler 82 „ISAM -
Table not found" auslösen.
#### Scenario: Tabelle wird beim ersten Öffnen angelegt
- **WHEN** eine Datenbank ohne die Tabelle `Kunden` mit `OPEN "db" FOR ISAM KundeTyp "Kunden" AS #1` geöffnet wird
- **THEN** entsteht die Tabelle mit den Feldern des Typs und die Dateinummer ist nutzbar
#### Scenario: Abweichendes Satzlayout
- **WHEN** eine bestehende Tabelle mit einem Typ geöffnet wird, dessen Felder nicht zu ihren Spalten passen
- **THEN** tritt Laufzeitfehler 88 auf
#### Scenario: Entfernte Tabelle
- **WHEN** `DELETETABLE` für eine nicht vorhandene Tabelle aufgerufen wird
- **THEN** tritt Laufzeitfehler 82 auf
### Requirement: Indexverwaltung
`CREATEINDEX` SHALL einen benannten Index über eine oder mehrere Spalten
anlegen, wahlweise eindeutig oder mehrdeutig und je Spalte auf- oder
absteigend geordnet. `DELETEINDEX` SHALL einen Index entfernen.
`SETINDEX` SHALL den aktiven Index einer Dateinummer setzen und damit die
Reihenfolge bestimmen, in der Sätze durchlaufen werden; `GETINDEX$` SHALL
den Namen des aktiven Index liefern. Ein unbekannter Indexname MUST
Laufzeitfehler 83 „ISAM - Index not found" auslösen, ein unbekannter
Spaltenname Fehler 84 „ISAM - Invalid column". Nach `SETINDEX` MUST der
Cursor als unpositioniert gelten.
#### Scenario: Aktiver Index bestimmt die Reihenfolge
- **WHEN** ein Index über die Spalte `Name` gesetzt ist und die Sätze mit `MOVEFIRST` und `MOVENEXT` durchlaufen werden
- **THEN** erscheinen sie in aufsteigender Reihenfolge der Spalte `Name`
#### Scenario: Unbekannter Index
- **WHEN** `SETINDEX #1, "GibtsNicht"` ausgeführt wird
- **THEN** tritt Laufzeitfehler 83 auf
#### Scenario: Index über unbekannte Spalte
- **WHEN** `CREATEINDEX` eine Spalte nennt, die der Satztyp nicht führt
- **THEN** tritt Laufzeitfehler 84 auf
### Requirement: Cursorbewegung in der Ordnung des aktiven Index
`MOVEFIRST`, `MOVELAST`, `MOVENEXT` und `MOVEPREVIOUS` SHALL den Cursor
in der Ordnung des aktiven Index bewegen. Läuft der Cursor über das Ende
hinaus, SHALL `EOF` für diese Dateinummer wahr werden; läuft er über den
Anfang hinaus, SHALL `BOF` wahr werden. In beiden Fällen SHALL der Cursor
als unpositioniert gelten. Eine Bewegung ohne aktiven Index SHALL der
Einfügereihenfolge folgen.
#### Scenario: Cursor läuft über das Ende hinaus
- **WHEN** nach dem letzten Satz `MOVENEXT` ausgeführt wird
- **THEN** liefert `EOF` für diese Dateinummer wahr
#### Scenario: Rückwärts über den Anfang hinaus
- **WHEN** auf dem ersten Satz `MOVEPREVIOUS` ausgeführt wird
- **THEN** liefert `BOF` für diese Dateinummer wahr
### Requirement: Schlüsselsuche
`SEEKEQ`, `SEEKGT` und `SEEKGE` SHALL den Cursor auf den ersten Satz
setzen, dessen Schlüssel im aktiven Index gleich, größer bzw.
größer-gleich dem angegebenen Wert ist. Die Zahl der Schlüsselwerte MUST
höchstens der Spaltenzahl des aktiven Index entsprechen; ein
mehrspaltiger Index SHALL mit einem Präfix der Spalten durchsucht werden
können. Findet die Suche keinen Satz, SHALL `EOF` wahr werden und der
Cursor unpositioniert bleiben. Eine Suche ohne aktiven Index MUST
Laufzeitfehler 83 auslösen.
#### Scenario: Suche nach größerem Schlüssel
- **WHEN** Sätze mit den Schlüsseln 10, 20 und 30 vorliegen und `SEEKGT #1, 20` ausgeführt wird
- **THEN** steht der Cursor auf dem Satz mit Schlüssel 30
#### Scenario: Erfolglose Suche
- **WHEN** `SEEKGT #1, 30` bei größtem Schlüssel 30 ausgeführt wird
- **THEN** liefert `EOF` wahr
#### Scenario: Suche über Präfix eines mehrspaltigen Index
- **WHEN** der aktive Index über `Nachname` und `Vorname` geht und mit nur einem Wert gesucht wird
- **THEN** positioniert die Suche auf dem ersten Satz mit passendem `Nachname`
### Requirement: Satzoperationen auf dem aktuellen Satz
`INSERT` SHALL einen Satz aus einer Variablen des Tabellentyps einfügen,
`RETRIEVE` den aktuellen Satz in eine solche Variable lesen, `UPDATE` ihn
aus einer solchen Variablen überschreiben und `DELETE` ihn entfernen.
`RETRIEVE`, `UPDATE` und `DELETE` ohne positionierten Cursor MUST
Laufzeitfehler 85 „ISAM - No current record" auslösen. Ein `INSERT` oder
`UPDATE`, das einen als eindeutig angelegten Index verletzen würde, MUST
Laufzeitfehler 86 „ISAM - Duplicate value for unique index" auslösen und
den Satzbestand unverändert lassen. Nach `DELETE` SHALL der Cursor als
unpositioniert gelten. Alle Indizes der Tabelle SHALL nach jeder
Satzoperation die neue Ordnung widerspiegeln.
#### Scenario: Einfügen und Zurücklesen
- **WHEN** ein Satz eingefügt, per `SEEKEQ` gesucht und mit `RETRIEVE` gelesen wird
- **THEN** stimmen alle Feldwerte mit den eingefügten überein
#### Scenario: Operation ohne aktuellen Satz
- **WHEN** nach `SETINDEX` ohne Positionierung `RETRIEVE #1, k` ausgeführt wird
- **THEN** tritt Laufzeitfehler 85 auf
#### Scenario: Verstoß gegen eindeutigen Index
- **WHEN** ein Satz eingefügt wird, dessen Schlüssel in einem eindeutigen Index bereits vorkommt
- **THEN** tritt Laufzeitfehler 86 auf und der Satzbestand bleibt unverändert
#### Scenario: Index folgt der Änderung
- **WHEN** die indizierte Spalte eines Satzes per `UPDATE` geändert wird
- **THEN** erscheint der Satz beim nächsten Durchlauf an seiner neuen Position in der Indexordnung
### Requirement: Transaktionen mit Rücknahme
`BEGINTRANS` SHALL eine Transaktion eröffnen, `COMMITTRANS` alle
Änderungen seit ihrem Beginn festschreiben und `ROLLBACK` sie
zurücknehmen. `SAVEPOINT` SHALL als **Funktion** einen Sicherungspunkt
setzen und dessen Kennung liefern; `ROLLBACK` mit einer solchen Kennung
SHALL auf diesen Punkt zurücksetzen, ohne die Transaktion aufzugeben.
`ROLLBACK ALL` SHALL alle Änderungen seit `BEGINTRANS` zurücknehmen und
die Transaktion beenden. Nach jeder Rücknahme MUST der Satzbestand
einschließlich aller Indizes dem Zustand zum Bezugspunkt entsprechen und
der Cursor als unpositioniert gelten.
#### Scenario: Rücknahme stellt den Bestand wieder her
- **WHEN** nach `BEGINTRANS` Sätze eingefügt und gelöscht werden und dann `ROLLBACK` folgt
- **THEN** entspricht der Bestand beim erneuten Durchlauf dem Zustand vor `BEGINTRANS`
#### Scenario: Rücknahme auf einen Sicherungspunkt
- **WHEN** innerhalb einer Transaktion `s = SAVEPOINT` ausgewertet, danach geändert und mit `ROLLBACK s` zurückgesetzt wird
- **THEN** bleiben die Änderungen vor dem Sicherungspunkt erhalten und die danach sind zurückgenommen
#### Scenario: ROLLBACK ALL beendet die Transaktion
- **WHEN** nach mehreren Sicherungspunkten `ROLLBACK ALL` ausgeführt wird
- **THEN** entspricht der Bestand dem Zustand vor `BEGINTRANS` und die Transaktion ist beendet
### Requirement: Vergleichsreihenfolge für Indizes
Textspalten SHALL in Indizes nach einer festgelegten, dokumentierten
Sortierordnung geordnet werden. Diese Ordnung MUST dieselbe sein, die der
Vergleichsoperator des Dialekts auf Strings anwendet, damit ein Programm
die Satzreihenfolge vorhersagen kann. Die gewählte Ordnung MUST in
docs/sprachreferenz.md festgehalten sein, einschließlich der Behandlung
von Groß- und Kleinschreibung und von Zeichen jenseits von ASCII.
#### Scenario: Indexordnung entspricht dem Stringvergleich
- **WHEN** zwei Texte als Schlüssel indiziert und dieselben Werte mit `<` verglichen werden
- **THEN** entspricht ihre Reihenfolge im Index dem Ergebnis des Vergleichs
### Requirement: Pufferverwaltung mit benanntem Fehler
`SETMEM` SHALL als **Funktion** die für ISAM verfügbare Puffergröße um
den übergebenen Betrag ändern und die danach verfügbare Größe liefern.
Reicht
der verfügbare Puffer für eine Operation nicht aus, MUST Laufzeitfehler 89
„ISAM - Insufficient ISAM buffers" auftreten statt eines generischen
Fehlers oder eines stillen Abbruchs. Weicht die Bedeutung von `SETMEM`
mangels DOS-Speichermodell vom Vorbild ab, MUST die Abweichung in
docs/sprachreferenz.md dokumentiert sein.
#### Scenario: Pufferfehler wird benannt
- **WHEN** eine Operation den verfügbaren ISAM-Puffer überschreitet
- **THEN** tritt Laufzeitfehler 89 auf
### Requirement: Eigenes Datenbankdateiformat
Die Datenbankdatei SHALL ein eigenes, in docs/dateiformate.md
beschriebenes Format verwenden und ist bewusst nicht binärkompatibel zu
Datenbankdateien des Vorbilds — dieselbe Linie wie bei den
UTF-32-Festtextfeldern. Das Format SHALL eine Formatversion tragen; eine
unbekannte Version MUST mit einem benannten Fehler abgewiesen werden
statt fehlinterpretiert zu werden. Eine strukturell beschädigte Datei
MUST Laufzeitfehler 88 auslösen.
#### Scenario: Unbekannte Formatversion
- **WHEN** eine Datenbankdatei mit einer höheren Formatversion geöffnet wird
- **THEN** wird sie mit einem benannten Fehler abgewiesen und nicht gelesen
#### Scenario: Beschädigte Datei
- **WHEN** eine strukturell beschädigte Datenbankdatei geöffnet wird
- **THEN** tritt Laufzeitfehler 88 auf

View File

@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: ISAM-Anweisungen und -Funktionen in Grammatik und Signaturprüfung
Der Parser SHALL die ISAM-Anweisungen und -Funktionen des Vorbilds als
eigene Konstrukte erkennen, und die Semantik SHALL für jede eine Signatur
führen (Argumentanzahl und -typen). Der maßgebliche Umfang ergibt sich
aus dem Sprachinventar; er umfasst mindestens `CREATEINDEX`,
`DELETEINDEX`, `SETINDEX`, `GETINDEX$`, `INSERT`, `RETRIEVE`, `UPDATE`,
`DELETE`, `DELETETABLE`, `MOVEFIRST`, `MOVELAST`, `MOVENEXT`,
`MOVEPREVIOUS`, `SEEKEQ`, `SEEKGT`, `SEEKGE`, `BEGINTRANS`,
`COMMITTRANS`, `ROLLBACK`, `ROLLBACK ALL`, `SAVEPOINT`, `SETMEM` und
`BOF`. Eine fehlerhafte Verwendung MUST eine Diagnose erzeugen, die
das Element namentlich nennt; ein ISAM-Element MUST NOT als unbekannter
Bezeichner oder mit einem generischen Syntaxfehler abgewiesen werden
(Guiding Principle).
#### Scenario: ISAM-Anweisung wird als solche erkannt
- **WHEN** ein Modul `SETINDEX #1, "NachName"` enthält
- **THEN** entsteht keine Diagnose „unbekannter Bezeichner" und die Anweisung wird als ISAM-Anweisung erfasst
#### Scenario: Falsche Argumentanzahl wird namentlich gemeldet
- **WHEN** ein Modul `SEEKGT #1` ohne Schlüsselwert enthält
- **THEN** nennt die Diagnose `SEEKGT` und die erwartete Argumentanzahl
#### Scenario: Typprüfung des Satzarguments
- **WHEN** `RETRIEVE #1, x$` mit einem String statt einer Variablen des Tabellentyps geprüft wird
- **THEN** wird „Type mismatch" mit Quelltextposition gemeldet

View File

@@ -0,0 +1,78 @@
## 1. Umfang festlegen
- [ ] 1.1 ISAM-Abschnitt der Original-Hilfe auswerten und alle Anweisungen, Funktionen und Klauseln gegen die ISAM-Einträge in `docs/inventar.md` abgleichen; verifiziert dadurch, dass jedes gefundene Element im Inventar mit Status `offen` steht und im Change notiert ist, falls es über die Aufzählung im Proposal hinausgeht
- [ ] 1.2 Argumentformen je Element aus der Original-Hilfe festhalten (insbesondere die Schreibweise der Spaltenliste bei `CREATEINDEX`); verifiziert durch die Notiz im Change, die jede Signatur mit ihrer Quelle nennt
## 2. Frontend: Grammatik und Signaturen
- [ ] 2.1 ISAM-Anweisungen in die Parser-Grammatik aufnehmen; verifiziert durch Frontend-Test, dass `SETINDEX #1, "Name"` diagnose-frei parst
- [ ] 2.2 Signaturen (Argumentanzahl und -typen) für alle ISAM-Elemente in der Semantik führen; verifiziert durch Frontend-Test, dass `SEEKGT #1` ohne Schlüsselwert eine Diagnose erzeugt, die `SEEKGT` namentlich nennt
- [ ] 2.3 Satzargumente gegen den Tabellentyp prüfen; verifiziert durch Frontend-Test, dass `RETRIEVE #1, x$` „Type mismatch" meldet
- [ ] 2.4 `Unsupported`-Absenkung von `OPEN … FOR ISAM` entfernen und durch die reguläre Absenkung ersetzen; verifiziert dadurch, dass ein ISAM-Programm nicht mehr mit Laufzeitfehler 73 endet
- [ ] 2.5 Prüfen, dass kein ISAM-Element mehr als unbekannter Bezeichner behandelt wird; verifiziert durch Frontend-Test über ein Programm, das jedes Element einmal verwendet, ohne Diagnose „unbekannter Bezeichner"
## 3. Speicherschicht
- [ ] 3.1 `redb` als Abhängigkeit aufnehmen, auf das ISAM-Modul in `tb-runtime` beschränkt; verifiziert dadurch, dass `cargo build -p tb-runtime` durchläuft
- [ ] 3.2 Datenbankdatei mit Formatversion anlegen und öffnen; verifiziert durch Test, dass eine Datei mit höherer Formatversion mit benanntem Fehler abgewiesen und eine strukturell beschädigte Datei mit Laufzeitfehler 88 abgelehnt wird
- [ ] 3.3 Satztabelle `satz-id → Satzbytes` mit monoton vergebenen, nie wiederverwendeten IDs; verifiziert durch Test, dass eine nach `DELETE` neu vergebene ID nicht vorkommt
- [ ] 3.4 Satzserialisierung auf den Recordpuffern und der UTF-32-Festtextkodierung aus `datei-eio` aufsetzen; verifiziert durch Roundtrip-Test über alle Feldtypen des Satztyps
- [ ] 3.5 Ordnungserhaltende Schlüsselkodierung je Spaltentyp (Zahlen vorzeichenrichtig fester Breite, Text nach Codepoints, mehrspaltig verkettet mit eindeutigem Trenner, absteigend durch Byte-Invertierung); verifiziert durch Eigenschaftstest über zufällige Wertepaare, dass der Byte-Vergleich der Kodierung dem fachlichen Vergleich entspricht
- [ ] 3.6 Sortierordnung (Unicode-Codepoints, Groß-/Kleinschreibung unterschieden) in docs/sprachreferenz.md festhalten; verifiziert durch den Abschnittseintrag
- [ ] 3.7 Datenbankdateiformat in docs/dateiformate.md beschreiben inklusive Formatversion und bewusster Inkompatibilität zum Vorbild; verifiziert durch den Abschnittseintrag
## 4. Tabellen
- [ ] 4.1 `OPEN … FOR ISAM typname tabellenname AS #n` an die Dateitabelle binden, Satzlayout aus dem benannten Typ ableiten; verifiziert durch Korpustest, dass die Dateinummer danach nutzbar ist
- [ ] 4.2 Tabelle beim ersten Öffnen anlegen; verifiziert durch Korpustest gegen eine Datenbank ohne diese Tabelle
- [ ] 4.3 Layoutabgleich bei bestehender Tabelle mit Fehler 88 bei Abweichung; verifiziert durch Korpustest mit unpassendem Typ
- [ ] 4.4 Ungültige Datenbank- und Tabellennamen mit Fehler 81 abweisen; verifiziert durch Korpustest
- [ ] 4.5 `CLOSE` löst die Bindung und schreibt ausstehende Änderungen fest; verifiziert durch Korpustest, dass die Sätze nach erneutem Öffnen vorhanden sind
- [ ] 4.6 `DELETETABLE` entfernt Tabelle samt Indizes, Fehler 82 bei fehlender Tabelle; verifiziert durch Korpustest für beide Fälle
## 5. Indizes
- [ ] 5.1 Indextabellen `Schlüsselbytes → satz-id` je Index anlegen; verifiziert durch Test, dass die Einträge in Schlüsselreihenfolge stehen
- [ ] 5.2 `CREATEINDEX` mit einer und mit mehreren Spalten, eindeutig und mehrdeutig, auf- und absteigend; verifiziert durch Korpustest je Variante
- [ ] 5.3 `DELETEINDEX`; verifiziert durch Korpustest, dass der Index danach nicht mehr gesetzt werden kann
- [ ] 5.4 `SETINDEX` setzt den aktiven Index und den Cursor auf unpositioniert, `GETINDEX$` liefert ihn zurück; verifiziert durch Korpustest
- [ ] 5.5 Fehler 83 bei unbekanntem Index und Fehler 84 bei unbekannter Spalte; verifiziert durch Korpustest für beide Fälle
## 6. Cursor
- [ ] 6.1 Cursor je Dateinummer als Wert führen (aktiver Index, letzte Satz-ID, Kennzeichen unpositioniert); verifiziert durch Unit-Test, dass der Cursor eine Satzänderung überlebt
- [ ] 6.2 `MOVEFIRST`, `MOVELAST`, `MOVENEXT`, `MOVEPREVIOUS` in der Ordnung des aktiven Index; verifiziert durch Korpustest, dass ein Durchlauf die Sätze in Indexreihenfolge liefert
- [ ] 6.3 Bewegung ohne aktiven Index folgt der Einfügereihenfolge; verifiziert durch Korpustest
- [ ] 6.4 `EOF` am Ende und `BOF` am Anfang, Cursor danach jeweils unpositioniert; verifiziert durch je einen Korpustest für `MOVENEXT` am Ende und `MOVEPREVIOUS` am Anfang
- [ ] 6.5 `SEEKEQ`, `SEEKGT`, `SEEKGE` inklusive Präfixsuche über einen mehrspaltigen Index; verifiziert durch Korpustest mit den Schlüsseln 10, 20, 30 und einer Präfixsuche
- [ ] 6.6 Erfolglose Suche setzt `EOF` und lässt den Cursor unpositioniert; verifiziert durch Korpustest
- [ ] 6.7 Suche ohne aktiven Index löst Fehler 83 aus; verifiziert durch Korpustest
## 7. Satzoperationen
- [ ] 7.1 `INSERT` und `RETRIEVE`; verifiziert durch Korpustest, dass ein eingefügter Satz per `SEEKEQ` gefunden und feldgleich gelesen wird
- [ ] 7.2 `UPDATE` und `DELETE`, Cursor nach `DELETE` unpositioniert; verifiziert durch Korpustest
- [ ] 7.3 Fehler 85 bei `RETRIEVE`/`UPDATE`/`DELETE` ohne positionierten Cursor; verifiziert durch je einen Korpustest
- [ ] 7.4 Eindeutigkeitsprüfung mit Fehler 86 und unverändertem Satzbestand; verifiziert durch Korpustest, der nach dem Fehler den Bestand nachzählt
- [ ] 7.5 Alle Indizes nach jeder Satzoperation nachführen; verifiziert durch Korpustest, dass ein Satz nach Änderung der indizierten Spalte an neuer Position im Durchlauf erscheint
## 8. Transaktionen
- [ ] 8.1 `BEGINTRANS`/`COMMITTRANS` auf einer Schreibtransaktion; verifiziert durch Korpustest, dass die Änderungen nach erneutem Öffnen vorhanden sind
- [ ] 8.2 Rücknahmeprotokoll der Änderungen innerhalb einer Transaktion; verifiziert durch Unit-Test, dass jede Satzoperation einen Protokolleintrag erzeugt
- [ ] 8.3 `ROLLBACK ALL` stellt Sätze und Indizes wieder her, beendet die Transaktion und setzt den Cursor auf unpositioniert; verifiziert durch Korpustest mit Einfügen, Löschen und anschließendem Durchlauf
- [ ] 8.4 `SAVEPOINT` als Funktion (liefert Kennung) und `ROLLBACK kennung`; verifiziert durch Korpustest, dass Änderungen vor dem Punkt erhalten bleiben
## 9. Übrige Elemente
- [ ] 9.2 `SETMEM` als Funktion mit echter Puffergrenze und Fehler 89 beim Überschreiten; verifiziert durch Korpustest, der die Grenze niedrig setzt und den Fehler auslöst
- [ ] 9.3 Vorgabewert und Bedeutung von `SETMEM` sowie die Abweichung vom DOS-Speichermodell in docs/sprachreferenz.md festhalten; verifiziert durch den Abschnittseintrag
- [ ] 9.4 Indexordnung gegen den Stringvergleich des Dialekts prüfen; verifiziert durch Korpustest, dass Indexreihenfolge und `<`-Vergleich übereinstimmen
## 10. Abschluss
- [ ] 10.1 ISAM-Einträge in `docs/inventar.md` auf `implementiert` setzen; verifiziert dadurch, dass der Abgleichstest aus `phase-3-runtime-bildschirm` grün bleibt
- [ ] 10.2 `docs/bibliothek.md` um die ISAM-Elemente erweitern; verifiziert dadurch, dass jedes umgesetzte Element dort einen Eintrag hat
- [ ] 10.3 Korpus-ISAM-Programme in einem temporären Arbeitsverzeichnis ausführen; verifiziert dadurch, dass nach `cargo test` `git status` sauber ist
- [ ] 10.4 Vollständige Suite auf allen Zielplattformen ausführen; verifiziert dadurch, dass `cargo test --workspace` grün ist
- [ ] 10.5 PLAN.md Phase 3 als abgeschlossen markieren; verifiziert durch den aktualisierten Planabschnitt

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-04

View File

@@ -0,0 +1,180 @@
## Context
Siehe proposal.md — Why. Der Ist-Zustand ist eng umrissen:
- `tb-runtime::datetime` rechnet vollständig selbst: Kalenderumrechnung
(Howard Hinnant), serielle Datumswerte ab dem 30.12.1899, `WEEKDAY`,
Textformen. Diese Rechnung ist getestet und bleibt.
- Der einzige Punkt, an dem die Zeitzone eine Rolle spielt, ist
`jetzt_sekunden()` — Sekunden seit der Unix-Epoche. Alles Weitere
(`jetzt_serial`, `DATE$`, `TIME$`, `TIMER`, `NOW`) baut darauf auf.
- `tb-runtime` hat bislang eine einzige Abhängigkeit mit fachlichem
Gewicht: `unicode-width`.
Damit ist die Aufgabe klein: **eine Zahl** — der UTC-Versatz zu einem
Zeitpunkt — kommt künftig von außen.
## Goals / Non-Goals
**Goals:**
- Zeitfunktionen liefern das, was die Uhr des Rechners zeigt.
- Sommerzeit wird zum jeweiligen Zeitpunkt berücksichtigt.
- Die Berührungsfläche zur Bibliothek bleibt eine Funktion, damit sie
austauschbar ist und die getestete Kalenderrechnung unberührt bleibt.
- Zeitverhalten bleibt automatisiert prüfbar, ohne von der Zone des
Testrechners abzuhängen.
**Non-Goals:**
- Zeitzonenbehaftete Datentypen im Dialekt — das Vorbild kennt nur
Wanduhrzeit.
- Eine Sprachanbindung an die Bibliothek (Zonennamen, Umrechnung zwischen
Zonen). Das ist der Stufe-2-Eintrag im PLAN, nicht dieser Change.
- Historische Zonendaten jenseits dessen, was die Systemdatenbank
hergibt.
## Decisions
### D1: `jiff` als Zeitzonenbibliothek
*Gewählt:* `jiff` — sie liest die Zeitzonendatenbank des Systems und
liefert den Versatz zu einem Zeitpunkt ohne Einschränkung.
*Alternativen:*
| Bibliothek | Bewertung |
|---|---|
| `jiff` | Für genau diese Frage gebaut; Systemzonendatenbank; keine Sicherheitsauflage. **Gewählt.** |
| `chrono` (≥ 0.4.20) | Gleichwertig sicher, seit 0.4.20 ohne `localtime_r` (Zonenermittlung über `iana-time-zone`, Datenbank über einen `tz-rs`-Zweig). Etablierter, bringt aber Serde-/Ökosystem-Anbindung mit, die hier niemand braucht. Tragfähige zweite Wahl. |
| `time` | Der lokale Versatz ist auf Unix nur nach ausdrücklichem Opt-in in möglicherweise undefiniertes Verhalten verfügbar (`set_soundness`). **Verworfen** — das ist kein Preis für eine Uhrzeit. |
| Eigenbau (`iana-time-zone` + TZif-Parser) | Hieße, die Zonendatenbank selbst zu lesen und dauerhaft nachzupflegen. Genau das, wofür man eine Bibliothek nimmt. **Verworfen.** |
*Warum die Wahl leicht revidierbar bleibt:* Die Bibliothek wird an genau
einer Stelle aufgerufen (D2). Ein Wechsel zu `chrono` wäre ein Austausch
dieser einen Funktion.
### D2: Berührungsfläche ist eine Funktion
```
tb-runtime::datetime
utc_versatz(zeitpunkt: i64) -> Option<i32> // Sekunden, None = Zone unbekannt
└── einziger Aufruf der Bibliothek
jetzt_sekunden() ─┐
jetzt_serial() ├── rechnen weiter selbst
civil_from_days() │
weekday() … ─┘
```
Die Kalenderrechnung sieht die Bibliothek nie. Sie bekommt nach wie vor
Sekunden seit der Epoche — nur eben um den Versatz verschobene.
*Warum nicht die Bibliothek durchreichen:* Ihre Datums-/Zeittypen wären
ein zweites Kalendermodell neben dem des Dialekts (der bei 1899 zählt,
`WEEKDAY` = 1 für Sonntag, CURRENCY-nahe Serienwerte). Zwei Modelle
nebeneinander wären eine dauerhafte Fehlerquelle; die vorhandene Rechnung
ist getestet und bleibt maßgeblich.
### D3: Der Versatz gilt je Zeitpunkt, nicht je Programmlauf
`utc_versatz` bekommt den Zeitpunkt als Argument. Ein Programm, das über
eine Zeitumstellung hinweg läuft, folgt ihr; ein Programm, das mit
`DATE$ =` in einen anderen Teil des Jahres springt, bekommt den dort
gültigen Versatz.
*Preis:* Eine Zonenabfrage je Zeitabfrage. Bei den Aufrufmengen eines
Terminalprogramms belanglos; die Bibliothek hält die Zonendaten selbst
vor.
### D4: Zonenquelle ist umschaltbar — damit Tests nicht von der Maschine abhängen
```rust
pub enum Zeitzone {
/// Zone des Rechners (Vorgabe).
System,
/// Fester Versatz in Sekunden — für Tests und den Korpus.
Fest(i32),
/// Zone nicht ermittelbar; es gilt UTC.
Unbekannt,
}
```
`RtState` hält eine `Zeitzone`. Die Vorgabe ist `System`; schlägt die
Ermittlung fehl, wird daraus `Unbekannt`. Tests und Korpusprogramme
setzen `Fest(n)`.
*Warum nicht `TZ` in den Tests setzen:* Umgebungsvariablen sind
prozessweit, Tests laufen nebenläufig, und `set_var` ist seit Rust 2024
`unsafe`. Ein Zustand im `RtState` ist der offensichtliche Weg — er ist
genau so weit sichtbar wie nötig.
*Korpus:* Das Harness bekommt eine Kopfzeilen-Direktive analog zu
`' tb-screen:` und `' tb-tempdir`:
```text
' tb-tz: +02:00
```
Damit bleibt die Sollausgabe eines Zeitprogramms unabhängig davon, wo der
Testrechner steht.
### D5: Rückfallebene ist sichtbar, nicht still
Ohne ermittelbare Zone gilt UTC — ein Abbruch wäre unverhältnismäßig, ein
stiller Wechsel aber ein Verstoß gegen den Guiding Principle. Die Laufzeit
führt den Zustand mit, und der Zustand ist abfragbar; die Sprachreferenz
beschreibt ihn.
*Wie ein Programm es erfährt:* Über die vorhandene Systemabfrage
`ENVIRON$` wäre es geraten. Stattdessen bekommt die Laufzeit einen
benannten Zustand, den der Runner beim Start auf die Fehlerausgabe
schreibt, wenn er `Unbekannt` ist — einmal, nicht je Abfrage. Ein
Programm, das es programmatisch braucht, bekommt es in Stufe 2 über die
Bibliotheksanbindung.
### D6: Reihenfolge der Umsetzung
```
1. Abhaengigkeit + utc_versatz() -> isoliert testbar
2. jetzt_sekunden/jetzt_serial umstellen -> DATE$/TIME$/TIMER/NOW folgen
3. Zeitzone im RtState + Korpusdirektive -> Tests werden maschinenunabhaengig
4. Doku: Abweichung zurueckziehen, PLAN Stufe 2
```
## Risks / Trade-offs
- **Erste schwere Abhängigkeit der Laufzeit** → bewusst entschieden
(2026-09-04). Die Berührungsfläche ist eine Funktion (D2); ein Wechsel
der Bibliothek bleibt ein lokaler Eingriff.
- **Zeitabhängige Tests werden flatterig** → deshalb D4: kein Test und
kein Korpusprogramm fragt die echte Zone ab. Was die Systemzone
betrifft, wird über `Fest(n)` und `Unbekannt` geprüft.
- **Zeitumstellung erzeugt mehrdeutige Wanduhrzeiten** (Rückstellung) und
nicht existierende (Vorstellung) → betrifft nur die Richtung
Wanduhr → Zeitpunkt. Die Zeitfunktionen dieses Dialekts gehen die
andere Richtung (Zeitpunkt → Wanduhr) und sind davon nicht betroffen.
`DATEVALUE`/`TIMEVALUE` lesen Wanduhrzeit und rechnen gar nicht um.
`DATE$ =`/`TIME$ =` setzen einen Versatz gegen die aktuelle Wanduhrzeit
— auch das ist eindeutig.
- **Die Systemzonendatenbank kann fehlen** (schlanke Container) → D5.
- **Bauzeit und Binärgröße wachsen** → für ein Werkzeug, dessen Programme
Uhrzeiten anzeigen, ist eine falsche Uhrzeit der größere Preis.
## Migration Plan
Rein verhaltensändernd, ohne Formatbruch: `.tbc`-Dateien, Korpus-Golden-
Files und Schnittstellen bleiben unberührt. Das einzige beobachtbare
Verhalten, das sich ändert, ist der Wert von `DATE$`, `TIME$`, `TIMER`
und `NOW` — und zwar zum Richtigen hin.
Rücknahme: Ein Zurücksetzen auf UTC wäre `Zeitzone::Unbekannt` als
Vorgabe; die Abhängigkeit bliebe ungenutzt.
## Open Questions
- Ob die Bibliotheksanbindung in Stufe 2 (Zonennamen, Umrechnung zwischen
Zonen als BASIC-Funktionen) auf derselben Bibliothek aufsetzt oder eine
eigene Wahl trifft, entscheidet Stufe 2. Für diesen Change ohne
Wirkung — hier wird nur der Versatz gebraucht.

View File

@@ -0,0 +1,76 @@
# Ortszeit statt UTC
## Why
`DATE$`, `TIME$`, `TIMER` und `NOW` liefern heute koordinierte Weltzeit.
Das ist keine Rekonstruktion des Vorbilds, sondern eine Notlösung: die
Standardbibliothek von Rust kennt keine Zeitzonen, und in
`phase-3-runtime-bildschirm` sollte die erste schwere Abhängigkeit der
Laufzeit nicht nebenbei entstehen. Die Abweichung steht in
docs/sprachreferenz.md.
Für ein BASIC zum Bauen von Terminal-Werkzeugen ist das ein echter
Mangel: Ein Programm, das eine Uhr anzeigt, ein Protokoll schreibt oder
einen Termin prüft, zeigt schlicht die falsche Zeit — je nach Zone um
Stunden daneben. Das Vorbild lief auf der Ortszeit des Rechners; jedes
übernommene Programm rechnet damit.
Der Projektinhaber hat am 2026-09-04 entschieden, dafür eine Bibliothek
in die Laufzeit aufzunehmen. Dieser Change zieht die Abweichung zurück.
## What Changes
- **Zeitfunktionen liefern Ortszeit**: `DATE$`, `TIME$`, `TIMER` und
`NOW` rechnen in der Zeitzone des Rechners statt in UTC. `TIMER` zählt
Sekunden seit der **lokalen** Mitternacht.
- **Sommerzeit wird berücksichtigt**: Der Versatz wird für den jeweiligen
Zeitpunkt bestimmt, nicht einmalig beim Programmstart — ein Programm,
das über einen Zeitumstellungspunkt hinweg läuft, folgt ihm.
- **Neue Abhängigkeit in `tb-runtime`**: eine Zeitzonenbibliothek, deren
Berührungsfläche bewusst auf eine einzige Frage beschränkt bleibt —
„welcher UTC-Versatz gilt zu diesem Zeitpunkt?". Die Kalenderrechnung
(serielle Datumswerte, `WEEKDAY`, Zerlegung) bleibt die vorhandene,
getestete Eigenimplementierung in `tb-runtime::datetime`.
- **Rückfallebene**: Lässt sich die Zeitzone des Rechners nicht
ermitteln, gilt UTC. Das Programm läuft weiter; die Rückfallebene ist
über eine Funktion der Laufzeit erkennbar und dokumentiert.
- **`DATE$ =` / `TIME$ =` bleiben ein Programmversatz** (sie stellen
nicht die Systemuhr) — sie wirken künftig auf die Ortszeit.
- **Abweichung zurückziehen**: Der Eintrag „Uhr läuft in UTC" entfällt
aus docs/sprachreferenz.md; an seine Stelle tritt die Beschreibung der
Zonenermittlung samt Rückfallebene.
- **Stufe 2 im PLAN**: Die Zeitzonenbibliothek wird in den Ideenspeicher
„Runtime-Bibliotheken als BASIC-Bibliotheken" aufgenommen — neben
crossterm —, damit BASIC-Programme später direkt auf Zonen zugreifen
können (Zonennamen, Versatz zu einem Zeitpunkt, Umrechnung zwischen
Zonen).
**Non-Goals:** Zeitzonenbehaftete Datentypen im Dialekt (das Vorbild
kennt nur lokale Wanduhrzeit); eine Sprachanbindung an die Bibliothek
(das ist der Stufe-2-Eintrag, nicht dieser Change); Änderungen an der
Zählweise serieller Datumswerte oder an `DATEVALUE`/`TIMEVALUE`, die
Wanduhrzeit lesen und daher keine Umrechnung brauchen.
## Capabilities
### Modified Capabilities
- `konsolen-basisbibliothek`: Die Datums- und Zeitfunktionen SHALL die
Ortszeit des Rechners liefern statt UTC, einschließlich Sommerzeit und
einer dokumentierten Rückfallebene.
## Impact
- `crates/tb-runtime/src/datetime.rs`: Versatzermittlung über die neue
Bibliothek; `jetzt_serial`/`jetzt_sekunden` liefern Ortszeit. Die
Kalenderrechnung bleibt unverändert.
- `crates/tb-runtime/src/builtins.rs`: `DATE$`, `TIME$`, `TIMER`, `NOW`
rechnen über die neue Funktion.
- `Cargo.toml` (Workspace) und `crates/tb-runtime/Cargo.toml`: neue
Abhängigkeit.
- `tests/compat`: Korpusprogramm für die Zeitfunktionen, das ohne
Abhängigkeit von der Zone des Testrechners prüfbar bleibt.
- `docs/sprachreferenz.md`: Abweichung „Uhr läuft in UTC" entfällt;
Zonenermittlung und Rückfallebene beschrieben.
- `docs/bibliothek.md`: Abschnitt Datum/Zeit fortgeschrieben.
- `PLAN.md`: Stufe-2-Eintrag um die Bibliothek erweitert.

View File

@@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Zeitfunktionen liefern Ortszeit
`DATE$`, `TIME$`, `TIMER` und `NOW` SHALL den Zeitpunkt in der Zeitzone
des Rechners liefern, nicht in koordinierter Weltzeit. `TIMER` SHALL die
Sekunden seit der **lokalen** Mitternacht zählen. Der maßgebliche Versatz
SHALL für den jeweils abgefragten Zeitpunkt bestimmt werden, nicht einmal
beim Programmstart — ein Programm, das über eine Zeitumstellung hinweg
läuft, folgt ihr. Die Zerlegungsfunktionen (`DAY`, `MONTH`, `YEAR`,
`WEEKDAY`, `HOUR`, `MINUTE`, `SECOND`) arbeiten unverändert auf seriellen
Werten und rechnen selbst nicht um.
#### Scenario: Datum und Uhrzeit stimmen mit der Systemuhr überein
- **WHEN** ein Programm `DATE$` und `TIME$` abfragt
- **THEN** entsprechen sie der Ortszeit des Rechners zum Abfragezeitpunkt
#### Scenario: TIMER zählt ab lokaler Mitternacht
- **WHEN** `TIMER` abgefragt wird
- **THEN** entspricht der Wert den Sekunden seit der letzten lokalen Mitternacht, nicht seit der UTC-Mitternacht
#### Scenario: NOW und die Zerlegungsfunktionen passen zusammen
- **WHEN** `n# = NOW` gebildet und `HOUR(n#)` ausgewertet wird
- **THEN** liefert `HOUR(n#)` dieselbe Stunde, die auch `TIME$` nennt
#### Scenario: Sommerzeit wirkt zum jeweiligen Zeitpunkt
- **WHEN** in einer Zone mit Sommerzeit ein Zeitpunkt vor und einer nach der Umstellung abgefragt wird
- **THEN** unterscheiden sich die beiden Versätze um die Umstellungsspanne
### Requirement: Zonenermittlung mit Rückfallebene
Die Laufzeit SHALL die Zeitzone des Rechners über die
Betriebssystemeinstellung bestimmen. Lässt sie sich nicht ermitteln,
SHALL UTC gelten und das Programm ohne Fehler weiterlaufen. Ob die
Rückfallebene greift, MUST für ein Programm erkennbar sein und in
docs/sprachreferenz.md beschrieben stehen; ein stiller Wechsel auf UTC
MUST NOT vorkommen.
#### Scenario: Zone nicht ermittelbar
- **WHEN** die Zeitzone des Rechners nicht bestimmt werden kann
- **THEN** rechnen die Zeitfunktionen in UTC und das Programm läuft ohne Fehler weiter
#### Scenario: Rückfallebene ist erkennbar
- **WHEN** ein Programm den Zonenzustand abfragt
- **THEN** unterscheidet die Antwort erkennbar zwischen ermittelter Zone und Rückfall auf UTC
### Requirement: Programmversatz wirkt auf die Ortszeit
`DATE$ = text$` und `TIME$ = text$` SHALL weiterhin nicht die Systemuhr
stellen, sondern einen programmeigenen Versatz setzen. Dieser Versatz
SHALL auf die **Ortszeit** wirken: nach `TIME$ = "10:20:30"` nennt
`TIME$` diese Uhrzeit, und `NOW`, `HOUR` und `TIMER` stimmen damit
überein.
#### Scenario: Gesetzte Uhrzeit wird zurückgelesen
- **WHEN** `TIME$ = "10:20:30"` ausgeführt und danach `TIME$` abgefragt wird
- **THEN** nennt `TIME$` `10:20:30`
#### Scenario: Versatz wirkt auf alle Zeitfunktionen
- **WHEN** `DATE$ = "01-02-1993"` gesetzt und danach `YEAR(NOW)` ausgewertet wird
- **THEN** liefert `YEAR(NOW)` 1993

View File

@@ -0,0 +1,35 @@
## 1. Versatzermittlung
- [ ] 1.1 `jiff` als Workspace-Abhängigkeit aufnehmen und in `tb-runtime` einbinden; verifiziert dadurch, dass `cargo build -p tb-runtime` durchläuft und `cargo tree -p tb-runtime` die Bibliothek genau einmal zeigt
- [ ] 1.2 `datetime::utc_versatz(zeitpunkt: i64) -> Option<i32>` als einzigen Aufrufpunkt der Bibliothek schreiben (Sekunden Versatz, `None` = Zone nicht ermittelbar); verifiziert durch Unit-Test, dass der Versatz für einen festen Zeitpunkt im gültigen Bereich 50400…50400 liegt
- [ ] 1.3 Sommerzeit prüfen: für dieselbe Zone einen Zeitpunkt im Januar und einen im Juli abfragen; verifiziert durch Unit-Test, dass sich die Versätze in einer Zone mit Sommerzeit unterscheiden und in einer ohne gleich bleiben
- [ ] 1.4 Sicherstellen, dass die Kalenderrechnung unberührt bleibt; verifiziert dadurch, dass `datetime.rs` außerhalb von `utc_versatz` keinen Bibliotheksaufruf enthält und die bestehenden `datetime`-Tests unverändert grün sind
## 2. Zonenzustand in der Laufzeit
- [ ] 2.1 `Zeitzone { System, Fest(i32), Unbekannt }` einführen und in `RtState` halten; Vorgabe `System`, bei fehlgeschlagener Ermittlung `Unbekannt`; verifiziert durch Unit-Test je Variante
- [ ] 2.2 `jetzt_sekunden`/`jetzt_serial` auf den Zonenzustand umstellen; verifiziert durch Unit-Test, dass `Fest(3600)` genau eine Stunde gegenüber `Unbekannt` verschiebt
- [ ] 2.3 `DATE$`, `TIME$`, `TIMER` und `NOW` über den Zonenzustand rechnen; verifiziert durch Test, dass `HOUR(NOW)` und die Stunde aus `TIME$` bei `Fest(n)` übereinstimmen
- [ ] 2.4 `TIMER` zählt ab lokaler Mitternacht; verifiziert durch Test, dass sich `TIMER` bei `Fest(3600)` gegenüber `Unbekannt` um 3600 verschiebt (modulo Tageslänge)
- [ ] 2.5 `DATE$ =` / `TIME$ =` wirken auf die Ortszeit; verifiziert durch Test, dass `TIME$` nach `TIME$ = "10:20:30"` genau diesen Wert nennt und `HOUR(NOW)` 10 liefert
## 3. Rückfallebene
- [ ] 3.1 Bei nicht ermittelbarer Zone auf UTC zurückfallen, ohne Fehler; verifiziert durch Test mit `Zeitzone::Unbekannt`, dass alle Zeitfunktionen Werte liefern
- [ ] 3.2 Den Rückfall beim Programmstart einmalig auf die Fehlerausgabe melden (nicht je Abfrage); verifiziert dadurch, dass `tbc run` bei `Unbekannt` genau eine Meldung schreibt und die Standardausgabe unberührt bleibt
- [ ] 3.3 Rückfallebene in docs/sprachreferenz.md beschreiben; verifiziert durch den Abschnittseintrag
## 4. Prüfbarkeit ohne Abhängigkeit von der Testmaschine
- [ ] 4.1 Kopfzeilen-Direktive `' tb-tz: ±HH:MM` im Korpus-Harness auswerten und als `Zeitzone::Fest` setzen; verifiziert dadurch, dass ein Korpusprogramm ohne die Direktive weiterhin läuft und eines mit ihr den gesetzten Versatz verwendet
- [ ] 4.2 Korpusprogramm `zeitzone.bas` mit festem Versatz: `DATE$`/`TIME$`/`TIMER`/`NOW` gegen eine per `DATE$ =`/`TIME$ =` gesetzte Uhrzeit prüfen; verifiziert dadurch, dass die Sollausgabe unabhängig von der Zone des Testrechners stimmt
- [ ] 4.3 Bestehendes `datumzeit.bas` auf die Direktive umstellen, damit es auch nach der Umstellung deterministisch bleibt; verifiziert dadurch, dass seine Sollausgabe unverändert bleibt
## 5. Abschluss
- [ ] 5.1 Abweichung „Uhr läuft in UTC" aus docs/sprachreferenz.md entfernen und durch die Beschreibung der Zonenermittlung ersetzen; verifiziert dadurch, dass der Abweichungspunkt nicht mehr auftaucht
- [ ] 5.2 `docs/bibliothek.md`, Abschnitt Datum/Zeit, fortschreiben; verifiziert durch den geänderten Abschnitt
- [ ] 5.3 PLAN.md Stufe 2, Eintrag „Runtime-Bibliotheken als BASIC-Bibliotheken", um die Zeitzonenbibliothek erweitern (Zonennamen, Versatz zu einem Zeitpunkt, Umrechnung zwischen Zonen) — neben crossterm; verifiziert durch den ergänzten Eintrag
- [ ] 5.4 PLAN.md Phase 3: den Hinweis auf die UTC-Abweichung durch den Verweis auf diesen Change ersetzen; verifiziert durch den aktualisierten Abschnitt
- [ ] 5.5 Inventar unverändert lassen und den Abgleichstest prüfen; verifiziert dadurch, dass `cargo test -p tb-frontend --test inventar` grün bleibt (die Elemente waren bereits implementiert, nur ihr Zeitbezug ändert sich)
- [ ] 5.6 Vollständige Suite ausführen; verifiziert dadurch, dass `cargo test --workspace` grün ist

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-03

View File

@@ -0,0 +1,287 @@
## Context
Siehe proposal.md — Why. Technisch entscheidend ist der Ist-Zustand
dreier Stellen:
- `tb-ui::screen::TextScreen` (385 Zeilen) trägt die Bildschirmsemantik
bereits vollständig: `cls`, `set_color`, `locate`, `csrlin`, `pos`,
`view_print`, `cell`, `print`, `scroll_up`, `resize`. Ratatui kommt
darin an genau zwei Stellen vor: `basic_color()` (4 Zeilen) und
`impl Widget for &TextScreen` (~35 Zeilen).
- `tb-runtime::host::Host` ist ein Stream-Trait (`write(&str)`,
`read_line()`). `tb-runtime` und `tb-ui` sind beide Blätter im
Abhängigkeitsgraphen und treffen sich erst in `tb-cli`/`tb-ide`.
- Rund 50 dokumentierte Elemente besitzen im Frontend eine Signatur in
`builtin_fn`/`builtin_stmt`, senken aber auf `HStmtKind::Unsupported`
bzw. `HExpr::Unsupported` ab → Opcode `0x05` → Laufzeitfehler 73.
Zwei Entscheidungen vom 2026-09-03 rahmen die Arbeit: Das Windowing wird
— wie beim Vorbild — vom erzeugten Programm mitgebracht und nicht von der
IDE, sodass Forms und `PRINT` in denselben Zellenpuffer zeichnen. Die
Forms-Engine selbst bleibt dabei am von PLAN.md vorgesehenen Ort
`tb-ui::forms`.
## Goals / Non-Goals
**Goals:**
- Der Zellenpuffer liegt dort, wo sowohl die Laufzeitbibliothek als auch
die spätere Forms-Engine in `tb-ui` ihn erreichen, ohne dass die
Ausführungsschicht eine Terminal-Bibliothek zieht.
- Bildschirmverhalten ist ohne Terminal automatisiert prüfbar.
- Die Abdeckung misst sich selbst statt gepflegt zu werden.
- Die Umstellung der acht bestehenden Golden Files geschieht in einem
nachvollziehbaren, einmaligen Schritt.
**Non-Goals:**
- Optimierung der Bildschirmausgabe (Diff-Minimierung, Teilaktualisierung)
— erst wenn Messungen sie verlangen.
- Ein Rendering-Backend jenseits des Terminals.
- Die Forms-Engine selbst (Phase 4, `tb-ui::forms`); dieser Change
liefert ihr nur die Zeichenfläche.
## Decisions
### D1: Zellenpuffer nach `tb-runtime::screen`, ratatui-frei
`Cell` und `TextScreen` ziehen unverändert nach `tb-runtime::screen`.
`basic_color()` und `impl Widget` bleiben in `tb-ui` und arbeiten künftig
auf dem Runtime-Typ.
*Warum:* `PRINT`, `LOCATE`, `CLS`, `COLOR`, `CSRLIN`, `POS` und die
Funktionsform von `SCREEN` sind Elemente der Laufzeitbibliothek. Sie
müssen den Puffer erreichen, ohne dass `tb-runtime` eine
Terminal-Bibliothek zieht — sonst braucht jeder VM- und Bibliothekstest
ein Terminal. Das trägt die Entscheidung allein.
*Verhältnis zur Forms-Engine:* Forms bleibt laut PLAN.md in
`tb-ui::forms` (Entscheidung 2026-09-03). Das passt zur Richtung: `tb-ui`
hängt künftig an `tb-runtime` und zeichnet von oben in denselben Puffer
wie `PRINT` — ein `MSGBOX` über einer Konsolenausgabe überdeckt deren
Zellen und gibt sie beim Schließen zurück. Läge der Puffer weiterhin in
`tb-ui`, wäre genau diese gemeinsame Fläche für die Bibliothek
unerreichbar. Umgekehrt entsteht kein Zyklus: die Ausführungsschicht
erreicht die Forms-Engine über die Host-Schnittstelle (D2), die sie
selbst deklariert und `tb-ui` implementiert.
*Alternativen:*
- `tb-runtime` hängt an `tb-ui`: null Doppelung, aber ratatui und
crossterm landen in jedem VM-Test. Verworfen.
- Semantik bleibt in `tb-ui`, jeder Host führt einen eigenen Puffer:
dieselbe Scroll-/Umbruch-/Doppelzellenlogik dreimal. Verworfen.
*Aufwand:* Der Umzug ist mechanisch — betroffen sind der `use`-Block, die
zwei ratatui-Stellen und die Modulzuordnung. Die Semantik selbst wird
nicht angefasst; erweitert wird sie nur um die Doppelzellen (D4).
### D2: `Host` wird Bildschirm- statt Stream-Schnittstelle
`write`/`read_line` weichen einer Schnittstelle, die den Zellenpuffer zur
Anzeige übergibt und Ereignisse (Tastendruck, Größenänderung, Abbruch)
zurückliefert. Die Bibliothek schreibt nicht mehr in den Host, sondern in
den Puffer; der Host stellt dar.
*Warum:* `LOCATE 5,10`, `COLOR 14,1`, `CSRLIN` und besonders
`SCREEN(r,c)` — das ein Zeichen vom Bildschirm *zurückliest* — sind über
einen Zeichenstrom nicht ausdrückbar. Das ist ein Zwang, keine Präferenz.
*Wann dargestellt wird:* Der Host wird an denselben Tick-Grenzen
aufgerufen, an denen die VM heute schon die Abbruchprüfung macht, sowie
vor jeder blockierenden Eingabe. Kein zusätzlicher Mechanismus.
*Blockieren bleibt erlaubt* (Entscheidung D3 aus Phase 2 bleibt gültig):
`INPUT` und `INPUT$` blockieren im Host. Erst die Ereignisschleife der
Phase 4 bringt kooperative Zustellpunkte.
*Alternativen:*
- Stream-Host behalten und Bildschirmwirkungen als ANSI-Escapes
hindurchreichen: `CSRLIN`/`POS`/`SCREEN()` bräuchten trotzdem einen
Schattenpuffer, und der Capture-Host müsste ANSI parsen. Verworfen.
### D3: Korpusvergleich als getrimmter Snapshot
Die `.out`-Datei hält das Textbild des Puffers bis zur letzten belegten
Zeile und Spalte. Eine zweite Ebene mit Farbattributen kommt nur hinzu,
wenn das Programm `COLOR` verwendet.
*Warum getrimmt:* Ein voller 80×25-Snapshot für `hello.bas` wären 2000
Zeichen für zwei Zeichen Signal, und jeder Diff zeigte Rauschen. Getrimmt
bleiben die bestehenden acht Sollausgaben inhaltlich fast unverändert —
`PRINT`-only-Programme ergeben dasselbe Textbild wie bisher der Stream.
*Warum Attribute optional:* Programme ohne `COLOR` erzeugen sonst eine
zweite Ebene aus lauter identischen Werten.
*Umstellung:* Die acht Golden Files werden einmalig neu erzeugt und der
Diff wird geprüft — erwartet wird, dass sich nur Zeilen ändern, die
vorher auf `\r`/Umbruchverhalten beruhten. Ein unerwarteter Diff ist ein
Befund, kein Anpassungsgrund.
*Alternativen:*
- Zwei Harnesses (Stream für Alt-Tests, Snapshot für neue): dauerhaft
doppelte Infrastruktur für einen einmaligen Umstellungsschmerz.
Verworfen.
- Voller Snapshot: siehe oben. Verworfen.
### D4: Doppelzellen über Darstellungsbreite
Breite Zeichen belegen zwei Zellen: die erste trägt das Zeichen, die
zweite ist als Fortsetzung markiert. Neue Abhängigkeit `unicode-width`
in `tb-runtime`.
*Warum eine Abhängigkeit:* Die Breitentabelle folgt einem
Unicode-Annex und ändert sich mit jeder Unicode-Version; sie
nachzubauen hieße, sie dauerhaft nachzupflegen.
*Konsequenzen, die die Spec festhält:* Cursor rückt um zwei Spalten vor;
`POS` zählt die Anfangsspalte; ein breites Zeichen in der letzten Spalte
wird vollständig umgebrochen und die Spalte bleibt leer; `LOCATE` auf
eine Fortsetzungszelle wirkt auf den Zeichenanfang. Beim Überschreiben
einer Hälfte muss die andere geleert werden, sonst entstehen
Geisterzeichen.
### D4a: 80×25 ist nirgends eine Konstante
Anforderung des Projektinhabers (2026-09-03), Priorität für das ganze
Programm: 80×25 ist stets Synonym für „voller Bildschirm", nie eine feste
Grenze. Moderne Terminals haben keine feste Größe, und sie ändert sich
während der Ausführung.
*Konsequenz für die Umsetzung:* Kein Zweig darf gegen 80 oder 25
vergleichen. Die Abmessungen kommen ausschließlich aus dem Puffer
(`cols()`/`rows()`), abgeleitet aus der Darstellungsfläche. Betroffen
sind `CLS` (voller aktueller Bildschirm), der voreingestellte
Scrollbereich, die Umbruchspalte, die Scrollzeile sowie die
Grenzprüfungen von `LOCATE` und der Funktionsform von `SCREEN`.
*Größenänderung zur Laufzeit* ist damit kein Sonderfall, sondern der
Normalfall: Das Größenänderungsereignis kommt über den Host (D2), der
Puffer wird angepasst, Inhalt bleibt oben links, Cursor und
`VIEW PRINT`-Bereich werden geklemmt. Ein Bereich, der durch die
Verkleinerung leer würde, fällt auf den vollen Bildschirm zurück, statt
ungültig zu bleiben.
*Das Mindestmaß bleibt eine Darstellungsschranke:* Ist die Fläche kleiner
als 80×25, zeigt die Darstellungsschicht den Hinweis „Terminal zu klein"
(bestehende Entscheidung 2026-09-02). Das ist eine untere Schranke der
Anzeige, keine Bildschirmgröße für das Programm.
*Prüfbarkeit:* Der Test-Host bekommt seine Größe je Korpusprogramm
explizit gesetzt, ohne stillschweigenden Vorgabewert. Mindestens ein
Bildschirmprogramm läuft bei zwei Größen mit je eigener Sollausgabe —
eine hartkodierte 80 oder 25 fällt damit sofort auf. Ein weiteres
durchläuft eine Größenänderung mitten in der Ausführung.
### D4b: Das Inventar geht der Umfangsschätzung vor
Die Erhebung in Aufgabe 1.1 (2026-09-03) hat gezeigt, dass die
ursprüngliche Umfangsliste dieses Changes drei ganze Gruppen nicht
enthielt: die 13 finanzmathematischen Funktionen, die 14
Record-Konvertierungsfunktionen und einen Rest an Dateisystem- und
Systemelementen. Sie hat außerdem zwei Elemente widerlegt, die
`phase-3-isam` fälschlich führte (`CHECKPOINT`, `TEXTCOMP`).
*Konsequenz für die Arbeitsweise:* Maßgeblich ist das Inventar, nicht die
Aufzählung im Proposal. Wo beide auseinandergehen, wird das Proposal
nachgezogen. Aufzählungen in Proposal und Specs sind Mindestumfang.
*Konsequenz für die Quelle:* `bas7advr.hlp/TOPIC_LIST.html` ist
nachweislich unvollständig — `SGN`, `SQR`, `SPACE$`, `STRING$`, `CSRLIN`,
`BEEP` und `DATE$` als Funktion fehlen dort. Das Inventar bildet die
Vereinigungsmenge aus dieser Liste, `qb45advr.hlp/TOPIC_LIST.html` und
`docs/sprachreferenz.md`; jeder Eintrag nennt seine Fundstelle. Die
Zählung einer einzelnen Themenliste ist kein Vollständigkeitsnachweis.
### D5: Inventar als Markdown-Tabelle mit Abgleichstest
`docs/inventar.md` trägt eine Pipe-Tabelle mit festen Spalten (Name, Art,
Gruppe, Status, Fundstelle). Ein Test parst sie und hält sie gegen
`builtin_fn`/`builtin_stmt`, die `Unsupported`-Marker und
`banned_feature`.
*Warum Markdown und nicht TOML/YAML:* Der Plan nennt „maschinenlesbar",
aber keinen Konsumenten außer der Messbarkeit selbst; Zielpfad ist
ausdrücklich `docs/inventar.md`, und das IDE-Hilfesystem (Phase 5)
rendert `docs/`-Markdown. Eine Pipe-Tabelle ist mit einem Split
maschinenlesbar. Ein zweites Format bräuchte einen Generator, um wieder
bei Markdown zu landen.
*Der Test ist der eigentliche Punkt:* Ohne ihn verrottet der Status. Mit
ihm kann das Inventar nicht falsch werden, ohne dass die Suite rot wird.
### D6: `Unsupported`-Marker bleiben, aber nur noch für Phase 4
Die Marker verschwinden nicht — Ereignissteuerung, `SETUEVENT`, `MSGBOX`
und `INPUTBOX$` behalten sie bis Phase 4. Der Abgleichstest aus D5 stellt
sicher, dass genau die im Inventar als `offen` geführten Elemente noch
Marker tragen.
### D7: Reihenfolge der Umsetzung
```
1. Inventar (Sollseite) -> misst alles Folgende
2. Puffer-Umzug + Host + Snapshot -> BREAKING, zusammen in einem Schritt
3. Bildschirmanweisungen -> braucht 2
4. Strings/Format/Datum/Mathematik -> unabhaengig, jederzeit
5. Datei-E/A -> unabhaengig von 2/3
6. Altlasten Phase 2 -> unabhaengig
```
Schritt 2 ist der einzige Bruch und wird bewusst als eine Einheit
umgesetzt: Puffer-Umzug, `Host`-Neuschnitt und Snapshot-Umstellung hängen
so eng zusammen, dass ein Zwischenzustand die Suite ohnehin rot ließe.
Schritte 46 können vorgezogen werden, falls Schritt 2 sich zieht.
## Risks / Trade-offs
- **Der `Host`-Bruch legt kurzzeitig die gesamte Suite lahm** → Schritt 2
als eine Einheit umsetzen, nicht in Teilschritten; erst wenn Puffer,
Host und Harness zusammen stehen, wird gemessen.
- **Neu erzeugte Golden Files zementieren einen Fehler** → Der Diff der
acht Dateien wird einzeln geprüft. Erwartung: `PRINT`-only-Programme
ändern sich nicht. Jede Abweichung wird begründet oder ist ein Bug.
- **Doppelzellen brechen bestehende Annahmen** (`POS` = Zeichenzahl,
Zelle = Zeichen) → Korpustest mit CJK/Emoji inklusive Umbruch am
rechten Rand, bevor die übrige Bildschirmarbeit darauf aufsetzt.
- **`RND`-Kompatibilität ist Rekonstruktionsarbeit** — die Zahlenfolge
des Vorbilds ist nur über die Dokumentation und Beobachtung greifbar →
Algorithmus dokumentieren; gelingt die exakte Folge nicht, wird die
Abweichung in der Sprachreferenz festgehalten statt still
abzuweichen.
- **UTF-32-Records machen Dateien binär inkompatibel zum Vorbild** →
bewusst entschieden (2026-09-02); Preis ist, dass Originaldateien nicht
gelesen werden können. Muss in der Sprachreferenz stehen.
- **`SHELL`, `FILES`, Satzsperren und `LPRINT` sind plattformabhängig** →
jede Abweichung wird in der Sprachreferenz unter „Abweichungen"
benannt; stilles Abweichen ist laut Guiding Principle ein Bug.
- **Das Inventar aus der Original-Hilfe zu erheben ist der stillste
Aufwandstreiber** → als eigener erster Schritt geführt, damit sein
Umfang sichtbar wird, statt sich in die Implementierungsarbeit zu
mischen.
## Migration Plan
1. Inventar anlegen (rein additiv, kein Bruch).
2. Bruchschritt in einem Zug: `TextScreen` nach `tb-runtime::screen`,
`tb-ui` auf Adapterrolle, `Host` neu, Harness auf Snapshot, acht
Golden Files neu erzeugen und Diff prüfen.
3. Alles Weitere additiv — jedes neue Element wechselt im Inventar von
`offen` auf `implementiert`, der Abgleichstest hält beides zusammen.
Rücknahme: Schritt 2 ist ein zusammenhängender Commit und als Ganzes
rücknehmbar. Danach gibt es keinen weiteren Bruch.
## Open Questions
- Umfang der `KEY n`-Funktionstasten-Makros (`KEY LIST`/`ON`/`OFF`): Die
Anweisung steht im Scope; welche Makro-Semantik das Vorbild genau
zeigt, ist bei der Umsetzung aus der Original-Hilfe zu klären und in
docs/sprachreferenz.md festzuhalten. Berührt weder Puffer noch Host
noch Aufgabenschnitt.
- Ersatzwert für `FRE` auf einer Plattform ohne DOS-Speichermodell: bei
der Umsetzung zu wählen und zu dokumentieren.
- Konkrete Gestalt der Schnittstelle, über die die Forms-Engine in
`tb-ui` später eigene Zeichenbereiche im Puffer belegt und wieder
freigibt: erst in Phase 4 zu klären, wenn die Steuerelemente stehen.
Ändert weder Puffer noch Host noch Aufgabenschnitt dieses Changes.

View File

@@ -0,0 +1,186 @@
# Phase 3 (Kern) — Laufzeitbibliothek, Bildschirm und Datei-E/A
## Why
Phase 2 hat eine lauffähige VM geliefert, aber nur eine dünne
Bibliotheksscheibe: rund 50 dokumentierte Anweisungen und Funktionen sind
im Frontend zwar typgeprüft, senken aber auf `HStmtKind::Unsupported`/
`HExpr::Unsupported` ab und enden zur Laufzeit in Fehler 73. Betroffen
sind alle Bildschirmanweisungen (`CLS`, `LOCATE`, `COLOR`, `VIEW PRINT`,
`CSRLIN`, `POS`, `INKEY$`), die komplette Datei-E/A, `PRINT USING`,
die Datums-/Zeitfunktionen und die System-Anweisungen. Klassische
Konsolenprogramme des Vorbilds laufen damit nicht.
Zusätzlich fehlt das Messinstrument: die Leitplanke „Vollständigkeit ist
das Soll" fordert ein Inventar aller Anweisungen/Funktionen mit
Abdeckungsstatus als Abnahmekriterium für Phase 6 — bislang existiert nur
die Implementierungsseite (die `Unsupported`-Marker), nicht die Sollseite
aus der Original-Hilfe.
Dieser Change löst den Plan-Meilenstein „klassische Konsolenprogramme
laufen unverändert" ein. ISAM ist als eigener Change `phase-3-isam`
ausgegliedert und schließt Phase 3 ab.
## What Changes
- **Vollständigkeits-Inventar** (`docs/inventar.md`): Tabelle aller
Anweisungen, Funktionen und Metabefehle des Vorbilds aus der
Original-Hilfe mit Status `implementiert` / `offen` / `Non-Feature`
und Fundstelle. Ein automatischer Test gleicht die Tabelle gegen
`builtin_fn`/`builtin_stmt` und die `Unsupported`-Marker im Code ab;
Divergenz schlägt fehl. Damit misst sich die Abdeckung selbst.
- **Bildschirmpuffer wandert nach `tb-runtime`** (Entscheidung
2026-09-03, Explore-Session): `Cell`/`TextScreen` mit der kompletten
Bildschirmsemantik (Cursor, Farbattribute, Scrollen, `VIEW PRINT`,
`resize`) ziehen ratatui-frei von `tb-ui::screen` nach
`tb-runtime::screen`. In `tb-ui` bleiben nur die Ratatui-Anbindung
(`basic_color`, `impl Widget`) und künftig der Terminal-Adapter.
Begründung: `PRINT`, `LOCATE`, `CLS` und `SCREEN()` sind Elemente der
Laufzeitbibliothek und müssen den Puffer erreichen; zugleich bleiben
`tb-vm` und `tb-runtime` damit frei von einer Terminal-Abhängigkeit,
sodass jedes Bildschirmverhalten ohne Terminal prüfbar ist. Die
Forms-Engine bleibt laut PLAN.md in `tb-ui::forms` (Entscheidung
2026-09-03) und zeichnet von dort in denselben Puffer — `tb-ui` hängt
künftig an `tb-runtime`, nicht umgekehrt.
- **BREAKING: `Host`-Trait neu geschnitten**: statt `write(&str)` /
`read_line()` künftig Präsentation eines Zellenpuffers und
Ereignisabfrage. `write`/`read_line` können den Bildschirmzustand
nicht transportieren (`LOCATE`, `COLOR`, `CSRLIN`, `SCREEN(r,c)` liest
vom Bildschirm zurück). Betrifft `ConsoleHost`, `CaptureHost` und
`tbc run`.
- **BREAKING: Korpusvergleich auf Bildschirm-Snapshot** (getrimmt):
`.out`-Dateien halten künftig das Textbild des Puffers bis zur letzten
belegten Zelle statt eines Byte-Streams; eine Attributebene kommt nur
hinzu, wenn das Programm `COLOR` verwendet. Die acht bestehenden
Golden Files werden neu erzeugt. Ein Byte-Stream kann `LOCATE`/`COLOR`
nicht abbilden.
- **Bildschirmanweisungen und -funktionen**: `CLS`, `COLOR`, `LOCATE`,
`WIDTH`, `SCREEN` (Anweisung und Funktion), `VIEW PRINT`, `CSRLIN`,
`POS`, `INKEY$`, `INPUT$`, `TAB`, `SPC`, `KEY` (inkl. Klärung des
Umfangs der Funktionstasten-Makros `KEY LIST`/`ON`/`OFF`).
- **80×25 ist nirgends eine feste Grenze** (Vorgabe des Projektinhabers
2026-09-03, Priorität für das ganze Programm): 80×25 steht stets als
Synonym für „voller Bildschirm". Kein beobachtbares Verhalten darf eine
feste Spalten- oder Zeilenzahl voraussetzen — `CLS`, der
voreingestellte Scrollbereich, Umbruch- und Scrollgrenzen sowie die
Bereichsprüfungen von `LOCATE` und `SCREEN()` folgen den aktuellen
Abmessungen. Größenänderungen während der Ausführung werden als
Ereignis zugestellt und angepasst (Inhalt oben links erhalten, Cursor
und `VIEW PRINT`-Bereich geklemmt). Das Mindestmaß 80×25 bleibt
ausschließlich eine untere Schranke der Darstellung.
- **Breite Unicode-Zeichen belegen zwei Zellen** (Entscheidung
2026-09-02): Zellenmodell, Cursorfortschritt, `POS`/`LOCATE` und
Umbruch am rechten Rand berücksichtigen die Darstellungsbreite
(unicode-width). Ersetzt die bisherige Regel „1 Zeichen = 1 Zelle".
- **Vollständige String- und Zahlenbibliothek**: die restlichen
dokumentierten Stringfunktionen sowie `PRINT USING` und `FORMAT$`
vollständig, inklusive Klärung des `%`-Präfixes bei Feldüberlauf.
- **Datums- und Zeitfunktionen**: `NOW`, `DATESERIAL`, `TIMESERIAL`,
`DATEVALUE`, `TIMEVALUE`, `DAY`, `MONTH`, `YEAR`, `WEEKDAY`, `HOUR`,
`MINUTE`, `SECOND` sowie `DATE$`/`TIME$` als Anweisungsform.
- **Finanzmathematik** (Befund aus Aufgabe 1.1, 2026-09-03 — im
ursprünglichen Umfang übersehen): `FV#`, `PV#`, `Pmt#`, `IPmt#`,
`PPmt#`, `NPer#`, `Rate#`, `NPV#`, `IRR#`, `MIRR#`, `SLN#`, `SYD#`,
`DDB#`.
- **Record-Konvertierungsfunktionen** (ebenfalls Befund 1.1):
`MKI$`/`MKL$`/`MKS$`/`MKD$`/`MKC$` und `CVI`/`CVL`/`CVS`/`CVD`/`CVC`
sowie die MBF-Varianten `MKSMBF$`/`MKDMBF$`/`CVSMBF`/`CVDMBF` — sie
gehören zum `FIELD`-basierten wahlfreien Zugriff.
- **Weitere Befunde aus 1.1**: `CURDIR$`, `DIR$`, `CHDRIVE`,
`SHELL` als Funktion, `LPOS`, `STACK` (Anweisung und Funktion),
`SYSTEM`, `ERDEV`/`ERDEV$`, `SetFormatCC`.
- **Signaturen für alle Inventarelemente**: Die Semantik führt für jedes
im Inventar geführte Element eine Signatur, auch wenn das
Laufzeitverhalten noch aussteht — sonst enden dokumentierte Elemente
in „unbekannter Bezeichner" statt in einer namentlichen Ablehnung.
- **Mathematik vervollständigen**: kompatibler `RND`/`RANDOMIZE`-PRNG
(gleiche Zahlenfolge wie das Vorbild bei gleichem Startwert),
Rundungsverhalten, Integer-Überlauf → Fehler 6.
- **Datei-E/A**: `OPEN` in beiden Syntaxen (sequenziell, random, binär),
`CLOSE`, `RESET`, `PRINT #`, `WRITE #`, `INPUT #`, `LINE INPUT #`,
`GET`, `PUT`, `FIELD`, `LSET`, `RSET`, `SEEK` (Anweisung und
Funktion), `EOF`, `LOF`, `LOC`, `FREEFILE`, `FILEATTR`, `LOCK`/
`UNLOCK`, `NAME … AS`, `LPRINT`. Feste Strings in Records als UTF-32
(Entscheidung 2026-09-02, 4 Bytes/Zeichen, feste Recordlänge).
- **System-Anweisungen**: `FILES`, `KILL`, `CHDIR`, `MKDIR`, `RMDIR`,
`SHELL`, `ENVIRON`/`ENVIRON$`, `CLEAR`, `FRE`, `TRON`/`TROFF`,
plattformübergreifende Pfadsemantik.
- **Drei Altlasten aus Phase 2**:
- `ON ERROR GOTO`-Scoping: ein Handler-Label auf Modulebene ist auch
aus Prozeduren heraus ansprechbar; heute wird nicht-lokales
`ON ERROR` in einer Prozedur fälschlich wie `ON LOCAL ERROR`
behandelt (`sema.rs`, `lower_stmt`/`OnError`).
- DATA-Rohtext: unquotierte `DATA`-Elemente verlieren die Groß-/
Kleinschreibung, weil der Lexer Bezeichner normalisiert.
- `TODO verify`-Zellen der Konvertierungsmatrix in
`docs/tbvm-design.md` gegen die Original-Hilfe auflösen
(CURRENCY-Promotion mit Gleitkomma, `^`-Ergebnistyp,
Exponentialform-Schwellen, CURRENCY-Ausgabeformat).
**Non-Goals:** ISAM (eigener Change `phase-3-isam`, schließt Phase 3 ab);
Forms-Engine, Ereignisschleife und Ereignissteuerung (`TIMER/KEY(n) ON|
OFF|STOP`, `ON SIGNAL`/`SIGNAL`, `SETUEVENT`, `MSGBOX`, `INPUTBOX$`
Phase 4); IDE und
Debugger (Phase 5); `tbc build --exe` (Phase 6). Die Forms-Engine bleibt
laut PLAN.md in `tb-ui::forms` (Entscheidung 2026-09-03); dieser Change
schafft ihr mit dem Puffer in `tb-runtime` nur die Zeichenfläche.
## Capabilities
### New Capabilities
- `datei-eio`: Dateiverwaltung und Datenübertragung — `OPEN`/`CLOSE`,
die drei Zugriffsarten (sequenziell, random, binär), Record-Layout mit
UTF-32-Festtextfeldern, `FIELD`/`LSET`/`RSET`, Positions- und
Statusfunktionen (`EOF`/`LOF`/`LOC`/`SEEK`/`FREEFILE`/`FILEATTR`),
Sperren, Dateisystem-Anweisungen und plattformübergreifende
Pfadsemantik.
- `sprachinventar`: das Vollständigkeits-Inventar als prüfbares
Artefakt — Aufbau der Tabelle, Statusvokabular, Pflicht zur
Vollständigkeit gegenüber der Original-Hilfe und der automatische
Abgleich gegen den Code.
### Modified Capabilities
- `textbildschirm`: Der Zellenpuffer SHALL in `tb-runtime` liegen und
ratatui-frei sein; das Unicode-Zellenmodell wechselt von „1 Zeichen =
1 Zelle" auf Darstellungsbreite (breite Zeichen belegen zwei Zellen);
neue Anforderung für die Bildschirmanweisungen des Dialekts auf dem
Puffer.
- `konsolen-basisbibliothek`: Der `Host`-Trait wechselt vom Stream- auf
ein Bildschirmmodell (Präsentation + Ereignisabfrage); die
Funktionsliste wird von der Korpusscheibe auf die vollständige
String-, Zahlen-, Formatierungs- (`PRINT USING`) und Datums-/
Zeitbibliothek erweitert; `RND` erhält eine Kompatibilitätsanforderung.
- `kompat-testkorpus`: Der Laufzeitvergleich wechselt vom Byte-Stream
auf einen getrimmten Bildschirm-Snapshot; neue Abdeckungsanforderung
für Bildschirm-, Formatierungs- und Datei-E/A-Semantik.
- `sprach-frontend`: `ON ERROR GOTO` auf Modulebene SHALL aus
Prozeduren heraus ansprechbar sein (bisher fälschlich lokal);
unquotierte `DATA`-Elemente SHALL ihren Rohtext behalten.
## Impact
- `crates/tb-ui`: `screen.rs` verliert Puffer und Semantik an
`tb-runtime`; zurück bleiben `basic_color` und `impl Widget` als
Adapter auf den Runtime-Puffer. Neue Abhängigkeit `tb-runtime`
(bisher waren beide Blätter im Abhängigkeitsgraphen); `forms.rs`
bleibt als Platzhalter für Phase 4 unberührt.
- `crates/tb-runtime`: neues `screen`-Modul; `host.rs` neu geschnitten
(**BREAKING**); `strings`, `format`, `math`, `datetime`, `fileio`,
`builtins` von Stub/Scheibe auf Vollimplementierung; neue
Abhängigkeit `unicode-width`.
- `crates/tb-frontend`: rund 50 `Unsupported`-Marker weichen echter
Absenkung; rund 30 bislang signaturlose Elemente (Finanzmathematik,
`MK…$`/`CV…`, `CURDIR$`, `DIR$`, `CHDRIVE`, `LPOS`, `STACK`, `SYSTEM`,
`ERDEV`/`ERDEV$`, `SetFormatCC`) erhalten Signaturen; `sema.rs` (`ON ERROR`-Scoping), `lexer.rs` (DATA-Rohtext).
- `crates/tb-vm`: neue Builtin-Indizes; `Instr::Unsupported` bleibt für
die verbleibenden Phase-4-Marker bestehen.
- `crates/tb-cli`: `tbc run` auf den neuen `Host`; Harness auf
Snapshot-Vergleich (**BREAKING** für die acht bestehenden `.out`).
- `tests/compat`: acht Golden Files neu erzeugt, neue Korpusdateien für
Bildschirm, `PRINT USING`, Datei-E/A, Datum/Zeit, breite Zeichen.
- `docs/`: neu `docs/inventar.md` und `docs/bibliothek.md`;
fortgeschrieben `docs/sprachreferenz.md` (Doppelzellen, `KEY n`,
`PRINT USING`-Überlauf) und `docs/tbvm-design.md`
(Konvertierungsmatrix). PLAN.md-Haken folgen bei Abschluss.

View File

@@ -0,0 +1,295 @@
# Rohliste — Themen der Original-Hilfe (Aufgabe 1.1)
**Quelle:** `bas7advr.hlp` (BASIC 7 Advisor, Sprachreferenz der
Professional Edition), Themenliste unter
https://dos-help.soulsphere.org/bas7advr.hlp/TOPIC_LIST.html
(abgerufen 2026-09-03).
**Nicht verwendet:** `vbdpss.hlp` ist eine Knowledge-Base mit
Artikelnummern und Support-Adressen, keine Sprachreferenz — geprüft und
verworfen.
Die Liste ist wortgetreu übernommen. Sie enthält neben Sprachthemen auch
Unterseiten (`SCREEN Mode …`, `DRAW Statement Macro Language`,
`PLAY Statement (Music) — …`) und einen Anker (`@L8000`); diese sind
keine eigenen Sprachelemente und gehen nicht als Inventareintrag ein.
## Themen (wortgetreu)
$INCLUDE Metacommand
$STATIC and $DYNAMIC Metacommands
@L8000
ABS Function
ASC Function
ATN Function
Absolute Routine
BEGINTRANS Statement
BLOAD Statement
BOF Function
BSAVE Statement
CALL Statement (BASIC Procedures)
CALL, CALLS Statements (Non-BASIC Procedures)
CCUR Function
CDBL Function
CHAIN Statement
CHDIR Statement
CHDRIVE Statement
CHR$ Function
CINT Function
CIRCLE Statement
CLEAR Statement
CLNG Function
CLOSE Statement
CLS Statement
COLOR Statement
COM Statements
COMMAND$ Function
COMMITTRANS Statement
COMMON Statement
CONST Statement
COS Function
CREATEINDEX Statement
CSNG Function
CURDIR$ Function
CVI, CVL, CVS, CVD, and CVC Functions
CVSMBF, CVDMBF Function
DATA Statement
DATE$ Statement
DDB# Function
DECLARE Statement (BASIC Procedures)
DECLARE Statement (Non-BASIC Procedures)
DEF FN Statement
DEF SEG Statement
DEFtype Statement
DELETE Statement
DELETEINDEX Statement
DELETETABLE Statement
DIM Statement
DIR$ Function
DO...LOOP Statement
DRAW Statement - Differences from BASICA
DRAW Statement Macro Language
DateSerial# Function
DateValue# Function
Day& Function
END Statement
ENVIRON Statement
ENVIRON$ Function
EOF Function
ERASE Statement
ERDEV, ERDEV$ Function
ERR Statement
ERR, ERL Functions
ERROR Statement
EVENT Statements
EXIT Statement
EXP Function
FIELD Statement
FILEATTR Function
FILES Statement
FIX Function
FOR...NEXT Statement
FRE Function
FREEFILE Function
FUNCTION Statement
FV# Function
FormatX$ Functions
GET Statement (File I/O)
GET Statement (Graphics)
GETINDEX$ Function
GOSUB...RETURN Statement
GOTO Statement
HEX$ Function
Hour& Function
IF...THEN...ELSE Statement
INKEY$ Function
INP Function
INPUT # Statement
INPUT Statement
INPUT$ Function
INSERT Statement
INSTR Function
INT Function
IOCTL Statement
IOCTL$ Function
IPmt# Function
IRR# Function
Interrupt, InterruptX Routines
KEY Statements (Assignment)
KEY Statements (Event Trapping)
KILL Statement
LBOUND Function
LCASE$ Function
LEFT$ Function
LEN Function
LET Statement
LINE INPUT # Statement
LINE INPUT Statement
LINE Statement
LOC Function
LOCATE Statement
LOCK...UNLOCK Statement
LOF Function
LOG Function
LPOS Function
LPRINT Statement
LPRINT USING Statement
LSET Statement
LTRIM$ Function
MID$ Function
MID$ Statement
MIRR# Function
MKDIR Statement
MKI$, MKL$, MKS$, MKD$, and MKC$ Functions
MKSMBF$, MKDMBF$ Functions
MOVEFIRST, MOVELAST, MOVENEXT, MOVEPREVIOUS Statements
Minute& Function
Month& Function
NAME Statement
NPV# Function
NPer# Function
Now# Function
OCT$ Function
ON COM Statement
ON ERROR Statement
ON KEY Statement
ON PEN Statement
ON PLAY Statement
ON SIGNAL Statement
ON STRIG Statement
ON TIMER Statement
ON UEVENT Statement
ON...GOSUB and ON...GOTO Statement
OPEN COM Statement
OPEN Statement (File I/O)
OPTION BASE Statement
OUT Statement
PAINT Statement
PALETTE, PALETTE USING Statements
PCOPY Statement
PEEK Function
PEN Function
PEN Statements
PLAY Statement (Music)
PLAY Statements (Event Trapping)
PMAP Function
POINT Function
POKE Statement
POS Function
PPmt# Function
PRESET Statement
PRINT # Statement
PRINT Statement
PRINT USING Statement
PSET Statement
PUT Statement (File I/O)
PUT Statement (Graphics)
PV# Function
Pmt# Function
RANDOMIZE Statement
READ Statement
REDIM Statement
REM Statement
RESTORE Statement
RESUME Statement
RETRIEVE Statement
RETURN Statement
RIGHT$ Function
RMDIR Statement
RND Function
ROLLBACK, ROLLBACK ALL Statements
RSET Statement
RTRIM$ Function
RUN Statement
Rate# Function
SADD Function
SAVEPOINT Function
SCREEN Function
SCREEN Statement
SEEK Function
SEEK Statement
SEEKGT, SEEKGE, SEEKEQ Statements
SELECT CASE Statement
SETINDEX Statement
SETMEM Function
SHARED Statement
SHELL Function
SHELL Statement
SIGNAL Statements
SIN Function
SLEEP Statement
SLN# Function
SOUND Statement
SPC Function
SSEG Function
SSEGADD Function
STACK Function
STACK Statement
STATIC Statement
STICK Function
STOP Statement
STR$ Function
STRIG Function
STRIG Statements
SUB Statement
SWAP Statement
SYD# Function
SYSTEM Statement
Second& Function
SetFormatCC Routine
SetUEvent Routine
StringAddress Routine
StringAssign Routine
StringLength Routine
StringRelease Routine
TAB Function
TAN Function
TIME$ Function
TIME$ Statement
TIMER Function
TIMER Statements
TRON/TROFF Statements
TYPE Statement
TimeSerial# Function
TimeValue# Function
UBOUND Function
UCASE$ Function
UEVENT Statements
UNLOCK Statement
UPDATE Statement
VAL Function
VARPTR$ Function
VARPTR, VARSEG Functions
VIEW PRINT Statement
VIEW Statement (Graphics)
WAIT Statement
WHILE...WEND Statement
WIDTH Statements
WINDOW Statement
WRITE # Statement
WRITE Statement
Weekday& Function
Year& Function
## Gegenprobe (Aufgabe 1.1, Befund C)
`bas7advr.hlp/TOPIC_LIST.html` ist **nicht vollständig**. Die Gegenprobe
gegen `qb45advr.hlp/TOPIC_LIST.html` (abgerufen 2026-09-03) belegt das:
diese Liste führt interne Ankernamen statt Klarnamen, enthält aber
Einträge, zu denen `bas7advr` kein Thema führt.
| Anker in `qb45advr` | Element | in `bas7advr`-Themenliste |
|---|---|---|
| `.sgnr` / `.sgnx` | `SGN` | fehlt |
| `.sqrr` / `.sqrx` | `SQR` | fehlt |
| `.sp$r` / `.sp$x` | `SPACE$` | fehlt |
| `.str$$$r` / `.str$$$x` | `STRING$` | fehlt |
| `.csrlinr` / `.csrlinx` | `CSRLIN` | fehlt |
| `.beepr` / `.beepx` | `BEEP` | fehlt |
| `.datef` | `DATE$` (Funktion) | nur `DATE$ Statement` |
**Folge für das Inventar:** Maßgeblich ist die Vereinigungsmenge aus
beiden Themenlisten und `docs/sprachreferenz.md`. Jeder Inventareintrag
führt seine Fundstelle; Einträge, die nur über die Gegenprobe belegt
sind, nennen `qb45advr`. Die Zählung einer einzelnen Themenliste ist
kein hinreichender Vollständigkeitsnachweis.

View File

@@ -0,0 +1,173 @@
## Purpose
Die Datei-E/A stellt die Dateiverwaltung des Dialekts bereit: sequenzieller,
wahlfreier und binärer Zugriff über Dateinummern, Record-Puffer mit
Festtextfeldern, Positions- und Statusfunktionen sowie die
Dateisystem-Anweisungen — plattformübergreifend und mit den
Laufzeitfehlern des Vorbilds.
## ADDED Requirements
### Requirement: Dateinummern und Öffnen in drei Zugriffsarten
`OPEN` SHALL in beiden Syntaxen des Vorbilds (Klausel- und Kurzform) die
Zugriffsarten sequenziell (`INPUT`, `OUTPUT`, `APPEND`), wahlfrei
(`RANDOM`) und binär (`BINARY`) unterstützen und die Datei an eine
Dateinummer binden. `FREEFILE` SHALL die kleinste freie Dateinummer
liefern. `CLOSE` ohne Argument SHALL alle offenen Dateien schließen,
`RESET` ebenso. Eine bereits belegte Dateinummer MUST Laufzeitfehler 55
„File already open" auslösen, eine nicht geöffnete Nummer Fehler 52
„Bad file name or number", eine fehlende Datei bei `INPUT` Fehler 53
„File not found".
#### Scenario: Sequenzielles Schreiben und Lesen
- **WHEN** eine Datei mit `OPEN "d.txt" FOR OUTPUT AS #1` beschrieben, geschlossen und mit `FOR INPUT` wieder geöffnet wird
- **THEN** liefert das Lesen genau die geschriebenen Zeilen
#### Scenario: Doppeltes Öffnen derselben Nummer
- **WHEN** `OPEN` eine bereits offene Dateinummer verwendet
- **THEN** tritt Laufzeitfehler 55 auf
#### Scenario: Freie Dateinummer
- **WHEN** `#1` offen ist und `FREEFILE` aufgerufen wird
- **THEN** liefert `FREEFILE` eine Nummer, die noch nicht belegt ist
### Requirement: Sequenzielle Datenübertragung
`PRINT #` SHALL wie `PRINT` formatieren (Druckzonen, Zahlendarstellung mit
Vorzeichenspalte), `WRITE #` SHALL Elemente komma-getrennt mit Strings in
Anführungszeichen schreiben. `INPUT #` SHALL komma-getrennte Felder lesen
und dabei Anführungszeichen auswerten, `LINE INPUT #` die ganze Zeile ohne
Zeilenende. `EOF` SHALL das Erreichen des Dateiendes melden; Lesen über das
Dateiende hinaus MUST Laufzeitfehler 62 „Input past end of file" auslösen.
#### Scenario: WRITE # und INPUT # sind gegenläufig
- **WHEN** `WRITE #1, "a,b", 5` geschrieben und die Zeile mit `INPUT #1, s$, n` gelesen wird
- **THEN** enthält `s$` den Text `a,b` und `n` den Wert 5
#### Scenario: Lesen über das Dateiende
- **WHEN** `INPUT #1, x` nach dem letzten Datensatz ausgeführt wird
- **THEN** tritt Laufzeitfehler 62 auf
### Requirement: Wahlfreier Zugriff mit Record-Puffern
`OPEN … FOR RANDOM … LEN = n` SHALL Datensätze fester Länge bereitstellen.
`GET` und `PUT` SHALL einen Datensatz an einer 1-basierten Recordnummer
lesen bzw. schreiben; ohne Nummer gilt der Satz nach der letzten Operation.
`FIELD` SHALL den Recordpuffer in benannte Festtextfelder aufteilen,
`LSET`/`RSET` SHALL links- bzw. rechtsbündig in ein Feld schreiben und
überlange Werte abschneiden. Alternativ SHALL `GET`/`PUT` mit einer
Variablen eines benutzerdefinierten Typs arbeiten.
#### Scenario: Datensatz schreiben und an gleicher Stelle lesen
- **WHEN** ein Record an Position 3 geschrieben und anschließend von Position 3 gelesen wird
- **THEN** stimmen alle Feldwerte mit den geschriebenen überein
#### Scenario: RSET schneidet ab
- **WHEN** in ein Feld der Länge 5 mit `RSET` der Text `ABCDEFG` geschrieben wird
- **THEN** enthält das Feld genau 5 Zeichen
### Requirement: Record-Konvertierungsfunktionen
Die Bibliothek SHALL die Konvertierungsfunktionen für Recordpuffer
bereitstellen: `MKI$`, `MKL$`, `MKS$`, `MKD$` und `MKC$` wandeln einen
numerischen Wert in seine Byte-Darstellung als String, `CVI`, `CVL`,
`CVS`, `CVD` und `CVC` wandeln zurück. Jedes Paar MUST gegenläufig sein.
Ein Argument, dessen Länge nicht zur Zielbreite passt, MUST
Laufzeitfehler 5 „Illegal function call" auslösen. `MKSMBF$`, `MKDMBF$`,
`CVSMBF` und `CVDMBF` SHALL dieselbe Rolle für das
Microsoft-Binärformat älterer BASIC-Versionen erfüllen; ist dieses
Format ein deklariertes Non-Feature, MUST die Ablehnung namentlich
erfolgen und in der Sprachreferenz unter „Abweichungen" stehen.
#### Scenario: MK und CV sind gegenläufig
- **WHEN** `CVL(MKL$(123456))` ausgewertet wird
- **THEN** ist das Ergebnis 123456
#### Scenario: Falsche Argumentlänge
- **WHEN** `CVI("x")` mit einem ein Zeichen langen String ausgewertet wird
- **THEN** tritt Laufzeitfehler 5 auf
### Requirement: Festtextfelder als UTF-32 mit fester Recordlänge
Feste Strings (`STRING * n`) in Recordpuffern SHALL als UTF-32 gespeichert
werden — 4 Bytes je Zeichen, damit `STRING * n` stets genau `4 * n` Bytes
belegt und die Recordlänge unabhängig vom Zeicheninhalt konstant bleibt.
Die daraus folgende Binär-Inkompatibilität zu Dateien des Vorbilds ist
beschlossen (2026-09-02) und MUST in der Sprachreferenz unter
„Abweichungen" dokumentiert sein.
#### Scenario: Recordlänge unabhängig vom Inhalt
- **WHEN** in ein Feld `STRING * 10` einmal `abc` und einmal `äöüßÄÖÜ` geschrieben wird
- **THEN** belegt der Datensatz in beiden Fällen dieselbe Byteanzahl und der Folgesatz beginnt an derselben Position
### Requirement: Binärzugriff auf Byteebene
`OPEN … FOR BINARY` SHALL byteweise Positionierung erlauben. `GET`/`PUT`
SHALL an einer 1-basierten Byteposition lesen bzw. schreiben, wobei die
übertragene Länge der Größe der Zielvariablen entspricht. `SEEK` als
Anweisung SHALL die Position setzen, `SEEK` als Funktion sie liefern.
#### Scenario: Positioniertes Byte überschreiben
- **WHEN** in einer Binärdatei an Position 4 ein Byte geschrieben und anschließend gelesen wird
- **THEN** liefert das Lesen den geschriebenen Wert und die übrigen Bytes bleiben unverändert
### Requirement: Positions- und Statusfunktionen
`LOF` SHALL die Dateigröße in Bytes liefern, `LOC` die aktuelle Position
in der Einheit der Zugriffsart (Datensatz bei `RANDOM`, Byte bei
`BINARY`), `SEEK` die nächste Lese-/Schreibposition und `FILEATTR` den
Modus bzw. das Betriebssystem-Handle. Alle Funktionen MUST bei einer nicht
geöffneten Dateinummer Laufzeitfehler 52 auslösen.
#### Scenario: Größe nach dem Schreiben
- **WHEN** 100 Bytes geschrieben wurden und `LOF(1)` aufgerufen wird
- **THEN** liefert `LOF(1)` den Wert 100
#### Scenario: Statusfunktion auf geschlossener Datei
- **WHEN** `LOF(9)` ohne offene Datei 9 aufgerufen wird
- **THEN** tritt Laufzeitfehler 52 auf
### Requirement: Satzsperren
`LOCK` und `UNLOCK` SHALL einen Datensatz-, Byte- oder den gesamten
Dateibereich sperren und wieder freigeben. Auf Plattformen ohne
entsprechende Sperrmechanik MUST das Verhalten in der Sprachreferenz unter
„Abweichungen" dokumentiert sein statt still abzuweichen.
#### Scenario: Sperren und Freigeben
- **WHEN** ein Datensatzbereich gesperrt und anschließend mit denselben Grenzen freigegeben wird
- **THEN** läuft die Folgeoperation auf diesem Bereich ohne Fehler
### Requirement: Dateisystem-Anweisungen mit plattformübergreifender Pfadsemantik
`KILL`, `NAME … AS`, `FILES`, `CHDIR`, `CHDRIVE`, `MKDIR`, `RMDIR`,
`CURDIR$`, `DIR$` sowie `SHELL` als Anweisung **und** als Funktion SHALL
verfügbar sein. `CURDIR$` SHALL das aktuelle Verzeichnis liefern, `DIR$`
den ersten bzw. — bei leerem Argument — den nächsten Treffer eines
Suchmusters und den leeren String, wenn keiner mehr folgt. Die
`SHELL`-Funktion SHALL den Beendigungsstatus des aufgerufenen Prozesses
liefern. `CHDRIVE` SHALL auf Plattformen ohne Laufwerksbuchstaben
namentlich abgewiesen oder als Non-Feature dokumentiert werden. Pfade SHALL sowohl `\` als auch `/` als Trennzeichen
akzeptieren und auf die Konvention der laufenden Plattform abgebildet
werden; Laufwerksbuchstaben SHALL auf Nicht-Windows-Plattformen als
gewöhnlicher Namensbestandteil behandelt oder mit benannter Ablehnung
abgewiesen werden. Fehlende Dateien MUST Fehler 53 auslösen, fehlende
Pfade Fehler 76 „Path not found".
#### Scenario: Rückwärts-Schrägstrich auf Unix
- **WHEN** `OPEN "unter\d.txt" FOR OUTPUT AS #1` auf einer Unix-Plattform ausgeführt wird
- **THEN** entsteht die Datei `d.txt` im Verzeichnis `unter`
#### Scenario: Verzeichnis durchlaufen
- **WHEN** `DIR$("*.txt")` und danach `DIR$("")` wiederholt ausgewertet werden
- **THEN** liefern sie nacheinander alle passenden Namen und zuletzt den leeren String
#### Scenario: Fehlender Pfad
- **WHEN** `MKDIR` in einem nicht existierenden Elternverzeichnis aufgerufen wird
- **THEN** tritt Laufzeitfehler 76 auf
### Requirement: LPRINT als Druckausgabe
`LPRINT` und `LPRINT USING` SHALL wie `PRINT` formatieren und in einen
Druckerkanal schreiben. `LPOS` SHALL die aktuelle Spalte im
Druckerpuffer liefern. Existiert auf der Plattform kein Druckerkanal,
MUST das Ziel (Datei, Standardausgabe oder Ablehnung) in der
Sprachreferenz unter „Abweichungen" dokumentiert sein.
#### Scenario: LPRINT formatiert wie PRINT
- **WHEN** `LPRINT 1; 2` ausgeführt wird
- **THEN** entspricht der erzeugte Text der Ausgabe von `PRINT 1; 2`
#### Scenario: Druckerspalte
- **WHEN** nach `LPRINT "abc";` die Funktion `LPOS(0)` ausgewertet wird
- **THEN** liefert sie die Spalte hinter dem ausgegebenen Text

View File

@@ -0,0 +1,85 @@
## ADDED Requirements
### Requirement: Korpusabdeckung der Phase-3-Semantik
Der Korpus SHALL um Referenzprogramme mit dokumentierter Sollausgabe für
die Bildschirmsteuerung (`LOCATE`, `COLOR`, `CLS`, `VIEW PRINT`-Scrollen,
`CSRLIN`/`POS`, Zurücklesen per `SCREEN`), die Formatierung
(`PRINT USING` inklusive Feldüberlauf), breite Unicode-Zeichen
(Doppelzellen, Umbruch am rechten Rand), Datum und Zeit sowie die
Datei-E/A in allen drei Zugriffsarten erweitert werden. Dateiprogramme
MUST in einem temporären Arbeitsverzeichnis laufen und dürfen keine
Artefakte im Projektbaum hinterlassen.
#### Scenario: Bildschirmsteuerung als Korpustest
- **WHEN** die Testsuite läuft
- **THEN** existiert ein Korpusprogramm, das mit `LOCATE` und `COLOR` an definierten Positionen ausgibt, und sein Snapshot entspricht der Sollausgabe
#### Scenario: Datei-Korpustest hinterlässt nichts
- **WHEN** ein Datei-E/A-Korpusprogramm gelaufen ist
- **THEN** ist das Arbeitsverzeichnis wieder entfernt und der Projektbaum unverändert
### Requirement: Nachweis der Größenunabhängigkeit
Die Bildschirmgröße des Test-Hosts SHALL je Korpusprogramm explizit
festgelegt und in der Sollausgabe vermerkt sein; ein Vorgabewert MUST NOT
stillschweigend gelten. Mindestens ein Korpusprogramm SHALL bei zwei
verschiedenen Bildschirmgrößen laufen und je Größe eine eigene
Sollausgabe besitzen, um nachzuweisen, dass Löschen, Umbruch, Scrollen
und Cursorgrenzen der jeweiligen Größe folgen. Mindestens ein
Korpusprogramm SHALL eine Größenänderung während der Ausführung
durchlaufen und danach die neuen Grenzen ausnutzen.
#### Scenario: Gleiches Programm bei zwei Größen
- **WHEN** dasselbe Bildschirm-Korpusprogramm bei 80×25 und bei 120×40 läuft
- **THEN** stimmt jede Ausgabe mit der Sollausgabe ihrer Größe überein und die Sollausgaben unterscheiden sich in Umbruch- und Scrollverhalten
#### Scenario: Größenänderung mitten im Programm
- **WHEN** der Test-Host während der Ausführung von 80×25 auf 120×40 wechselt
- **THEN** bleibt der bisherige Inhalt oben links erhalten und die anschließende Ausgabe nutzt die neuen Grenzen
## MODIFIED Requirements
### Requirement: Korpusdateien mit byte-genauer Sollausgabe
Jedes Korpusprogramm `tests/compat/<name>.bas` SHALL eine `<name>.out`
mit dem exakten Sollzustand des Bildschirms besitzen (UTF-8,
LF-Zeilenenden). Die Sollausgabe SHALL den Zellenpuffer als Textbild
abbilden, getrimmt bis zur letzten belegten Zeile und Spalte; nachgestellte
Leerzeichen innerhalb einer Zeile sind signifikant — `PRINT` gibt Zahlen
mit führendem Vorzeichen-/Leerzeichen und nachgestelltem Leerzeichen aus.
Verwendet ein Programm `COLOR`, SHALL die Sollausgabe zusätzlich eine
Attributebene gleicher Abmessung enthalten; ohne `COLOR` MUST sie
entfallen. `.gitattributes` MUST die `.out`-Dateien vor
Zeilenenden-Konvertierung schützen.
#### Scenario: Zahlformatierung in der Sollausgabe
- **WHEN** ein Korpusprogramm `PRINT 1; 2; 3` enthält
- **THEN** lautet die Sollzeile ` 1 2 3 ` (mit nachgestelltem Leerzeichen)
#### Scenario: Getrimmter Snapshot
- **WHEN** ein Korpusprogramm nur zwei Zeilen ausgibt
- **THEN** umfasst die Sollausgabe genau diese zwei Zeilen und keine leeren Folgezeilen
#### Scenario: Attributebene nur bei COLOR
- **WHEN** ein Korpusprogramm ohne `COLOR` läuft
- **THEN** enthält seine `.out` keine Attributebene
### Requirement: Laufzeitvergleich über den Korpus
Ein automatischer Test SHALL jede Korpusdatei `tests/compat/*.bas`
kompilieren, über die VM mit einem Host ohne Terminal ausführen und den
resultierenden Bildschirmzustand gegen die zugehörige `.out`-Datei
vergleichen — getrimmt, mit signifikanten Leerzeichen innerhalb der
Zeilen und, sofern vorhanden, einschließlich der Attributebene. Bei
Abweichung MUST der Test Datei, erste abweichende Zeile sowie Soll und
Ist nennen; weicht nur die Attributebene ab, MUST er Zeile, Spalte, Soll-
und Ist-Attribut nennen.
#### Scenario: Korpus läuft mit korrekter Ausgabe
- **WHEN** die Laufzeit-Testsuite läuft
- **THEN** stimmt der Bildschirmzustand jeder Korpusdatei mit ihrer `.out` überein
#### Scenario: Abweichung wird benannt
- **WHEN** ein Korpusprogramm eine abweichende Ausgabe erzeugt
- **THEN** schlägt der Test fehl und nennt Datei, Zeilennummer, Soll- und Ist-Zeile
#### Scenario: Abweichendes Farbattribut
- **WHEN** ein Korpusprogramm dasselbe Textbild, aber ein abweichendes Attribut erzeugt
- **THEN** schlägt der Test fehl und nennt Zeile, Spalte, Soll- und Ist-Attribut

View File

@@ -0,0 +1,208 @@
## ADDED Requirements
### Requirement: Vollständige Stringbibliothek
Die Bibliothek SHALL sämtliche in der Original-Hilfe dokumentierten
Stringfunktionen und -anweisungen mit der Semantik des Vorbilds
bereitstellen — darunter `LEN`, `LEFT$`, `RIGHT$`, `MID$` (Funktion und
Anweisung), `INSTR`, `UCASE$`, `LCASE$`, `LTRIM$`, `RTRIM$`, `STRING$`,
`SPACE$`, `CHR$`, `ASC`, `STR$`, `VAL`, `HEX$`, `OCT$` und `SWAP` für
Strings. Zeichen sind Unicode-Codepoints (dokumentierte Abweichung).
Randfälle folgen dem Vorbild: `VAL` liest das führende Zahlenpräfix und
überspringt Leerraum, `STR$` stellt nicht-negativen Zahlen ein Leerzeichen
voran, `ASC("")` löst Laufzeitfehler 5 aus, eine Startposition kleiner 1
bei `MID$`/`INSTR` ebenso, `LEFT$`/`RIGHT$` mit Länge über der Stringlänge
liefern den ganzen String. Welche Elemente die Liste umfasst, MUST das
Sprachinventar bestimmen; ein dort als `implementiert` geführtes Element
MUST verfügbar sein.
#### Scenario: VAL mit Restzeichen
- **WHEN** `VAL(" 12.5abc")` ausgewertet wird
- **THEN** ist das Ergebnis 12.5
#### Scenario: Länge über Stringende
- **WHEN** `LEFT$("ab", 10)` ausgewertet wird
- **THEN** ist das Ergebnis `ab`
#### Scenario: Ungültige Startposition
- **WHEN** `MID$("abc", 0)` ausgewertet wird
- **THEN** tritt Laufzeitfehler 5 auf
### Requirement: PRINT USING und FORMAT$
`PRINT USING`, `LPRINT USING` und `FORMAT$` SHALL die Formatzeichen des
Vorbilds unterstützen: numerische Platzhalter (`#`), Dezimalpunkt,
Tausendertrennung (`,`), Vorzeichenführung (`+`, `-`), Währungs- und
Füllzeichen (`$$`, `**`, `**$`), Exponentialform (`^^^^`) sowie die
String-Platzhalter `&`, `!` und `\ \`. Literale Zeichen SHALL unverändert
übernommen werden, `_` SHALL das Folgezeichen literal machen. Passt ein
Wert nicht in das Feld, SHALL die Ausgabe mit einem `%`-Präfix erfolgen
und der Wert vollständig dargestellt werden; dieses Überlaufverhalten MUST
in der Sprachreferenz festgehalten sein. Eine Formatzeichenkette ohne
Platzhalter bei vorhandenen Argumenten MUST Laufzeitfehler 5 auslösen.
`SetFormatCC` SHALL das für `$$` verwendete Währungszeichen und die
zugehörige Stellung setzen; ohne Aufruf gilt ein dokumentierter
Vorgabewert.
#### Scenario: Numerisches Feld mit Nachkommastellen
- **WHEN** `PRINT USING "###.##"; 3.14159` ausgeführt wird
- **THEN** lautet die Ausgabe ` 3.14`
#### Scenario: Feldüberlauf
- **WHEN** `PRINT USING "##"; 12345` ausgeführt wird
- **THEN** beginnt die Ausgabe mit `%` und enthält den vollständigen Wert
#### Scenario: Formatzeichenkette wird wiederverwendet
- **WHEN** `PRINT USING "## "; 1; 2; 3` ausgeführt wird
- **THEN** wird die Formatzeichenkette für jeden Wert erneut angewandt
### Requirement: Datums- und Zeitbibliothek
Die Bibliothek SHALL die Datums- und Zeitfunktionen des Vorbilds
bereitstellen: `DATE$` und `TIME$` als Funktion und als Anweisung, `TIMER`,
`NOW`, `DATESERIAL`, `TIMESERIAL`, `DATEVALUE`, `TIMEVALUE`, `DAY`,
`MONTH`, `YEAR`, `WEEKDAY`, `HOUR`, `MINUTE`, `SECOND`. Serielle Datumswerte
SHALL der Zählweise des Vorbilds folgen (ganzzahliger Anteil = Tag,
Nachkommaanteil = Tageszeit). `WEEKDAY` SHALL 1 für Sonntag liefern.
Ungültige Datums- oder Zeitangaben MUST Laufzeitfehler 5 auslösen.
#### Scenario: Serienwert und Rückumwandlung
- **WHEN** `d = DATESERIAL(1992, 3, 15)` gebildet und `YEAR(d)`, `MONTH(d)`, `DAY(d)` ausgewertet werden
- **THEN** liefern sie 1992, 3 und 15
#### Scenario: Wochentagszählung
- **WHEN** `WEEKDAY` auf einen Sonntag angewandt wird
- **THEN** ist das Ergebnis 1
#### Scenario: Ungültiges Datum
- **WHEN** `DATESERIAL(1992, 13, 1)` ausgewertet wird
- **THEN** tritt Laufzeitfehler 5 auf
### Requirement: Kompatibler Zufallszahlengenerator
`RND` und `RANDOMIZE` SHALL die Zahlenfolge des Vorbilds reproduzieren:
gleicher Startwert MUST dieselbe Folge liefern. `RND` ohne Argument und
mit positivem Argument SHALL die nächste Zahl liefern, mit Argument 0 die
zuletzt gelieferte wiederholen, mit negativem Argument die Folge auf einen
aus dem Argument abgeleiteten Startwert setzen. `RANDOMIZE` ohne Argument
SHALL einen Startwert erfragen, `RANDOMIZE TIMER` einen zeitabhängigen
setzen. Der verwendete Algorithmus MUST in der Sprachreferenz dokumentiert
sein.
#### Scenario: Reproduzierbare Folge
- **WHEN** zweimal `RANDOMIZE 42` gefolgt von drei `RND`-Aufrufen ausgeführt wird
- **THEN** sind beide Dreierfolgen identisch
#### Scenario: RND(0) wiederholt
- **WHEN** nach einem `RND`-Aufruf `RND(0)` ausgewertet wird
- **THEN** liefert es denselben Wert wie der vorangegangene Aufruf
### Requirement: Mathematikbibliothek mit Überlaufverhalten
Die Bibliothek SHALL die numerischen Funktionen des Vorbilds
bereitstellen (`ABS`, `SGN`, `INT`, `FIX`, `SQR`, `EXP`, `LOG`, `SIN`,
`COS`, `TAN`, `ATN`, `CINT`, `CLNG`, `CSNG`, `CDBL`, `CCUR`). Rundung
SHALL kaufmännisch-symmetrisch zur geraden Zahl erfolgen (Banker's
Rounding). Überschreitet ein Ergebnis den Wertebereich des Zieltyps, MUST
Laufzeitfehler 6 „Overflow" auftreten; `SQR` und `LOG` mit unzulässigem
Argument MUST Laufzeitfehler 5 auslösen, Division durch null Fehler 11.
#### Scenario: Rundung zur geraden Zahl
- **WHEN** `CINT(0.5)` und `CINT(1.5)` ausgewertet werden
- **THEN** liefern sie 0 und 2
#### Scenario: Überlauf bei Konvertierung
- **WHEN** `CINT(40000)` ausgewertet wird
- **THEN** tritt Laufzeitfehler 6 auf
#### Scenario: Wurzel aus negativer Zahl
- **WHEN** `SQR(-1)` ausgewertet wird
- **THEN** tritt Laufzeitfehler 5 auf
### Requirement: Finanzmathematische Funktionen
Die Bibliothek SHALL die finanzmathematischen Funktionen des Vorbilds
bereitstellen: `FV#` (Endwert), `PV#` (Barwert), `Pmt#` (Zahlung),
`IPmt#` (Zinsanteil), `PPmt#` (Tilgungsanteil), `NPer#` (Anzahl
Zahlungsperioden), `Rate#` (Zinssatz), `NPV#` (Kapitalwert), `IRR#`
(interner Zinsfuß), `MIRR#` (modifizierter interner Zinsfuß), `SLN#`
(lineare Abschreibung), `SYD#` (arithmetisch-degressive Abschreibung) und
`DDB#` (geometrisch-degressive Abschreibung). Alle SHALL in DOUBLE
rechnen. Das Argument für die Fälligkeit SHALL zwischen nachschüssiger
und vorschüssiger Zahlung unterscheiden. Ungültige Argumente — etwa ein
Zinssatz von null bei `NPer#` oder eine Periode außerhalb der Laufzeit
bei `IPmt#`/`PPmt#` — MUST Laufzeitfehler 5 auslösen. `Rate#` und `IRR#`
nähern sich iterativ; findet die Näherung keine Lösung, MUST
Laufzeitfehler 5 auftreten statt eines stillen Ersatzwerts. Die
Abbruchbedingung der Näherung MUST in docs/bibliothek.md dokumentiert
sein.
#### Scenario: Endwert einer Rentenzahlung
- **WHEN** `FV#` für 10 Perioden, 5 % Zins und eine Zahlung von 100 nachschüssig ausgewertet wird
- **THEN** entspricht das Ergebnis dem dokumentierten Sollwert der Bibliotheksreferenz
#### Scenario: Zins- und Tilgungsanteil ergänzen sich
- **WHEN** `IPmt#` und `PPmt#` für dieselbe Periode desselben Darlehens ausgewertet werden
- **THEN** ist ihre Summe gleich `Pmt#` für dieses Darlehen
#### Scenario: Näherung ohne Lösung
- **WHEN** `IRR#` mit einer Zahlungsreihe ohne Vorzeichenwechsel ausgewertet wird
- **THEN** tritt Laufzeitfehler 5 auf
### Requirement: System- und Umgebungsanweisungen
`ENVIRON$` SHALL Umgebungsvariablen nach Namen oder Position liefern,
`ENVIRON` sie setzen. `COMMAND$` SHALL die Aufrufargumente ohne
Programmnamen liefern. `FRE` SHALL einen Speicherwert liefern, `CLEAR`
Variablen zurücksetzen, `TRON`/`TROFF` die Ablaufverfolgung schalten.
`STACK` SHALL als Anweisung die Stapelgröße setzen und als Funktion die
verfügbare Stapelgröße liefern. `SYSTEM` SHALL das Programm beenden und
die Kontrolle an das Betriebssystem zurückgeben. `ERDEV` und `ERDEV$`
SHALL den Fehlercode bzw. den Namen des zuletzt meldenden Geräts
liefern. Nicht abbildbare Größen (Speicher- und Stapelwerte, Gerätefehler)
MUST einen definierten, dokumentierten Ersatzwert liefern statt still zu
variieren; der Ersatzwert MUST in docs/sprachreferenz.md unter
„Abweichungen" stehen.
#### Scenario: Umgebungsvariable lesen
- **WHEN** eine Umgebungsvariable gesetzt ist und `ENVIRON$("NAME")` ausgewertet wird
- **THEN** liefert es deren Wert
#### Scenario: SYSTEM beendet das Programm
- **WHEN** ein Programm `SYSTEM` ausführt
- **THEN** endet es mit demselben Beendigungsstatus wie bei `END`
#### Scenario: Unbekannte Umgebungsvariable
- **WHEN** `ENVIRON$` für einen nicht gesetzten Namen ausgewertet wird
- **THEN** ist das Ergebnis der leere String
## MODIFIED Requirements
### Requirement: Host-Abstraktion für Konsolen-E/A
Alle Konsolenwirkungen der Ausführungsschicht SHALL über eine
austauschbare Host-Schnittstelle laufen, die einen Bildschirmzustand
darstellt statt eines Zeichenstroms: Übergabe des Zellenpuffers zur
Anzeige, Abholen von Tastatur- und Größenänderungsereignissen sowie die
Abbruchprüfung (Strg+Untbr). Host-Aufrufe dürfen blockieren.
`tbc run` SHALL einen Konsolen-Host verwenden, der den Puffer im Terminal
darstellt und Tastendrücke liefert. Tests MUST einen Host verwenden
können, der ohne Terminal arbeitet, den Bildschirmzustand nach jedem
Schritt festhält und vorbereitete Tastendrücke einspeist. Eine
Bildschirmwirkung, die nur über einen Zeichenstrom ausdrückbar wäre, MUST
NOT vorkommen: `LOCATE`, `COLOR`, `CSRLIN`, `POS` und die Funktionsform
von `SCREEN` MUST über diese Schnittstelle vollständig abgebildet sein.
#### Scenario: Capture-Host im Test
- **WHEN** ein Korpusprogramm im Test-Harness läuft
- **THEN** entsteht der vollständige Bildschirmzustand im Capture-Host und ist vergleichbar, ohne dass ein Terminal vorhanden ist
#### Scenario: Cursorbewegung über die Schnittstelle
- **WHEN** ein Programm `LOCATE 5, 10 : PRINT "x"` ausführt
- **THEN** meldet der Host-Bildschirmzustand das Zeichen `x` an Position (5, 10)
#### Scenario: Tastendruck aus dem Host
- **WHEN** der Test-Host einen vorbereiteten Tastendruck liefert und das Programm `INKEY$` auswertet
- **THEN** erhält das Programm diese Taste
## REMOVED Requirements
### Requirement: Korpus-Stringfunktionen
**Reason**: Die auf den Phase-2-Korpus zugeschnittene Teilliste wird durch
die Anforderung „Vollständige Stringbibliothek" ersetzt, die den
vollständigen dokumentierten Funktionsumfang fordert und ihren Umfang aus
dem Sprachinventar bezieht.
**Migration**: Alle bisher geforderten Funktionen und Randfälle sind in
„Vollständige Stringbibliothek" enthalten; kein Verhalten entfällt.

View File

@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Jedes dokumentierte Element besitzt eine Signatur
Die Semantik SHALL für jedes im Sprachinventar geführte Element eine
Signatur besitzen (Argumentanzahl und -typen) — auch für Elemente, deren
Laufzeitverhalten noch aussteht. Ein im Inventar geführtes Element MUST
NOT als unbekannter Bezeichner oder mit einem generischen Syntaxfehler
abgewiesen werden; die Diagnose bzw. der Laufzeitfehler MUST es
namentlich nennen (Guiding Principle). Das gilt insbesondere für die
Gruppen, die bisher gar keine Signatur haben: die finanzmathematischen
Funktionen, die Record-Konvertierungsfunktionen (`MK…$`/`CV…`), die
Dateisystem-Funktionen `CURDIR$`, `DIR$`, `CHDRIVE`, sowie `LPOS`,
`STACK`, `SYSTEM`, `ERDEV`/`ERDEV$` und `SetFormatCC`.
#### Scenario: Element ohne Laufzeitverhalten wird namentlich abgewiesen
- **WHEN** ein Modul eine im Inventar als `offen` geführte Funktion verwendet
- **THEN** nennt die Ablehnung deren Namen und nicht „unbekannter Bezeichner"
#### Scenario: Argumentprüfung ohne Laufzeitverhalten
- **WHEN** ein Modul `MKL$()` ohne Argument enthält
- **THEN** meldet die Semantik eine Argumentanzahl-Diagnose, die `MKL$` nennt
### Requirement: Modulweites ON ERROR ist aus Prozeduren ansprechbar
`ON ERROR GOTO label` ohne `LOCAL` SHALL einen modulweiten Fehlerhandler
setzen, dessen Sprungziel auch dann aufgelöst wird, wenn die Anweisung in
einer Prozedur steht und das Label auf Modulebene liegt. Nur
`ON LOCAL ERROR GOTO label` SHALL prozedurlokale Handler-Auflösung
verlangen. Ein modulweites `ON ERROR` in einer Prozedur MUST NOT wie
`ON LOCAL ERROR` behandelt werden, und ein auf Modulebene vorhandenes
Label MUST NOT als „Label not defined" gemeldet werden.
#### Scenario: Handler auf Modulebene aus einer Prozedur
- **WHEN** ein Modul das Label `Fehler:` auf Modulebene führt und eine `SUB` die Anweisung `ON ERROR GOTO Fehler` enthält
- **THEN** wird das Label aufgelöst und es entsteht keine Diagnose
#### Scenario: ON LOCAL ERROR bleibt prozedurlokal
- **WHEN** eine `SUB` die Anweisung `ON LOCAL ERROR GOTO Fehler` enthält und `Fehler:` nur auf Modulebene existiert
- **THEN** wird „Label not defined" gemeldet
### Requirement: DATA-Elemente behalten ihren Rohtext
Unquotierte Elemente einer `DATA`-Anweisung SHALL exakt so erhalten
bleiben, wie sie im Quelltext stehen — insbesondere in ihrer Groß- und
Kleinschreibung. Die Normalisierung von Bezeichnern MUST NOT auf
`DATA`-Elemente wirken. Führender und nachgestellter Leerraum eines
unquotierten Elements SHALL entfernt werden, innenliegender erhalten
bleiben.
#### Scenario: Gemischte Schreibweise bleibt erhalten
- **WHEN** ein Modul `DATA Hallo Welt` enthält und `READ s$` ausgeführt wird
- **THEN** enthält `s$` den Text `Hallo Welt` mit unveränderter Groß- und Kleinschreibung
#### Scenario: Leerraum an den Rändern
- **WHEN** ein Modul `DATA ab cd ` enthält und gelesen wird
- **THEN** lautet der Wert `ab cd`

View File

@@ -0,0 +1,74 @@
## Purpose
Das Sprachinventar ist das Messinstrument für die Leitplanke
„Vollständigkeit ist das Soll": eine gepflegte Liste aller in der
Original-Hilfe des Vorbilds dokumentierten Anweisungen, Funktionen und
Metabefehle mit Abdeckungsstatus, gegen die sich der Implementierungsstand
jederzeit prüfen lässt.
## ADDED Requirements
### Requirement: Vollständiges Inventar der Original-Hilfe
`docs/inventar.md` SHALL jede in der Original-Hilfe des Vorbilds
dokumentierte Anweisung, Funktion, Metabefehl, Operator-Schlüsselwort und
Klausel als eigenen Eintrag führen. Ein dokumentiertes Element MUST NOT
fehlen; Vollständigkeit gegenüber der Quelle geht der Kürze vor. Jeder
Eintrag SHALL Name, Art (Anweisung/Funktion/Metabefehl), Themengruppe,
Status und Fundstelle tragen.
#### Scenario: Dokumentiertes Element ist erfasst
- **WHEN** die Original-Hilfe die Anweisung `VIEW PRINT` dokumentiert
- **THEN** enthält das Inventar einen Eintrag `VIEW PRINT` mit Art, Gruppe, Status und Fundstelle
#### Scenario: Non-Feature ist erfasst, nicht weggelassen
- **WHEN** `PEEK` als Non-Feature deklariert ist
- **THEN** steht `PEEK` mit Status `Non-Feature` im Inventar und die Fundstelle verweist auf den Abschnitt „Abweichungen" der Sprachreferenz
### Requirement: Festes Statusvokabular
Der Status eines Eintrags SHALL genau einen von drei Werten tragen:
`implementiert` (Frontend senkt ab und die Laufzeit führt aus),
`offen` (dokumentiert, noch nicht verfügbar) oder `Non-Feature`
(vom Projektinhaber abgelehnt und in der Sprachreferenz unter
„Abweichungen" gelistet). Ein Eintrag mit Status `Non-Feature` MUST eine
Fundstelle in der Sprachreferenz nennen.
#### Scenario: Unzulässiger Status
- **WHEN** ein Eintrag den Status `teilweise` trägt
- **THEN** schlägt die Inventarprüfung mit Nennung des Eintrags fehl
#### Scenario: Non-Feature ohne Fundstelle
- **WHEN** ein Eintrag den Status `Non-Feature` trägt, aber keine Fundstelle nennt
- **THEN** schlägt die Inventarprüfung mit Nennung des Eintrags fehl
### Requirement: Automatischer Abgleich Inventar gegen Implementierung
Ein automatischer Test SHALL das Inventar gegen den tatsächlichen
Implementierungsstand prüfen und bei jeder Divergenz fehlschlagen. Als
`implementiert` markierte Elemente MUST im Frontend eine Signatur besitzen
und MUST NOT auf einen „nicht verfügbar"-Marker absenken; als `offen`
markierte Elemente MUST auf einen solchen Marker absenken oder ohne
Signatur sein; als `Non-Feature` markierte Elemente MUST zur Compile-Zeit
mit „Feature unavailable" abgewiesen werden. Der Test MUST das
divergierende Element, seinen Inventarstatus und den vorgefundenen
Zustand benennen.
#### Scenario: Inventar hinkt der Implementierung hinterher
- **WHEN** `LOCATE` implementiert wurde, im Inventar aber noch `offen` steht
- **THEN** schlägt der Abgleich fehl und nennt `LOCATE`, Status `offen` und den Befund „implementiert"
#### Scenario: Inventar behauptet zu viel
- **WHEN** ein Eintrag `implementiert` trägt, das Element aber weiterhin auf den „nicht verfügbar"-Marker absenkt
- **THEN** schlägt der Abgleich fehl und nennt das Element
#### Scenario: Abdeckungsstand ist abrufbar
- **WHEN** der Abgleich läuft
- **THEN** nennt er die Zahl der Einträge je Status als Abdeckungsstand
### Requirement: Keine stillen Lücken bei dokumentierten Elementen
Ein Element mit Status `offen` MUST bei Verwendung eine benannte
Ablehnung erzeugen — entweder eine Compile-Diagnose oder den Laufzeitfehler
für nicht verfügbare Funktionen —, die das Element namentlich nennt. Ein
generischer Syntaxfehler oder „unbekannter Bezeichner" für ein
dokumentiertes Element MUST NOT auftreten (Guiding Principle).
#### Scenario: Offenes Element wird namentlich abgewiesen
- **WHEN** ein Programm eine im Inventar als `offen` geführte Anweisung verwendet
- **THEN** nennt die Ablehnung den Namen der Anweisung, nicht nur „Syntaxfehler"

View File

@@ -0,0 +1,173 @@
## ADDED Requirements
### Requirement: Zellenpuffer ohne Terminalabhängigkeit
Der Zellenpuffer mit der vollständigen Bildschirmsemantik (Cursor,
Farbattribute, Umbruch, Scrollen, Scrollbereich, Größenänderung) SHALL
ohne Terminal instanziierbar, veränderbar und auslesbar sein. Die
Anbindung an ein konkretes Terminal SHALL ausschließlich in der
Darstellungsschicht liegen. Damit MUST jedes Bildschirmverhalten in
automatischen Tests ohne Terminal prüfbar sein, und die Ausführungsschicht
MUST NOT von einer Terminal-Bibliothek abhängen.
#### Scenario: Bildschirmverhalten im Test ohne Terminal
- **WHEN** ein Testprogramm in einer Umgebung ohne Terminal `LOCATE 5, 10 : PRINT "x"` ausführt
- **THEN** trägt die Zelle (5, 10) das Zeichen `x` und der Test benötigt kein Terminal
### Requirement: Bildschirmanweisungen des Dialekts
`CLS`, `COLOR`, `LOCATE`, `WIDTH`, `VIEW PRINT` und die Anweisungsform von
`SCREEN` SHALL auf dem Zellenpuffer wirken. `CLS` SHALL den Scrollbereich
löschen und den Cursor an dessen Anfang setzen; `CLS 2` SHALL nur den
Textbereich löschen. `COLOR` SHALL Vordergrund und Hintergrund für
nachfolgende Ausgaben setzen, ausgelassene Argumente lassen den bisherigen
Wert unverändert. `WIDTH` SHALL die Spalten- und Zeilenzahl setzen, soweit
die Darstellungsfläche es zulässt. `VIEW PRINT oben TO unten` SHALL den
Scrollbereich begrenzen, `VIEW PRINT` ohne Argumente ihn auf den ganzen
Bildschirm zurücksetzen.
#### Scenario: CLS setzt Cursor zurück
- **WHEN** nach Ausgaben in Zeile 10 `CLS` ausgeführt wird
- **THEN** ist der Puffer leer und `CSRLIN` liefert 1, `POS(0)` liefert 1
#### Scenario: COLOR wirkt nur auf Folgeausgaben
- **WHEN** `PRINT "a" : COLOR 14, 1 : PRINT "b"` ausgeführt wird
- **THEN** trägt die Zelle mit `a` das vorherige Attribut und die Zelle mit `b` Vordergrund 14 auf Hintergrund 1
#### Scenario: Ausgelassenes COLOR-Argument
- **WHEN** nach `COLOR 14, 1` die Anweisung `COLOR , 4` ausgeführt wird
- **THEN** bleibt der Vordergrund 14 und der Hintergrund wird 4
### Requirement: Bildschirm-Abfragefunktionen
`CSRLIN` SHALL die aktuelle Cursorzeile liefern, `POS(0)` die aktuelle
Cursorspalte, beide 1-basiert. Die Funktionsform `SCREEN(zeile, spalte
[, farbe])` SHALL das Zeichen an der genannten Position als Codepoint
liefern, bei gesetztem dritten Argument stattdessen dessen Farbattribut.
Positionen außerhalb des Bildschirms MUST Laufzeitfehler 5 „Illegal
function call" auslösen.
#### Scenario: Zeichen zurücklesen
- **WHEN** `LOCATE 3, 7 : PRINT "Q";` ausgeführt und danach `SCREEN(3, 7)` ausgewertet wird
- **THEN** liefert `SCREEN(3, 7)` den Codepoint von `Q`
#### Scenario: Abfrage außerhalb des Bildschirms
- **WHEN** bei 80×25 `SCREEN(30, 1)` ausgewertet wird
- **THEN** tritt Laufzeitfehler 5 auf
### Requirement: Tastatureingabe ohne Zeilenmodell
`INKEY$` SHALL ohne zu blockieren die nächste anstehende Taste liefern:
den leeren String bei leerem Puffer, ein Zeichen bei einer
Zeichentaste, eine zwei Zeichen lange Folge mit führendem Nullzeichen bei
einer Sondertaste. `INPUT$(n [, #dateinummer])` SHALL genau `n` Zeichen
lesen und dabei blockieren, ohne sie am Bildschirm zu wiederholen.
#### Scenario: INKEY$ bei leerem Tastaturpuffer
- **WHEN** `INKEY$` ohne anstehende Taste ausgewertet wird
- **THEN** liefert es den leeren String und blockiert nicht
#### Scenario: Sondertaste als zwei Zeichen
- **WHEN** F1 gedrückt wurde und `INKEY$` ausgewertet wird
- **THEN** hat das Ergebnis die Länge 2 und beginnt mit dem Nullzeichen
### Requirement: Keine feste Bildschirmgröße im Verhalten
Kein beobachtbares Verhalten SHALL eine feste Spalten- oder Zeilenzahl
voraussetzen. Wo die Referenz des Vorbilds von 80×25 spricht, ist stets
der volle aktuelle Bildschirm gemeint. Insbesondere SHALL `CLS` den
vollen aktuellen Bildschirm löschen, der Scrollbereich ohne eigene
`VIEW PRINT`-Einstellung den vollen aktuellen Bildschirm umfassen, der
Zeilenumbruch an der aktuell letzten Spalte erfolgen, das Scrollen an
der aktuell letzten Zeile des Bereichs auslösen und die Grenzprüfung von
`LOCATE` und der Funktionsform von `SCREEN` gegen die aktuellen
Abmessungen prüfen. Die Werte 80 und 25 MUST NOT als Grenze in
beobachtbarem Verhalten auftreten.
#### Scenario: Löschen und Scrollen auf großem Bildschirm
- **WHEN** der Bildschirm 120×40 misst, in Zeile 40 ein Umbruch erfolgt und danach `CLS` ausgeführt wird
- **THEN** scrollt der Bildschirm erst an Zeile 40 und `CLS` löscht alle 40 Zeilen
#### Scenario: Adressierbarkeit jenseits von 80×25
- **WHEN** der Bildschirm 120×40 misst und `LOCATE 40, 120` ausgeführt wird
- **THEN** entsteht kein Fehler und `CSRLIN` liefert 40, `POS(0)` liefert 120
### Requirement: Größenänderung zur Laufzeit
Ändert sich die Größe der Darstellungsfläche während ein Programm läuft,
SHALL die Größenänderung als Ereignis bis zum Bildschirmzustand
durchgereicht und der Zellenpuffer angepasst werden. Der Inhalt SHALL
oben links erhalten bleiben; der Cursor SHALL in die neuen Grenzen
geklemmt werden; ein `VIEW PRINT`-Bereich, der nicht mehr vollständig in
den Bildschirm passt, SHALL auf die neuen Grenzen geklemmt und, falls er
dadurch leer würde, auf den vollen Bildschirm zurückgesetzt werden. Alle
programmseitig sichtbaren Größen — `CSRLIN`, `POS`, die Grenzen von
`LOCATE` und der Funktionsform von `SCREEN` — MUST unmittelbar nach der
Änderung die neuen Abmessungen widerspiegeln.
#### Scenario: Vergrößerung während der Ausführung
- **WHEN** ein laufendes Programm bei 80×25 ausgibt und die Fläche auf 120×40 wächst
- **THEN** bleibt der bisherige Inhalt oben links stehen und `LOCATE 40, 120` ist danach zulässig
#### Scenario: Verkleinerung klemmt den Cursor
- **WHEN** der Cursor auf Zeile 40 steht und die Fläche auf 80×25 schrumpft
- **THEN** liegt der Cursor danach innerhalb der neuen Grenzen
#### Scenario: Scrollbereich überlebt die Verkleinerung
- **WHEN** `VIEW PRINT 30 TO 38` gesetzt ist und die Fläche auf 25 Zeilen schrumpft
- **THEN** ist der Scrollbereich danach gültig und liegt vollständig innerhalb des Bildschirms
## MODIFIED Requirements
### Requirement: Dynamische Terminalgröße mit Mindestmaß
Der Bildschirm SHALL der Terminalgröße folgen (Puffer per `resize`
anpassbar; Inhalt bleibt oben links erhalten, Cursor wird geklemmt).
Die Größe SHALL zu jedem Zeitpunkt aus der Darstellungsfläche abgeleitet
und nicht fest vorgegeben werden. Die Mindestgröße ist 80×25: kleinere
Werte werden auf 80×25 geklemmt, und ist die Render-Fläche kleiner als
80×25, SHALL nur ein Hinweis „Terminal zu klein" mit Ist- und
Mindestgröße gerendert werden. Dieses Mindestmaß ist ausschließlich eine
untere Schranke der Darstellung; es MUST NOT als Bildschirmgröße im
Verhalten des Programms auftreten, solange die Fläche größer ist.
#### Scenario: Vergrößertes Terminal
- **WHEN** der Bildschirm auf 120×40 gesetzt wird
- **THEN** sind alle 120 Spalten und 40 Zeilen adressierbar (`LOCATE 40, 120`)
#### Scenario: Zu kleines Terminal
- **WHEN** die Render-Fläche 60×20 misst
- **THEN** erscheint statt des Puffers der Hinweis mit Minimum 80×25
### Requirement: Unicode-Zellenmodell
Der Puffer SHALL Unicode-Zeichen speichern (keine CP437-Emulation).
Zeichen mit Darstellungsbreite 2 (u. a. Emoji, CJK) SHALL zwei
nebeneinanderliegende Zellen belegen (Entscheidung 2026-09-02): die erste
trägt das Zeichen, die zweite ist als Fortsetzung markiert und MUST NOT
eigenständig beschrieben werden. Der Cursor SHALL nach der Ausgabe eines
breiten Zeichens um zwei Spalten vorrücken; `POS` SHALL die Spalte des
Zeichenanfangs zählen. Passt ein breites Zeichen nicht mehr in die letzte
Spalte, SHALL es vollständig in die nächste Zeile umgebrochen werden und
die letzte Spalte leer bleiben. `LOCATE` auf die Fortsetzungszelle SHALL
auf den Zeichenanfang wirken.
#### Scenario: Umlaute und Symbole
- **WHEN** `Ä☃` ausgegeben wird
- **THEN** belegen `Ä` und `☃` je genau eine Zelle
#### Scenario: Breites Zeichen belegt zwei Zellen
- **WHEN** an Spalte 1 ein CJK-Zeichen ausgegeben wird
- **THEN** ist Spalte 2 als Fortsetzung belegt und der Cursor steht auf Spalte 3
#### Scenario: Breites Zeichen am rechten Rand
- **WHEN** bei 80 Spalten der Cursor auf Spalte 80 steht und ein breites Zeichen ausgegeben wird
- **THEN** bleibt Spalte 80 leer und das Zeichen steht in Spalte 1 der Folgezeile
### Requirement: 1-basierte Cursor-API mit Bereichsprüfung
`LOCATE`, `CSRLIN` und `POS` SHALL 1-basiert arbeiten. `LOCATE`
außerhalb der aktuellen Bildschirmgrenzen SHALL Laufzeitfehler 5
„Illegal function call" auslösen. Ausgelassene Argumente von `LOCATE`
SHALL den jeweiligen Wert unverändert lassen; die Argumente für
Cursorsichtbarkeit und Cursorform SHALL entgegengenommen und, soweit das
Terminal sie nicht abbilden kann, folgenlos bleiben.
#### Scenario: Grenzprüfung
- **WHEN** bei 80×25 `LOCATE 26, 1` aufgerufen wird
- **THEN** tritt Laufzeitfehler 5 auf
#### Scenario: Ausgelassenes LOCATE-Argument
- **WHEN** der Cursor auf (5, 9) steht und `LOCATE , 3` ausgeführt wird
- **THEN** steht der Cursor auf (5, 3)

View File

@@ -0,0 +1,106 @@
## 1. Vollständigkeits-Inventar
- [x] 1.1 Anweisungs-, Funktions- und Metabefehlsliste aus der Original-Hilfe erheben (dos-help.soulsphere.org, Topic-Listen, README der Professional Edition) und als Rohliste ablegen; verifiziert dadurch, dass die Zahl der erfassten Themen gegen die Topic-Übersicht der Quelle abgeglichen und im Change notiert ist
- [x] 1.2 `docs/inventar.md` als Pipe-Tabelle mit den Spalten Name, Art, Gruppe, Status, Fundstelle anlegen und alle Einträge aus 1.1 mit Erststatus füllen; verifiziert dadurch, dass jede Zeile sechs Spalten hat (Name, Art, Gruppe, Status, Fundstelle, Quelle) und das Statusvokabular nur `implementiert`/`offen`/`Non-Feature` enthält
- [x] 1.3 Abgleichstest schreiben, der die Tabelle parst und gegen `builtin_fn`, `builtin_stmt`, `banned_feature` sowie die `Unsupported`-Marker hält; verifiziert dadurch, dass der Test auf dem Ist-Stand grün ist und nach einer künstlich falsch gesetzten Statuszeile mit Nennung des Elements fehlschlägt
- [x] 1.4 Prüfung ergänzen, dass `Non-Feature`-Einträge eine Fundstelle in docs/sprachreferenz.md nennen und dokumentierte `offen`-Elemente namentlich abgewiesen werden (kein generischer Syntaxfehler); verifiziert durch je einen Testfall pro Regel
- [x] 1.5 Abdeckungsstand (Anzahl je Status) im Testausgabetext ausweisen; verifiziert dadurch, dass `cargo test` die drei Zahlen nennt
- [x] 1.6 Vom Inventar aufgedeckte Abweichung von der Phase-1-Spec schließen: Non-Features, die der Compiler nicht abwies, ergänzen — `CALLS`, `SSEG`, `SSEGADD`, `StringAddress`/`Assign`/`Length`/`Release`, `POINT`, `VIEW`, `COM`, `PEN`, `STRIG`, `STICK` in `banned_feature`; `LINE` ohne `INPUT` und `OPEN "COMn:"` als „Feature unavailable"; verifiziert durch `dokumentierte_elemente_werden_namentlich_abgewiesen`
- [x] 1.7 Signaturen in `builtin_fn`/`builtin_stmt` für alle bislang signaturlosen Inventarelemente ergänzen (Finanzmathematik, `MK…$`/`CV…`, `CURDIR$`, `DIR$`, `CHDRIVE`, `LPOS`, `STACK`, `SYSTEM`, `ERDEV`/`ERDEV$`, `SetFormatCC`); verifiziert durch Frontend-Test über ein Programm, das jedes davon einmal verwendet, ohne Diagnose „unbekannter Bezeichner"
- [x] 1.8 Sicherstellen, dass jedes als `offen` geführte Element namentlich abgewiesen wird (Diagnose oder Laufzeitfehler nennt den Namen); verifiziert durch Test, dass `MKL$()` ohne Argument eine Diagnose mit dem Namen `MKL$` erzeugt
## 2. Bruchschritt: Puffer, Host, Snapshot-Harness
- [x] 2.1 `Cell`/`TextScreen` samt Tests von `tb-ui::screen` nach `tb-runtime::screen` verschieben, ratatui-Importe entfernen; verifiziert dadurch, dass `cargo test -p tb-runtime` grün ist und `tb-runtime/Cargo.toml` weiterhin weder ratatui noch crossterm führt
- [x] 2.2 `tb-runtime` als Abhängigkeit in `tb-ui` aufnehmen; `basic_color()` und `impl Widget` in `tb-ui` belassen und auf den Runtime-Typ umstellen; verifiziert dadurch, dass `cargo run -p tb-ui --example spike` unverändert rendert
- [x] 2.3 `Host`-Trait neu schneiden: Übergabe des Zellenpuffers zur Anzeige, Ereignisabholung (Taste, Größenänderung), Abbruchprüfung; verifiziert dadurch, dass `tb-runtime` kompiliert und der Trait keine Stream-Methoden mehr trägt
- [x] 2.4 `ConsoleHost` auf Darstellung des Puffers im Terminal und Tastaturereignisse umstellen; verifiziert dadurch, dass `tbc run tests/compat/hello.bas` im Terminal die erwartete Ausgabe zeigt
- [x] 2.5 `CaptureHost` auf Bildschirmzustand plus vorbereitete Tastendrücke umstellen; verifiziert durch einen Unit-Test, der Zeichen und Attribut einer Zelle nach `LOCATE`/`COLOR`/`PRINT` prüft
- [x] 2.6 Darstellungsaufrufe an die bestehenden Tick-Grenzen und vor blockierende Eingaben hängen; verifiziert dadurch, dass ein Programm mit Ausgabe vor `INPUT` den Text vor der Eingabeaufforderung zeigt
- [x] 2.7 Snapshot-Serialisierung schreiben: Textbild getrimmt bis zur letzten belegten Zeile/Spalte, Attributebene nur bei verwendetem `COLOR`; verifiziert durch Unit-Tests für getrimmte Ausgabe, signifikante Zeilen-Innenleerzeichen und Weglassen der Attributebene
- [x] 2.8 Harness `crates/tb-cli/tests/compat.rs` auf Snapshot-Vergleich umstellen, inklusive Fehlermeldung mit Datei, erster abweichender Zeile sowie Zeile/Spalte/Soll/Ist bei Attributabweichung; verifiziert durch je einen absichtlich abweichenden Testfall
- [x] 2.9 Die acht bestehenden `.out`-Dateien neu erzeugen und jeden Diff einzeln prüfen und begründen; verifiziert dadurch, dass `PRINT`-only-Programme unverändert bleiben und jede Abweichung im Change begründet notiert ist
- [x] 2.10 `.gitattributes` auf die neuen Snapshot-Dateien prüfen; verifiziert dadurch, dass `.out`-Dateien weiterhin vor Zeilenenden-Konvertierung geschützt sind
## 3. Größenunabhängigkeit und Laufzeit-Größenänderung
- [x] 3.1 Alle Bildschirmpfade auf die Abmessungen des Puffers umstellen (`CLS`, voreingestellter Scrollbereich, Umbruchspalte, Scrollzeile, Grenzprüfungen von `LOCATE` und `SCREEN()`); verifiziert dadurch, dass eine Suche über `tb-runtime` und `tb-ui` keine Vergleiche gegen die Literale 80 oder 25 außerhalb der Mindestmaß-Prüfung der Darstellungsschicht mehr findet
- [x] 3.2 Größenänderungsereignis vom Host bis zum Puffer durchreichen; verifiziert durch Test, dass ein vom Test-Host ausgelöster Wechsel die Abmessungen des Puffers ändert
- [x] 3.3 Anpassung bei Größenänderung: Inhalt oben links erhalten, Cursor klemmen, `VIEW PRINT`-Bereich klemmen und bei leerem Ergebnis auf den vollen Bildschirm zurücksetzen; verifiziert durch je einen Unit-Test für Vergrößerung, Verkleinerung und ungültig gewordenen Scrollbereich
- [x] 3.4 Test-Host mit explizit gesetzter Bildschirmgröße je Korpusprogramm, ohne stillschweigenden Vorgabewert; verifiziert dadurch, dass ein Korpusprogramm ohne Größenangabe vom Harness abgewiesen wird
- [x] 3.5 Bildschirm-Korpusprogramm bei 80×25 und 120×40 mit je eigener Sollausgabe; verifiziert dadurch, dass sich die Sollausgaben in Umbruch- und Scrollverhalten unterscheiden und beide Läufe grün sind
- [x] 3.6 Korpusprogramm mit Größenänderung während der Ausführung; verifiziert dadurch, dass der Snapshot den erhaltenen Inhalt und die Ausgabe in den neuen Grenzen zeigt
## 4. Unicode-Doppelzellen
- [x] 4.1 `unicode-width` als Abhängigkeit aufnehmen und Zellenmodell um Fortsetzungszellen erweitern; verifiziert durch Unit-Test, dass ein CJK-Zeichen an Spalte 1 die Spalte 2 als Fortsetzung belegt
- [x] 4.2 Cursorfortschritt, `POS` und Umbruch am rechten Rand auf Darstellungsbreite umstellen; verifiziert durch Unit-Tests für Vorrücken um zwei Spalten und vollständigen Umbruch bei belegter letzter Spalte
- [x] 4.3 Überschreiben einer Zellenhälfte leert die andere; verifiziert durch Unit-Test, dass keine Geisterzeichen zurückbleiben
- [x] 4.4 `LOCATE` auf eine Fortsetzungszelle auf den Zeichenanfang abbilden; verifiziert durch Unit-Test
- [x] 4.5 Korpusprogramm mit CJK und Emoji inklusive Randumbruch ergänzen; verifiziert dadurch, dass der Snapshot der dokumentierten Sollausgabe entspricht
- [x] 4.6 Abweichung in docs/sprachreferenz.md festhalten; verifiziert durch den Abschnittseintrag
## 5. Bildschirmanweisungen und -funktionen
- [x] 5.1 `CLS` (inkl. `CLS 2`), `COLOR` mit ausgelassenen Argumenten, `LOCATE` mit ausgelassenen Argumenten und Fehler 5 bei Grenzüberschreitung; verifiziert durch Korpustest mit Snapshot und je einem Fehlerfall
- [x] 5.2 `WIDTH`, `VIEW PRINT` (setzen und zurücksetzen), Scrollen im begrenzten Bereich; verifiziert durch Korpustest, dass Zeilen außerhalb des Bereichs unverändert bleiben
- [x] 5.3 `CSRLIN`, `POS(0)` und die Funktionsform `SCREEN(zeile, spalte [, farbe])` inklusive Fehler 5 außerhalb des Bildschirms; verifiziert durch Korpustest mit Zurücklesen eines geschriebenen Zeichens
- [x] 5.4 Anweisungsform `SCREEN` entgegennehmen und dokumentiertes Verhalten festlegen; verifiziert dadurch, dass keine `Unsupported`-Absenkung mehr erfolgt und das Verhalten in der Sprachreferenz steht
- [x] 5.5 `INKEY$` nicht blockierend (leerer String, Zeichen, Sondertaste als zwei Zeichen mit führendem Nullzeichen); verifiziert durch Test mit vorbereiteten Tastendrücken im Capture-Host
- [x] 5.6 `INPUT$(n)` blockierend ohne Echo; verifiziert durch Test, dass genau `n` Zeichen gelesen werden und der Bildschirm unverändert bleibt
- [x] 5.7 `TAB` und `SPC` in der `PRINT`-Familie; verifiziert durch Korpustest mit Spaltenpositionen
- [x] 5.8 `KEY`-Umfang (`KEY LIST`/`ON`/`OFF`, Funktionstasten-Makros) aus der Original-Hilfe klären, umsetzen und in docs/sprachreferenz.md festhalten; verifiziert durch Korpustest und Dokumentationsabschnitt
## 6. String-, Zahlen- und Formatbibliothek
- [x] 6.1 Alle im Inventar geführten Stringfunktionen vervollständigen inklusive Randfälle (`VAL`-Präfix, `STR$`-Leerzeichen, Fehler 5 bei Startposition < 1, Länge über Stringende); verifiziert durch Korpustest je Randfall
- [x] 6.2 `PRINT USING`/`LPRINT USING`/`FORMAT$` mit numerischen Platzhaltern, Tausendertrennung, Vorzeichenführung, `$$`/`**`/`**$`, Exponentialform und String-Platzhaltern; verifiziert durch Korpustest je Formatzeichenklasse
- [x] 6.3 Feldüberlauf mit `%`-Präfix umsetzen und in docs/sprachreferenz.md festhalten; verifiziert durch Korpustest und Dokumentationsabschnitt
- [x] 6.4 Wiederverwendung der Formatzeichenkette bei mehreren Werten; verifiziert durch Korpustest
- [x] 6.5 Mathematikfunktionen vervollständigen inklusive Banker's Rounding, Fehler 6 bei Überlauf, Fehler 5 bei `SQR`/`LOG`, Fehler 11 bei Division durch null; verifiziert durch Korpustest je Fehlerfall
- [x] 6.6 Kompatiblen `RND`/`RANDOMIZE`-PRNG umsetzen (`RND(0)` wiederholt, negatives Argument setzt Startwert) und den Algorithmus dokumentieren; verifiziert durch Korpustest mit reproduzierbarer Folge nach `RANDOMIZE 42`
- [x] 6.7 Datums- und Zeitfunktionen inklusive Serienwert-Zählweise, `WEEKDAY` = 1 für Sonntag und Fehler 5 bei ungültigen Angaben; verifiziert durch Korpustest mit festem Datum
- [x] 6.8 `DATE$`/`TIME$` als Anweisung; verifiziert durch Test, dass die Funktionsform danach den gesetzten Wert liefert
- [x] 6.9 Finanzmathematische Funktionen `FV#`, `PV#`, `Pmt#`, `IPmt#`, `PPmt#`, `NPer#`, `Rate#`, `NPV#`, `IRR#`, `MIRR#`, `SLN#`, `SYD#`, `DDB#` in DOUBLE mit Fälligkeitsargument; verifiziert durch Korpustest je Funktion gegen dokumentierte Sollwerte
- [x] 6.10 Fehler 5 bei ungültigen Argumenten der Finanzfunktionen und bei erfolgloser Näherung von `Rate#`/`IRR#`; verifiziert durch Korpustest je Fall
- [x] 6.11 Abbruchbedingung der Näherung in docs/bibliothek.md dokumentieren; verifiziert durch den Abschnittseintrag
- [x] 6.12 `SetFormatCC` für Währungszeichen und -stellung von `$$`; verifiziert durch Korpustest, dass `PRINT USING "$$#.##"` das gesetzte Zeichen verwendet
- [x] 6.13 `docs/bibliothek.md` anlegen und mit dem umgesetzten Stand füllen; verifiziert dadurch, dass jede in Gruppe 6 umgesetzte Funktion dort einen Eintrag hat
## 7. Datei-E/A
- [x] 7.1 Dateitabelle und `OPEN` in beiden Syntaxen für sequenziell, `RANDOM` und `BINARY`, `CLOSE` (mit und ohne Argument), `RESET`, `FREEFILE`; verifiziert durch Korpustest inklusive Fehler 55, 52 und 53
- [x] 7.2 Sequenzielle Übertragung: `PRINT #`, `WRITE #`, `INPUT #`, `LINE INPUT #`, `EOF`, Fehler 62 beim Lesen über das Dateiende; verifiziert durch Korpustest, dass `WRITE #` und `INPUT #` gegenläufig sind
- [x] 7.3 Recordpuffer mit `FIELD`, `LSET`, `RSET` inklusive Abschneiden überlanger Werte; verifiziert durch Korpustest
- [x] 7.4 Festtextfelder als UTF-32 mit konstanter Recordlänge; verifiziert durch Korpustest, dass ein Record mit ASCII und einer mit Nicht-ASCII dieselbe Byteanzahl belegen
- [x] 7.5 `GET`/`PUT` für `RANDOM` mit Recordnummer und mit UDT-Variablen; verifiziert durch Korpustest mit Schreiben und Lesen an Position 3
- [x] 7.6 Binärzugriff mit byteweiser Positionierung, `SEEK` als Anweisung und Funktion; verifiziert durch Korpustest, dass ein positioniert überschriebenes Byte zurückgelesen wird und die Nachbarbytes unverändert bleiben
- [x] 7.7 `LOF`, `LOC`, `FILEATTR` inklusive Fehler 52 auf geschlossener Datei; verifiziert durch Korpustest
- [x] 7.8 `LOCK`/`UNLOCK` umsetzen; verifiziert durch Korpustest für Sperren und Freigeben, plattformbedingte Abweichungen in docs/sprachreferenz.md notiert
- [x] 7.9 Dateisystem-Anweisungen `KILL`, `NAME … AS`, `FILES`, `CHDIR`, `MKDIR`, `RMDIR`, `SHELL` mit plattformübergreifender Pfadsemantik (`\` und `/`, Laufwerksbuchstaben) inklusive Fehler 53 und 76; verifiziert durch Korpustest mit Rückwärts-Schrägstrich
- [x] 7.10 `LPRINT`/`LPRINT USING` mit dokumentiertem Ausgabeziel sowie `LPOS`; verifiziert durch Test, dass die Formatierung `PRINT` entspricht, `LPOS` die Druckerspalte liefert, und durch den Abschnitt in der Sprachreferenz
- [x] 7.11 Record-Konvertierungsfunktionen `MKI$`/`MKL$`/`MKS$`/`MKD$`/`MKC$` und `CVI`/`CVL`/`CVS`/`CVD`/`CVC`; verifiziert durch Korpustest, dass jedes Paar gegenläufig ist und falsche Argumentlänge Fehler 5 auslöst
- [x] 7.12 MBF-Varianten `MKSMBF$`/`MKDMBF$`/`CVSMBF`/`CVDMBF` umsetzen oder als Non-Feature namentlich abweisen und in docs/sprachreferenz.md listen; verifiziert durch Korpustest bzw. den Abschnittseintrag
- [x] 7.13 `CURDIR$`, `DIR$` (erster und Folgetreffer, leerer String am Ende), `CHDRIVE` und `SHELL` als Funktion; verifiziert durch Korpustest, der ein Verzeichnis mit `DIR$` vollständig durchläuft
- [x] 7.14 Korpus-Dateiprogramme in einem temporären Arbeitsverzeichnis ausführen; verifiziert dadurch, dass nach `cargo test` `git status` sauber ist
## 8. System- und Umgebungsanweisungen
- [x] 8.1 `ENVIRON$` (nach Name und Position), `ENVIRON`, `COMMAND$`; verifiziert durch Korpustest inklusive leerem String für unbekannte Namen
- [x] 8.2 `FRE`, `CLEAR`, `TRON`, `TROFF` mit definiertem, dokumentiertem Verhalten; verifiziert dadurch, dass keine `Unsupported`-Absenkung mehr erfolgt und der Ersatzwert für `FRE` in der Sprachreferenz steht
- [x] 8.3 `STACK` als Anweisung und Funktion, `SYSTEM`, `ERDEV`/`ERDEV$` mit definierten Ersatzwerten; verifiziert durch Korpustest, dass `SYSTEM` mit demselben Status endet wie `END`, und durch die Ersatzwert-Einträge in docs/sprachreferenz.md
## 9. Altlasten aus Phase 2
- [x] 9.1 `ON ERROR GOTO` auf Modulebene aus Prozeduren auflösbar machen (`sema.rs`, `lower_stmt`/`OnError`), `ON LOCAL ERROR` bleibt prozedurlokal; verifiziert durch je einen Frontend-Test für beide Fälle
- [x] 9.2 DATA-Rohtext im Lexer erhalten (keine Bezeichner-Normalisierung, Randleerraum entfernt, Innenleerraum erhalten); verifiziert durch Korpustest mit gemischter Schreibweise
- [x] 9.3 `TODO verify`-Zellen der Konvertierungsmatrix in docs/tbvm-design.md gegen die Original-Hilfe auflösen (CURRENCY-Promotion mit Gleitkomma, `^`-Ergebnistyp, Exponentialform-Schwellen, CURRENCY-Ausgabeformat); verifiziert dadurch, dass keine `TODO verify`-Zelle mehr im Dokument steht
- [x] 9.4 Korpustest `konvertierung.bas` um die in 9.3 geklärten Randfälle schärfen; verifiziert durch die erweiterte Sollausgabe
## 10. Abschluss
- [x] 10.1 Inventarstatus für alle in diesem Change umgesetzten Elemente auf `implementiert` setzen; verifiziert dadurch, dass der Abgleichstest aus 1.3 grün bleibt
- [x] 10.2 docs/sprachreferenz.md fortschreiben (Doppelzellen, `KEY n`, `PRINT USING`-Überlauf, UTF-32-Records, plattformbedingte Abweichungen); verifiziert durch die vorhandenen Abschnitte
- [x] 10.3 Vollständige Suite ausführen; verifiziert dadurch, dass `cargo test --workspace` grün ist. **Nur macOS geprüft** — Windows und Linux stehen aus und gehören zu den Plattformtests in Phase 6 (PLAN.md); die Laufzeit enthält keinen plattformspezifischen Code außer der Pfadnormierung und `SHELL`.
- [x] 10.4 Meilenstein prüfen: klassische Konsolenprogramme des Korpus laufen unverändert; verifiziert dadurch, dass jedes Korpusprogramm ohne Laufzeitfehler 73 durchläuft
- [x] 10.5 PLAN.md Phase 3 abhaken, soweit dieser Change reicht, und den offenen ISAM-Teil auf `phase-3-isam` verweisen; verifiziert durch den aktualisierten Planabschnitt

View File

@@ -0,0 +1,24 @@
' tb-screen: 80x25
' Bildschirmsteuerung (Spec textbildschirm): LOCATE mit ausgelassenen
' Argumenten, COLOR, CLS, VIEW PRINT-Scrollen, CSRLIN/POS und das
' Zuruecklesen per SCREEN.
LOCATE 3, 5
PRINT "Position";
PRINT " "; CSRLIN; POS(0)
LOCATE , 1
PRINT "Spalte 1 in Zeile 4"
COLOR 14, 1
PRINT "gelb auf blau"
COLOR 7, 0
LOCATE 12, 1
PRINT "gelesen: "; CHR$(SCREEN(3, 5)); CHR$(SCREEN(3, 6));
PRINT " Attribut "; SCREEN(5, 1, 1)
LOCATE 8, 1
VIEW PRINT 8 TO 10
LOCATE 10, 1
PRINT "a"
PRINT "b"
VIEW PRINT
LOCATE 14, 1
PRINT "fertig"
END

View File

@@ -0,0 +1,29 @@
Position 3 17
Spalte 1 in Zeile 4
gelb auf blau
a
b
gelesen: Po Attribut 30
fertig
--- Attribute (Vordergrund/Hintergrund je Zelle, hex) ---
7070707070707070707070707070707070707070
70707070707070707070707070707070707070
e1e1e1e1e1e1e1e1e1e1e1e1e1
70
70
70707070707070707070707070707070707070707070707070
707070707070

View File

@@ -0,0 +1,10 @@
' tb-screen: 80x25
' Breite Zeichen (CJK, Emoji) belegen zwei Zellen (Entscheidung 2026-09-02):
' der Cursor rückt um zwei Spalten vor, POS zählt die Anfangsspalte, und ein
' breites Zeichen am rechten Rand wandert als Ganzes in die nächste Zeile.
PRINT "AB"; "中文"; "CD"
PRINT LEN("中文")
' 79 schmale Zeichen, dann ein breites: die letzte Spalte bleibt leer.
PRINT STRING$(79, "x"); "字"; "E"
PRINT "Ende"
END

View File

@@ -0,0 +1,5 @@
AB中文CD
2
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
字E
Ende

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' BYREF wirkt auf das Argument zurueck; Klammern erzwingen
' Wertuebergabe (BYVAL); STATIC behaelt Werte; Rekursion ueber den
' Funktionsnamen (Spec vm-ausfuehrung).

View File

@@ -0,0 +1,18 @@
' tb-screen: 80x25
' DATA-Rohtext (Spec sprach-frontend): unquotierte Elemente behalten ihre
' Gross-/Kleinschreibung und ihren inneren Leerraum; Leerraum an den
' Raendern faellt weg. Quotierte Elemente bleiben unveraendert, auch mit
' Komma. Ein Doppelpunkt beendet die Anweisung.
DATA Hallo Welt, ab cd , x
READ a$, b$, c$
PRINT "["; a$; "]["; b$; "]["; c$; "]"
DATA "a,b", 42, -3.5, MiXeD Text
READ s$, n%, d!, t$
PRINT "["; s$; "]"; n%; d!; "["; t$; "]"
DATA eins : PRINT "nach Doppelpunkt"
READ e$
PRINT "["; e$; "]"
RESTORE
READ a$
PRINT "nach RESTORE: ["; a$; "]"
END

View File

@@ -0,0 +1,5 @@
[Hallo Welt][ab cd][x]
[a,b] 42 -3.5 [MiXeD Text]
nach Doppelpunkt
[eins]
nach RESTORE: [Hallo Welt]

119
tests/compat/dateien.bas Normal file
View File

@@ -0,0 +1,119 @@
' tb-screen: 80x25
' tb-tempdir
' Datei-E/A (Spec datei-eio): sequenziell schreiben und lesen, wahlfreier
' Zugriff mit UDT-Records und mit FIELD/LSET/RSET, Binaerzugriff sowie die
' Status- und Dateisystemfunktionen. Laeuft in einem temporaeren Verzeichnis.
TYPE Kunde
Nr AS INTEGER
Name AS STRING * 8
Saldo AS DOUBLE
END TYPE
DIM k AS Kunde, g AS Kunde
' --- sequenziell ---
OPEN "seq.txt" FOR OUTPUT AS #1
PRINT #1, "Zeile eins"
PRINT #1, 1; 2; 3
WRITE #1, "a,b", 5
CLOSE #1
OPEN "seq.txt" FOR INPUT AS #1
PRINT "Groesse:"; LOF(1)
LINE INPUT #1, z$
PRINT "["; z$; "]"
LINE INPUT #1, z$
PRINT "["; z$; "]"
INPUT #1, s$, n%
PRINT "["; s$; "]"; n%; "EOF:"; EOF(1)
CLOSE #1
' --- Anhaengen ---
OPEN "seq.txt" FOR APPEND AS #1
PRINT #1, "angehaengt"
CLOSE #1
OPEN "seq.txt" FOR INPUT AS #1
anzahl% = 0
DO WHILE NOT EOF(1)
LINE INPUT #1, z$
anzahl% = anzahl% + 1
LOOP
PRINT "Zeilen:"; anzahl%; "letzte: ["; z$; "]"
CLOSE #1
' --- wahlfrei mit UDT ---
OPEN "rec.dat" FOR RANDOM AS #2 LEN = 42
k.Nr = 7
k.Name = "Mueller"
k.Saldo = 100
PUT #2, 3, k
GET #2, 3, g
PRINT g.Nr; "["; g.Name; "]"; g.Saldo; "LOF:"; LOF(2)
CLOSE #2
' --- wahlfrei mit FIELD ---
OPEN "fld.dat" FOR RANDOM AS #3 LEN = 80
FIELD #3, 10 AS nm$, 10 AS ort$
LSET nm$ = "Meier"
RSET ort$ = "Berlin"
PUT #3, 1
LSET nm$ = "Schulz"
LSET ort$ = "Hamburg"
PUT #3, 2
GET #3, 1
PRINT "["; nm$; "]["; ort$; "]"
GET #3, 2
PRINT "["; nm$; "]["; ort$; "]"
CLOSE #3
' --- binaer ---
OPEN "bin.dat" FOR BINARY AS #4
w% = 4711
PUT #4, 4, w%
w% = 0
GET #4, 4, w%
PRINT "binaer:"; w%; "SEEK:"; SEEK(4)
CLOSE #4
' --- Konvertierungsfunktionen ---
PRINT CVI(MKI$(-5)); CVL(MKL$(123456)); CVD(MKD$(1.5))
PRINT LEN(MKI$(1)); LEN(MKL$(1)); LEN(MKD$(1))
' --- Dateisystem ---
MKDIR "unter"
PRINT "Verzeichnis:"; LEN(CURDIR$) > 0
n$ = DIR$("*.dat")
anzahl% = 0
DO WHILE n$ <> ""
anzahl% = anzahl% + 1
n$ = DIR$("")
LOOP
PRINT "dat-Dateien:"; anzahl%
NAME "seq.txt" AS "neu.txt"
PRINT "umbenannt:"; DIR$("neu.txt") <> ""
KILL "neu.txt"
KILL "rec.dat"
KILL "fld.dat"
KILL "bin.dat"
RMDIR "unter"
' --- Fehlerfaelle ---
ON ERROR GOTO Fehler
OPEN "fehlt.txt" FOR INPUT AS #5
W1:
OPEN "a.txt" FOR OUTPUT AS #6
OPEN "b.txt" FOR OUTPUT AS #6
W2:
x& = LOF(9)
W3:
CLOSE #6
KILL "a.txt"
ON ERROR GOTO 0
PRINT "fertig"
END
Fehler:
PRINT "Fehler"; ERR
f% = f% + 1
SELECT CASE f%
CASE 1: RESUME W1
CASE 2: RESUME W2
CASE ELSE: RESUME W3
END SELECT

18
tests/compat/dateien.out Normal file
View File

@@ -0,0 +1,18 @@
Groesse: 29
[Zeile eins]
[ 1 2 3 ]
[a,b] 5 EOF:-1
Zeilen: 4 letzte: [angehaengt]
7 [Mueller ] 100 LOF: 126
[Meier ][ Berlin]
[Schulz ][Hamburg ]
binaer: 4711 SEEK: 6
-5 123456 1.5
2 4 8
Verzeichnis:-1
dat-Dateien: 3
umbenannt:-1
Fehler 53
Fehler 55
Fehler 52
fertig

View File

@@ -0,0 +1,27 @@
' tb-screen: 80x25
' Datum und Zeit (Spec konsolen-basisbibliothek): Serienwert-Zaehlweise,
' Rueckumwandlung, WEEKDAY = 1 fuer Sonntag, Textformen und die
' programmeigene Uhr ueber DATE$ =/TIME$ =.
d# = DATESERIAL(1992, 3, 15)
PRINT YEAR(d#); MONTH(d#); DAY(d#); WEEKDAY(d#)
PRINT DATESERIAL(1900, 1, 1)
t# = TIMESERIAL(13, 45, 30)
PRINT HOUR(t#); MINUTE(t#); SECOND(t#)
PRINT DATEVALUE("03-15-1992") = d#; TIMEVALUE("13:45:30") = t#
DATE$ = "01-02-1993"
TIME$ = "10:20:30"
PRINT DATE$; " "; TIME$
PRINT YEAR(NOW); MONTH(NOW); DAY(NOW)
ON ERROR GOTO Fehler
x# = DATESERIAL(1992, 13, 1)
Weiter:
x# = TIMEVALUE("25:00")
Ende:
ON ERROR GOTO 0
PRINT "fertig"
END
Fehler:
PRINT "Fehler"; ERR
n% = n% + 1
IF n% = 1 THEN RESUME Weiter
RESUME Ende

View File

@@ -0,0 +1,9 @@
1992 3 15 1
2
13 45 30
-1 -1
01-02-1993 10:20:30
1993 1 2
Fehler 5
Fehler 5
fertig

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' ON ERROR/RESUME NEXT/ERR/ERL (Spec vm-fehlerbehandlung):
' Handler-Eintritt setzt ERR und ERL (letzte numerische Zeilennummer);
' RESUME NEXT setzt nach der fehlerausloesenden Anweisung fort.

37
tests/compat/finanzen.bas Normal file
View File

@@ -0,0 +1,37 @@
' tb-screen: 80x25
' Finanzmathematik (Spec konsolen-basisbibliothek): Endwert, Zahlung,
' Zins-/Tilgungsanteil, Abschreibungen, Kapitalwert und interner Zinsfuss.
' Vorzeichen wie beim Vorbild: ausgehende Zahlungen negativ.
PRINT USING "########.##"; FV#(0.05, 10, -100, 0, 0)
z# = PMT#(0.05, 10, 1000, 0, 0)
PRINT USING "########.##"; z#
' Zins- und Tilgungsanteil ergaenzen sich zur Zahlung.
PRINT IPMT#(0.05, 1, 10, 1000, 0, 0) + PPMT#(0.05, 1, 10, 1000, 0, 0) = z#
PRINT USING "#####.## "; SLN#(1000, 100, 10); SYD#(1000, 100, 10, 1); DDB#(1000, 100, 10, 1)
DIM q#(1 TO 4)
q#(1) = -1000: q#(2) = 400: q#(3) = 400: q#(4) = 400
PRINT USING "#.######"; IRR#(q#(), 0.1)
PRINT USING "#.######"; MIRR#(q#(), 0.05, 0.06)
PRINT USING "#.######"; RATE#(10, z#, 1000, 0, 0, 0.1)
PRINT USING "###.##"; NPER#(0.05, z#, 1000, 0, 0)
' Fehlerfaelle: ungueltige Argumente und erfolglose Naeherung.
ON ERROR GOTO Fehler
x# = SLN#(1000, 100, 0)
W1:
x# = SYD#(1000, 100, 10, 11)
W2:
DIM p#(1 TO 2)
p#(1) = 100: p#(2) = 200
x# = IRR#(p#(), 0.1)
W3:
ON ERROR GOTO 0
PRINT "fertig"
END
Fehler:
PRINT "Fehler"; ERR
n% = n% + 1
SELECT CASE n%
CASE 1: RESUME W1
CASE 2: RESUME W2
CASE ELSE: RESUME W3
END SELECT

12
tests/compat/finanzen.out Normal file
View File

@@ -0,0 +1,12 @@
1257.79
-129.50
-1
90.00 163.64 200.00
0.097010
0.083909
0.050000
10.00
Fehler 5
Fehler 5
Fehler 5
fertig

View File

@@ -0,0 +1,31 @@
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
z 1
z 2
z 3
z 4
z 5
z 6
z 7
z 8
z 9
z 10
z 11
z 12
z 13
z 14
z 15
z 16
z 17
z 18
z 19
z 20
z 21
z 22
z 23
z 24
z 25
z 26
z 27
z 28
z 29
z 30

View File

@@ -0,0 +1,24 @@
z 7
z 8
z 9
z 10
z 11
z 12
z 13
z 14
z 15
z 16
z 17
z 18
z 19
z 20
z 21
z 22
z 23
z 24
z 25
z 26
z 27
z 28
z 29
z 30

13
tests/compat/groesse.bas Normal file
View File

@@ -0,0 +1,13 @@
' tb-screen: 80x25, 120x40
' Nachweis der Größenunabhängigkeit (Spec kompat-testkorpus): dasselbe
' Programm läuft bei zwei Bildschirmgrößen. Der Umbruch am rechten Rand und
' das Scrollen am unteren Rand folgen der jeweiligen Größe — nirgends steckt
' eine feste 80 oder 25.
FOR i% = 1 TO 100
PRINT "x";
NEXT i%
PRINT
FOR z% = 1 TO 30
PRINT "z"; z%
NEXT z%
END

View File

@@ -0,0 +1,11 @@
' tb-screen: 80x25
' tb-resize: nach 1 auf 120x40
' Nachweis: eine Größenänderung mitten im Lauf wird zugestellt. Der zuvor
' ausgegebene Inhalt bleibt oben links erhalten, die folgende Ausgabe nutzt
' die neuen Grenzen (100 Zeichen passen jetzt in eine Zeile).
PRINT "vorher"
FOR i% = 1 TO 100
PRINT "y";
NEXT i%
PRINT
END

View File

@@ -0,0 +1,2 @@
vorher
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' Minimalfall: PRINT mit String und Zeilenumbruch
PRINT "Hallo, Welt!"
END

View File

@@ -0,0 +1,11 @@
' tb-screen: 80x25
' Funktionstasten-Makros (Original-Hilfe): KEY n, text$ belegt F1-F10 sowie
' 30/31 fuer F11/F12, hoechstens 15 Zeichen. KEY LIST listet alle zwoelf
' untereinander, KEY ON blendet die Softkey-Zeile unten ein.
KEY 1, "LIST"
KEY 2, "RUN"
KEY 30, "F11-Makro"
KEY 3, "mehr als fuenfzehn Zeichen"
KEY LIST
PRINT "nach LIST"
END

View File

@@ -0,0 +1,13 @@
LIST
RUN
mehr als fuenfz
F11-Makro
nach LIST

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' FOR/NEXT, DO/LOOP, SELECT CASE, GOSUB
FOR i% = 1 TO 3
PRINT "for"; i%

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' Zahlenkonvertierungs-Matrix (docs/tbvm-design.md, Abschnitt
' "Zahlenkonvertierungs-Matrix"): Banker's Rounding (Original-Hilfe:
' CINT), Ganzzahldivision rundet Operanden vor, Logik bitweise auf
@@ -9,6 +10,17 @@ PRINT 1 / 4; 1 / 3
PRINT 2 ^ 10; 2 ^ 0.5
PRINT 6 AND 3; 6 OR 3; 6 XOR 3; NOT 0; 1.5 AND 1
PRINT CLNG(2.5); CLNG(3.5)
' --- Die vier Projektentscheidungen vom 2026-09-04 (docs/tbvm-design.md) ---
' 1. CURRENCY gemischt mit Gleitkomma ergibt DOUBLE, nicht SINGLE.
c@ = 2.5
PRINT c@ + 1.5!; c@ + 1.5#; c@ * 2
' 2. Ergebnistyp von ^: DOUBLE bei DOUBLE/CURRENCY, sonst SINGLE.
PRINT 2 ^ 3; 2.5! ^ 2; 2.5# ^ 2; c@ ^ 2
' 3. Exponentialform-Schwelle von SINGLE: Exponent < -7 oder >= 7.
PRINT 1E-7!; 1E-8!; 9999999!; 10000000!
PRINT .01!; .001!
' 4. CURRENCY-Ausgabe: nachgestellte Nullen entfallen.
PRINT 1.5@; 2@; 2.5000@; .1234@; 1.10@
ON ERROR GOTO Uebergelaufen
i% = 32767
i% = i% + 1

View File

@@ -5,4 +5,9 @@
1024 1.414214
2 7 5 -1 0
2 4
4 4 5
8 6.25 6.25 6.25
.0000001 1E-08 9999999 1E+07
.01 .001
1.5 2 2.5 .1234 1.1
Fehler 6

View File

@@ -0,0 +1,40 @@
' tb-screen: 80x25
' Mathematik (Spec konsolen-basisbibliothek): Banker's Rounding, Vorzeichen
' von INT/FIX, Konvertierungen und der reproduzierbare PRNG.
PRINT CINT(0.5); CINT(1.5); CINT(2.5); CINT(-0.5); CINT(-1.5)
PRINT INT(-1.7); FIX(-1.7); INT(1.7); FIX(1.7)
PRINT ABS(-3); SGN(-3); SGN(0); SGN(3)
PRINT CLNG(2.5); CLNG(3.5)
' Fehlerfaelle ueber die Fehlerbehandlung sichtbar machen.
ON ERROR GOTO Fehler
x = SQR(-1)
PRINT "unerreichbar"
Weiter1:
x = LOG(0)
PRINT "unerreichbar"
Weiter2:
x% = CINT(40000)
PRINT "unerreichbar"
Weiter3:
x = 1 / 0
PRINT "unerreichbar"
Weiter4:
ON ERROR GOTO 0
' Reproduzierbarkeit: gleicher Startwert, gleiche Folge.
RANDOMIZE 42
a1 = RND: a2 = RND
RANDOMIZE 42
b1 = RND: b2 = RND
PRINT "gleich: "; (a1 = b1) AND (a2 = b2)
c = RND
PRINT "RND(0) wiederholt: "; (RND(0) = c)
END
Fehler:
PRINT "Fehler"; ERR
zaehler% = zaehler% + 1
SELECT CASE zaehler%
CASE 1: RESUME Weiter1
CASE 2: RESUME Weiter2
CASE 3: RESUME Weiter3
CASE ELSE: RESUME Weiter4
END SELECT

View File

@@ -0,0 +1,10 @@
0 2 2 0 -2
-2 -1 1 1
3 -1 0 1
2 4
Fehler 5
Fehler 5
Fehler 6
Fehler 11
gleich: -1
RND(0) wiederholt: -1

View File

@@ -0,0 +1,22 @@
' tb-screen: 80x25
' PRINT USING (Spec konsolen-basisbibliothek): numerische Platzhalter,
' Tausendertrennung, Vorzeichenfuehrung, Waehrung und Fuellzeichen,
' Exponentialform, String-Platzhalter, Wiederverwendung und Feldueberlauf.
PRINT USING "###.##"; 3.14159
PRINT USING "##.##"; -1.5
PRINT USING "+###"; 12
PRINT USING "###-"; -12
PRINT USING "###-"; 12
PRINT USING "##,#####"; 1234567
PRINT USING "$$###"; 12
PRINT USING "**###"; 12
PRINT USING "#.##^^^^"; 1234
PRINT USING "!"; "abc"
PRINT USING "&"; "abc"
PRINT USING "\ \"; "abcdef"
PRINT USING "\ \"; "ab"
PRINT USING "## "; 1; 2; 3
PRINT USING "Wert: ## Stueck"; 7
PRINT USING "##"; 12345
PRINT FORMAT$(3.14159, "###.##")
PRINT FORMAT$(42)

View File

@@ -0,0 +1,18 @@
3.14
-1.50
+ 12
12-
12
1,234,567
$12
***12
1.23E+03
a
abc
abcd
ab
1 2 3
Wert: 7 Stueck
%12345
3.14
42

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' PRINT-Formatierung von Zahlen: führendes Leerzeichen (bzw. -),
' nachgestelltes Leerzeichen; Semikolon = direkt anschließen.
PRINT 1; 2; 3

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' Komma = Sprung zur nächsten 14-Zeichen-Druckzone
PRINT "a", "b", "c"
PRINT "1234567890123", "x"

View File

@@ -0,0 +1,14 @@
' tb-screen: 80x25
' Randfaelle der Stringbibliothek (Spec konsolen-basisbibliothek):
' Laenge ueber Stringende, VAL-Praefix, STR$-Vorzeichenspalte, STRING$ mit
' Code und mit String, INSTR mit und ohne Startposition.
PRINT "["; LEFT$("ab", 10); "]"; "["; RIGHT$("ab", 10); "]"
PRINT "["; MID$("abc", 2); "]"; "["; MID$("abc", 2, 1); "]"
PRINT "["; STR$(5); "]"; "["; STR$(-5); "]"; "["; STR$(1.5); "]"
PRINT VAL(" 12.5abc"); VAL("x"); VAL("&HFF"); VAL("-3")
PRINT "["; STRING$(3, 65); "]"; "["; STRING$(3, "xy"); "]"; "["; SPACE$(3); "]"
PRINT INSTR("abcabc", "b"); INSTR(3, "abcabc", "b"); INSTR("abc", "z")
PRINT "["; LTRIM$(" a "); "]"; "["; RTRIM$(" a "); "]"
PRINT UCASE$("aÄb"); " "; LCASE$("AÄB"); " "; LEN("aÄb")
PRINT HEX$(255); " "; OCT$(8); " "; CHR$(9731); " "; ASC("A")
END

View File

@@ -0,0 +1,9 @@
[ab][ab]
[bc][b]
[ 5][-5][ 1.5]
12.5 0 255 -3
[AAA][xxx][ ]
2 5 0
[a ][ a]
AÄB aäb 3
FF 10 ☃ 65

View File

@@ -1,3 +1,4 @@
' tb-screen: 80x25
' Stringfunktionen; LEN zählt Zeichen (UTF-8-Abweichung dokumentiert)
s$ = "Terminal Basic"
PRINT LEN(s$)

15
tests/compat/system.bas Normal file
View File

@@ -0,0 +1,15 @@
' tb-screen: 80x25
' System- und Umgebungsanweisungen (Spec konsolen-basisbibliothek).
' Nicht abbildbare Groessen liefern dokumentierte Ersatzwerte.
ENVIRON "TB_KORPUS=hallo"
PRINT "["; ENVIRON$("TB_KORPUS"); "]"
ENVIRON "TB_KORPUS="
PRINT "["; ENVIRON$("TB_KORPUS"); "]"
PRINT "FRE>0:"; FRE(0) > 0; "STACK>0:"; STACK > 0
PRINT "ERDEV:"; ERDEV; "["; ERDEV$; "]"
TRON
TROFF
CLEAR
PRINT "COMMAND$ leer:"; COMMAND$ = ""
PRINT "fertig"
END

6
tests/compat/system.out Normal file
View File

@@ -0,0 +1,6 @@
[hallo]
[]
FRE>0:-1 STACK>0:-1
ERDEV: 0 []
COMMAND$ leer:-1
fertig

7
tests/compat/tabspc.bas Normal file
View File

@@ -0,0 +1,7 @@
' tb-screen: 80x25
' TAB(n) springt zur Spalte n; steht der Cursor schon dahinter, folgt erst
' ein Zeilenumbruch. SPC(n) gibt n Leerzeichen aus.
PRINT "a"; TAB(10); "b"; TAB(20); "c"
PRINT "x"; SPC(5); "y"
PRINT "lang genug fuer einen Umbruch"; TAB(5); "danach"
END

4
tests/compat/tabspc.out Normal file
View File

@@ -0,0 +1,4 @@
a b c
x y
lang genug fuer einen Umbruch
danach