From 52ccbb58481d8605a87f1208925c35ab24686d36 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 4 Sep 2026 06:54:19 +0200 Subject: [PATCH] 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) --- Cargo.lock | 2 + Cargo.toml | 3 + PLAN.md | 161 ++- crates/tb-cli/src/main.rs | 92 +- crates/tb-cli/tests/compat.rs | 321 ++++- crates/tb-frontend/src/hir.rs | 111 ++ crates/tb-frontend/src/lexer.rs | 25 + crates/tb-frontend/src/parser.rs | 84 +- crates/tb-frontend/src/sema.rs | 764 +++++++++-- crates/tb-frontend/tests/inventar.rs | 498 +++++++ crates/tb-runtime/Cargo.toml | 1 + crates/tb-runtime/src/builtins.rs | 1143 ++++++++++++++++- crates/tb-runtime/src/console.rs | 203 ++- crates/tb-runtime/src/datetime.rs | 198 ++- crates/tb-runtime/src/fileio.rs | 661 +++++++++- crates/tb-runtime/src/finanz.rs | 377 ++++++ crates/tb-runtime/src/host.rs | 206 ++- crates/tb-runtime/src/lib.rs | 6 +- crates/tb-runtime/src/screen.rs | 557 ++++++++ crates/tb-runtime/src/snapshot.rs | 141 ++ crates/tb-runtime/src/using.rs | 487 +++++++ crates/tb-ui/Cargo.toml | 1 + crates/tb-ui/examples/spike.rs | 4 +- crates/tb-ui/src/host.rs | 150 +++ crates/tb-ui/src/lib.rs | 1 + crates/tb-ui/src/screen.rs | 342 +---- crates/tb-vm/src/bytecode.rs | 7 + crates/tb-vm/src/codegen.rs | 157 ++- crates/tb-vm/src/interp.rs | 274 +++- crates/tb-vm/tests/vm.rs | 215 +++- docs/bibliothek.md | 304 +++++ docs/inventar.md | 312 +++++ docs/sprachreferenz.md | 130 +- docs/tbvm-design.md | 71 +- openspec/changes/phase-3-isam/.openspec.yaml | 2 + openspec/changes/phase-3-isam/design.md | 209 +++ openspec/changes/phase-3-isam/proposal.md | 104 ++ .../phase-3-isam/specs/isam-datenbank/spec.md | 187 +++ .../specs/sprach-frontend/spec.md | 27 + openspec/changes/phase-3-isam/tasks.md | 78 ++ .../changes/phase-3-ortszeit/.openspec.yaml | 2 + openspec/changes/phase-3-ortszeit/design.md | 180 +++ openspec/changes/phase-3-ortszeit/proposal.md | 76 ++ .../specs/konsolen-basisbibliothek/spec.md | 58 + openspec/changes/phase-3-ortszeit/tasks.md | 35 + .../phase-3-runtime-bildschirm/.openspec.yaml | 2 + .../phase-3-runtime-bildschirm/design.md | 287 +++++ .../phase-3-runtime-bildschirm/proposal.md | 186 +++ .../rohliste-original-hilfe.md | 295 +++++ .../specs/datei-eio/spec.md | 173 +++ .../specs/kompat-testkorpus/spec.md | 85 ++ .../specs/konsolen-basisbibliothek/spec.md | 208 +++ .../specs/sprach-frontend/spec.md | 54 + .../specs/sprachinventar/spec.md | 74 ++ .../specs/textbildschirm/spec.md | 173 +++ .../phase-3-runtime-bildschirm/tasks.md | 106 ++ tests/compat/bildschirm.bas | 24 + tests/compat/bildschirm.out | 29 + tests/compat/breitezeichen.bas | 10 + tests/compat/breitezeichen.out | 5 + tests/compat/byref.bas | 3 +- tests/compat/datarohtext.bas | 18 + tests/compat/datarohtext.out | 5 + tests/compat/dateien.bas | 119 ++ tests/compat/dateien.out | 18 + tests/compat/datumzeit.bas | 27 + tests/compat/datumzeit.out | 9 + tests/compat/fehlerbehandlung.bas | 3 +- tests/compat/finanzen.bas | 37 + tests/compat/finanzen.out | 12 + tests/compat/groesse.120x40.out | 31 + tests/compat/groesse.80x25.out | 24 + tests/compat/groesse.bas | 13 + tests/compat/groessenaenderung.bas | 11 + tests/compat/groessenaenderung.out | 2 + tests/compat/hello.bas | 3 +- tests/compat/keymakros.bas | 11 + tests/compat/keymakros.out | 13 + tests/compat/kontrollfluss.bas | 3 +- tests/compat/konvertierung.bas | 14 +- tests/compat/konvertierung.out | 5 + tests/compat/mathematik.bas | 40 + tests/compat/mathematik.out | 10 + tests/compat/printusing.bas | 22 + tests/compat/printusing.out | 18 + tests/compat/printzahlen.bas | 3 +- tests/compat/printzonen.bas | 3 +- tests/compat/stringrandfaelle.bas | 14 + tests/compat/stringrandfaelle.out | 9 + tests/compat/strings.bas | 3 +- tests/compat/system.bas | 15 + tests/compat/system.out | 6 + tests/compat/tabspc.bas | 7 + tests/compat/tabspc.out | 4 + 94 files changed, 10421 insertions(+), 792 deletions(-) create mode 100644 crates/tb-frontend/tests/inventar.rs create mode 100644 crates/tb-runtime/src/finanz.rs create mode 100644 crates/tb-runtime/src/screen.rs create mode 100644 crates/tb-runtime/src/snapshot.rs create mode 100644 crates/tb-runtime/src/using.rs create mode 100644 crates/tb-ui/src/host.rs create mode 100644 docs/bibliothek.md create mode 100644 docs/inventar.md create mode 100644 openspec/changes/phase-3-isam/.openspec.yaml create mode 100644 openspec/changes/phase-3-isam/design.md create mode 100644 openspec/changes/phase-3-isam/proposal.md create mode 100644 openspec/changes/phase-3-isam/specs/isam-datenbank/spec.md create mode 100644 openspec/changes/phase-3-isam/specs/sprach-frontend/spec.md create mode 100644 openspec/changes/phase-3-isam/tasks.md create mode 100644 openspec/changes/phase-3-ortszeit/.openspec.yaml create mode 100644 openspec/changes/phase-3-ortszeit/design.md create mode 100644 openspec/changes/phase-3-ortszeit/proposal.md create mode 100644 openspec/changes/phase-3-ortszeit/specs/konsolen-basisbibliothek/spec.md create mode 100644 openspec/changes/phase-3-ortszeit/tasks.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/.openspec.yaml create mode 100644 openspec/changes/phase-3-runtime-bildschirm/design.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/proposal.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/rohliste-original-hilfe.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/specs/datei-eio/spec.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/specs/kompat-testkorpus/spec.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/specs/konsolen-basisbibliothek/spec.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/specs/sprach-frontend/spec.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/specs/sprachinventar/spec.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/specs/textbildschirm/spec.md create mode 100644 openspec/changes/phase-3-runtime-bildschirm/tasks.md create mode 100644 tests/compat/bildschirm.bas create mode 100644 tests/compat/bildschirm.out create mode 100644 tests/compat/breitezeichen.bas create mode 100644 tests/compat/breitezeichen.out create mode 100644 tests/compat/datarohtext.bas create mode 100644 tests/compat/datarohtext.out create mode 100644 tests/compat/dateien.bas create mode 100644 tests/compat/dateien.out create mode 100644 tests/compat/datumzeit.bas create mode 100644 tests/compat/datumzeit.out create mode 100644 tests/compat/finanzen.bas create mode 100644 tests/compat/finanzen.out create mode 100644 tests/compat/groesse.120x40.out create mode 100644 tests/compat/groesse.80x25.out create mode 100644 tests/compat/groesse.bas create mode 100644 tests/compat/groessenaenderung.bas create mode 100644 tests/compat/groessenaenderung.out create mode 100644 tests/compat/keymakros.bas create mode 100644 tests/compat/keymakros.out create mode 100644 tests/compat/mathematik.bas create mode 100644 tests/compat/mathematik.out create mode 100644 tests/compat/printusing.bas create mode 100644 tests/compat/printusing.out create mode 100644 tests/compat/stringrandfaelle.bas create mode 100644 tests/compat/stringrandfaelle.out create mode 100644 tests/compat/system.bas create mode 100644 tests/compat/system.out create mode 100644 tests/compat/tabspc.bas create mode 100644 tests/compat/tabspc.out diff --git a/Cargo.lock b/Cargo.lock index 54c63da..4a1bb4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 15a50fc..c95d9c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ authors = ["Georg Bauer"] ratatui = "0.29" crossterm = "0.28" +# Text +unicode-width = "0.2" + # Allgemein anyhow = "1" thiserror = "2" diff --git a/PLAN.md b/PLAN.md index 63b80b9..e6e4bd9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 --- diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index e3f2df0..d708c3d 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -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, + 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 { + if self.puffer.is_empty() && blockierend { + self.nachfuellen(); + } + self.puffer.pop_front() + } +} diff --git a/crates/tb-cli/tests/compat.rs b/crates/tb-cli/tests/compat.rs index 2049c64..556e519 100644 --- a/crates/tb-cli/tests/compat.rs +++ b/crates/tb-cli/tests/compat.rs @@ -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 { +/// `x` 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 x lesbar")); + let parse = |s: &str, was: &str| { + s.trim() + .parse::() + .unwrap_or_else(|_| panic!("{name}: {was} {s:?} ist keine Zahl")) + }; + (parse(c, "Spaltenzahl"), parse(r, "Zeilenzahl")) +} + +/// Wert einer Kopfzeilen-Direktive `' : ` aus den ersten Zeilen. +fn direktive(src: &str, marke: &str) -> Option { + 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: x[, …]`. +/// 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: x`" + ) + }); + wert.split(',').map(|w| groesse_lesen(name, w)).collect() +} + +/// Optionale Größenänderung mitten im Lauf: +/// `' tb-resize: nach auf x`. +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 auf x`, 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::() + }; + 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::() + .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::() + .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 = 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()); + } + } +} diff --git a/crates/tb-frontend/src/hir.rs b/crates/tb-frontend/src/hir.rs index d78d991..c8cba0c 100644 --- a/crates/tb-frontend/src/hir.rs +++ b/crates/tb-frontend/src/hir.rs @@ -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, + var: Option, + }, Input { + /// `INPUT #n` — Quelle ist eine Datei statt der Tastatur. + file: Option, line_mode: bool, prompt: Option, /// 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), /// Ziel als Index in `HirModule::data` (0 = Anfang). Restore(u32), diff --git a/crates/tb-frontend/src/lexer.rs b/crates/tb-frontend/src/lexer.rs index 19f2410..6f8bc89 100644 --- a/crates/tb-frontend/src/lexer.rs +++ b/crates/tb-frontend/src/lexer.rs @@ -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; } } diff --git a/crates/tb-frontend/src/parser.rs b/crates/tb-frontend/src/parser.rs index 0b24312..c204e84 100644 --- a/crates/tb-frontend/src/parser.rs +++ b/crates/tb-frontend/src/parser.rs @@ -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 { 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 { + // `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::*; diff --git a/crates/tb-frontend/src/sema.rs b/crates/tb-frontend/src/sema.rs index 67bcfdb..2959ba6 100644 --- a/crates/tb-frontend/src/sema.rs +++ b/crates/tb-frontend/src/sema.rs @@ -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, Vec) { 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)>, /// Modulvariablen (Schlüssel wie `var_key`). module_vars: HashMap, + /// Sprungziele des Modulrumpfs — ein `ON ERROR GOTO` ohne `LOCAL` in + /// einer Prozedur verweist auf sie (Scoping-Regel des Vorbilds). + module_labels: HashMap, + module_line_labels: HashMap, /// Globale Slots (Modulvariablen, STATICs, versteckte Temps). globals: Vec, /// 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 { + 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 = 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 = 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, 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 { + 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 = (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 = (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 = (0..3).map(|i| conv_arg!(i, NumTy::Dbl)).collect(); + b(Builtin::Sln, a, Ty::Dbl, self) + } + "SYD" | "SYD#" | "DDB" | "DDB#" => { + let a: Vec = (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())); } diff --git a/crates/tb-frontend/tests/inventar.rs b/crates/tb-frontend/tests/inventar.rs new file mode 100644 index 0000000..d84fd9d --- /dev/null +++ b/crates/tb-frontend/tests/inventar.rs @@ -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 { + 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 { + let mut out = BTreeSet::new(); + let bytes: Vec = 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, + verboten: BTreeSet, + nicht_verfuegbar: BTreeSet, + /// Non-Features, die an ihrer Syntax statt an ihrem Namen erkannt werden. + syntaktisch: BTreeSet, +} + +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 = 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> = 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 = 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 = 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 = a.diagnostics.iter().map(|d| d.message.clone()).collect(); + assert!( + texte.iter().any(|m| m.contains("MKL$")), + "Diagnose nennt `MKL$` nicht: {texte:?}" + ); +} diff --git a/crates/tb-runtime/Cargo.toml b/crates/tb-runtime/Cargo.toml index 448afa4..225cb75 100644 --- a/crates/tb-runtime/Cargo.toml +++ b/crates/tb-runtime/Cargo.toml @@ -7,5 +7,6 @@ license.workspace = true authors.workspace = true [dependencies] +unicode-width.workspace = true thiserror.workspace = true log.workspace = true diff --git a/crates/tb-runtime/src/builtins.rs b/crates/tb-runtime/src/builtins.rs index 5e91cba..56a9e53 100644 --- a/crates/tb-runtime/src/builtins.rs +++ b/crates/tb-runtime/src/builtins.rs @@ -6,15 +6,47 @@ //! bildet `hir::Builtin` über ein erschöpfendes `match` darauf ab. use crate::console::PrintState; +use crate::datetime as dt; +use crate::finanz as fin; +use crate::fileio as fio; +use crate::value; use crate::errors::RuntimeError; use crate::format; -use crate::host::Host; +use crate::host::{Ereignis, Host}; +use crate::screen::TextScreen; use crate::value::{as_f64, cur_to_f64, f64_to_cur, Value}; +use std::collections::VecDeque; use std::rc::Rc; -/// Laufzeitzustand der Bibliothek (PRNG, Druckspalte, Kommandozeile). +/// Laufzeitzustand der Bibliothek: Bildschirm, Eingabepuffer, PRNG, +/// Kommandozeile. pub struct RtState { + /// Der Textbildschirm — Ziel aller Bildschirmwirkungen. + pub screen: TextScreen, + /// Offene Dateien nach Dateinummer. + pub dateien: crate::fileio::Dateien, pub print: PrintState, + /// Noch nicht abgeholte Tastendrücke in `INKEY$`-Form. + pub tasten: VecDeque, + /// Abbruchwunsch (Strg+Untbr) wurde gemeldet. + pub abbruch: bool, + /// Eingabeende wurde gemeldet. + pub ende: bool, + /// Funktionstasten-Makros: F1–F10, dann F11/F12 (`KEY 30`/`KEY 31`). + pub key_makros: [String; 12], + /// Ist die Softkey-Zeile eingeblendet (`KEY ON`)? + pub key_zeile: bool, + /// Währungszeichen für `$$` in `PRINT USING` (siehe `SetFormatCC`). + pub waehrung: crate::using::Waehrung, + /// Ablaufverfolgung (`TRON`/`TROFF`) eingeschaltet? + pub trace: bool, + /// Trefferliste und Zeiger für `DIR$`. + pub dir_treffer: Vec, + pub dir_index: usize, + /// Versatz der Programmuhr in Sekunden. `DATE$ =` und `TIME$ =` können + /// die Systemuhr nicht stellen; sie verschieben stattdessen diesen + /// Versatz (dokumentierte Abweichung). + pub uhr_offset: i64, rng: u32, rnd_last: f32, pub command: String, @@ -23,7 +55,21 @@ pub struct RtState { impl Default for RtState { fn default() -> Self { RtState { + screen: TextScreen::new(), + dateien: Default::default(), print: PrintState::default(), + tasten: VecDeque::new(), + abbruch: false, + ende: false, + // Das Vorbild belegt die Makros im Interpreter vor; ein + // kompiliertes Programm startet mit leeren Makros. + key_makros: Default::default(), + key_zeile: false, + waehrung: Default::default(), + trace: false, + dir_treffer: Vec::new(), + dir_index: 0, + uhr_offset: 0, // Startzustand des Vorbild-PRNG; die exakte // PRNG-Kompatibilität ist Aufgabe in PLAN.md Phase 3 // („RND/RANDOMIZE — kompatibler PRNG"). @@ -35,6 +81,132 @@ impl Default for RtState { } impl RtState { + /// Ereignisse vom Host abholen und einsortieren: Größenänderungen wirken + /// sofort auf den Bildschirm, Abbruch und Eingabeende werden gemerkt, + /// Tastendrücke landen in der Warteschlange für `INKEY$`/`INPUT`. + /// + /// Mit `blockierend = true` wird gewartet, bis mindestens ein Ereignis + /// kam oder das Eingabeende erreicht ist. + pub fn pump(&mut self, host: &mut dyn Host, blockierend: bool) { + loop { + let Some(e) = host.next_event(blockierend) else { + return; + }; + match e { + Ereignis::Taste(t) => self.tasten.push_back(t), + Ereignis::Groesse { cols, rows } => self.screen.resize(cols, rows), + Ereignis::Abbruch => self.abbruch = true, + Ereignis::Ende => self.ende = true, + } + if blockierend { + return; + } + } + } + + /// Bildschirm anzeigen und anstehende Ereignisse einsammeln. + /// Zustellpunkt an Tick-Grenzen und vor blockierender Eingabe. + pub fn tick(&mut self, host: &mut dyn Host) { + host.present(&self.screen); + self.pump(host, false); + } + + /// Nächster Tastendruck in `INKEY$`-Form; blockiert bei `blockierend`. + pub fn naechste_taste(&mut self, host: &mut dyn Host, blockierend: bool) -> Option { + loop { + if let Some(t) = self.tasten.pop_front() { + return Some(t); + } + if self.ende { + return None; + } + self.pump(host, blockierend); + if !blockierend && self.tasten.is_empty() { + return None; + } + if blockierend && self.tasten.is_empty() && self.ende { + return None; + } + } + } + + /// Zeileneingabe auf dem Bildschirm: Zeichen werden geechot, Rückschritt + /// löscht, Enter schließt ab. `None` = Eingabeende (Fehler 62). + pub fn read_line(&mut self, host: &mut dyn Host) -> Option { + let mut zeile = String::new(); + loop { + host.present(&self.screen); + let t = self.naechste_taste(host, true)?; + match t.as_str() { + crate::host::taste::ENTER => { + self.screen.print("\n"); + return Some(zeile); + } + crate::host::taste::BACKSPACE => { + if zeile.pop().is_some() { + self.screen.rueckschritt(); + } + } + _ => { + // Sondertasten (Nullzeichen + Kennung) erzeugen keine Eingabe. + if let Some(c) = einzelzeichen(&t) { + zeile.push(c); + self.screen.print(&c.to_string()); + } + } + } + } + } + + /// Softkey-Zeile ein-/ausblenden (`KEY ON`/`KEY OFF`). Eingeblendet + /// belegt sie die unterste Zeile; der Scrollbereich endet darüber, damit + /// die Ausgabe sie nicht überschreibt. + pub fn softkey_zeile(&mut self, ein: bool) { + if ein == self.key_zeile { + return; + } + self.key_zeile = ein; + let rows = self.screen.rows(); + if ein { + let _ = self.screen.view_print(1, rows - 1); + self.softkey_zeile_zeichnen(); + } else { + let (z, s) = (self.screen.csrlin(), self.screen.pos()); + let _ = self.screen.view_print(1, rows); + let _ = self.screen.locate(rows, 1); + self.screen.print(&" ".repeat(self.screen.cols())); + let _ = self.screen.locate(z, s); + } + } + + /// Die Softkey-Zeile neu zeichnen: Tastennummer plus die ersten sechs + /// Zeichen des Makros, wie beim Vorbild. + fn softkey_zeile_zeichnen(&mut self) { + let (z, s) = (self.screen.csrlin(), self.screen.pos()); + let breite = self.screen.cols(); + let mut zeile = String::new(); + for (i, m) in self.key_makros.iter().take(10).enumerate() { + zeile.push_str(&format!("{}", i + 1)); + zeile.extend(m.chars().take(6)); + zeile.push(' '); + } + zeile.truncate(breite); + let rows = self.screen.rows(); + let _ = self.screen.locate(rows, 1); + self.screen.print(&format!("{zeile:> 32) ^ (b >> 48)) as u16; + self.rng = (m as u32) << 8; + } + fn rng_next(&mut self) -> f32 { self.rng = self.rng.wrapping_mul(0xFD43FD).wrapping_add(0xC39EC3) & 0xFF_FFFF; self.rnd_last = self.rng as f32 / 16_777_216.0; @@ -42,6 +214,17 @@ impl RtState { } } +/// Ein Tastendruck als druckbares Zeichen — Sondertasten (führendes +/// Nullzeichen) und Steuerzeichen liefern `None`. +fn einzelzeichen(t: &str) -> Option { + let mut it = t.chars(); + let c = it.next()?; + if it.next().is_some() || c == '\0' || (c as u32) < 0x20 { + return None; + } + Some(c) +} + pub type BuiltinFn = fn(&mut RtState, &mut dyn Host, &mut [Value]) -> Result, RuntimeError>; @@ -84,14 +267,95 @@ pub mod ids { pub const PRINT_TAB: u16 = 34; pub const PRINT_SPC: u16 = 35; pub const PRINT_NEWLINE: u16 = 36; - pub const TIMER: u16 = 37; - pub const DATE_S: u16 = 38; - pub const TIME_S: u16 = 39; - pub const COMMAND_S: u16 = 40; - pub const DOEVENTS: u16 = 41; - pub const SLEEP: u16 = 42; - pub const BEEP: u16 = 43; - pub const COUNT: u16 = 44; + pub const PRINT_USING: u16 = 37; + pub const FORMAT_S: u16 = 38; + pub const SET_FORMAT_CC: u16 = 39; + pub const CLS: u16 = 40; + pub const COLOR: u16 = 41; + pub const LOCATE: u16 = 42; + pub const WIDTH: u16 = 43; + pub const VIEW_PRINT: u16 = 44; + pub const SCREEN_STMT: u16 = 45; + pub const KEY_ASSIGN: u16 = 46; + pub const KEY_LIST: u16 = 47; + pub const KEY_DISPLAY: u16 = 48; + pub const CSRLIN: u16 = 49; + pub const POS_FN: u16 = 50; + pub const SCREEN_FN: u16 = 51; + pub const INKEY_S: u16 = 52; + pub const INPUT_S: u16 = 53; + pub const TIMER: u16 = 54; + pub const DATE_S: u16 = 55; + pub const TIME_S: u16 = 56; + pub const DATE_SET: u16 = 57; + pub const TIME_SET: u16 = 58; + pub const NOW: u16 = 59; + pub const DATE_SERIAL: u16 = 60; + pub const TIME_SERIAL: u16 = 61; + pub const DATE_VALUE: u16 = 62; + pub const TIME_VALUE: u16 = 63; + pub const DAY_F: u16 = 64; + pub const MONTH_F: u16 = 65; + pub const YEAR_F: u16 = 66; + pub const WEEKDAY_F: u16 = 67; + pub const HOUR_F: u16 = 68; + pub const MINUTE_F: u16 = 69; + pub const SECOND_F: u16 = 70; + pub const COMMAND_S: u16 = 71; + pub const DOEVENTS: u16 = 72; + pub const SLEEP: u16 = 73; + pub const BEEP: u16 = 74; + pub const FV: u16 = 75; + pub const PV: u16 = 76; + pub const PMT: u16 = 77; + pub const NPER: u16 = 78; + pub const IPMT: u16 = 79; + pub const PPMT: u16 = 80; + pub const RATE: u16 = 81; + pub const NPV: u16 = 82; + pub const IRR: u16 = 83; + pub const MIRR: u16 = 84; + pub const SLN: u16 = 85; + pub const SYD: u16 = 86; + pub const DDB: u16 = 87; + pub const OPEN: u16 = 88; + pub const CLOSE: u16 = 89; + pub const CLOSE_ALL: u16 = 90; + pub const PRINT_ZIEL: u16 = 91; + pub const WRITE_FILE: u16 = 92; + pub const EOF_F: u16 = 93; + pub const LOF_F: u16 = 94; + pub const LOC_F: u16 = 95; + pub const SEEK_F: u16 = 96; + pub const SEEK_STMT: u16 = 97; + pub const FREEFILE: u16 = 98; + pub const FILEATTR: u16 = 99; + pub const LOCK_STMT: u16 = 100; + pub const KILL: u16 = 101; + pub const NAME_STMT: u16 = 102; + pub const FILES: u16 = 103; + pub const CHDIR: u16 = 104; + pub const CHDRIVE: u16 = 105; + pub const MKDIR: u16 = 106; + pub const RMDIR: u16 = 107; + pub const CURDIR_S: u16 = 108; + pub const DIR_S: u16 = 109; + pub const LPOS: u16 = 110; + pub const SHELL_STMT: u16 = 111; + pub const SHELL_FN: u16 = 112; + pub const MK_S: u16 = 113; + pub const CV_F: u16 = 114; + pub const ENVIRON_S: u16 = 115; + pub const ENVIRON_SET: u16 = 116; + pub const FRE: u16 = 117; + pub const CLEAR: u16 = 118; + pub const TRON: u16 = 119; + pub const TROFF: u16 = 120; + pub const STACK_FN: u16 = 121; + pub const STACK_STMT: u16 = 122; + pub const ERDEV: u16 = 123; + pub const ERDEV_S: u16 = 124; + pub const COUNT: u16 = 125; } /// Dispatch-Tabelle in Index-Reihenfolge. @@ -134,13 +398,94 @@ pub fn builtin_table() -> &'static [BuiltinFn] { bi_print_tab, bi_print_spc, bi_print_newline, + bi_print_using, + bi_format_s, + bi_set_format_cc, + bi_cls, + bi_color, + bi_locate, + bi_width, + bi_view_print, + bi_screen_stmt, + bi_key_assign, + bi_key_list, + bi_key_display, + bi_csrlin, + bi_pos, + bi_screen_fn, + bi_inkey, + bi_input_s, bi_timer, bi_date, bi_time, + bi_date_set, + bi_time_set, + bi_now, + bi_date_serial, + bi_time_serial, + bi_date_value, + bi_time_value, + bi_day, + bi_month, + bi_year, + bi_weekday, + bi_hour, + bi_minute, + bi_second, bi_command, bi_doevents, bi_sleep, bi_beep, + bi_fv, + bi_pv, + bi_pmt, + bi_nper, + bi_ipmt, + bi_ppmt, + bi_rate, + bi_npv, + bi_irr, + bi_mirr, + bi_sln, + bi_syd, + bi_ddb, + bi_open, + bi_close, + bi_close_all, + bi_print_ziel, + bi_write_file, + bi_eof, + bi_lof, + bi_loc, + bi_seek_f, + bi_seek_stmt, + bi_freefile, + bi_fileattr, + bi_lock, + bi_kill, + bi_name, + bi_files, + bi_chdir, + bi_chdrive, + bi_mkdir, + bi_rmdir, + bi_curdir, + bi_dir, + bi_lpos, + bi_shell_stmt, + bi_shell_fn, + bi_mk, + bi_cv, + bi_environ_s, + bi_environ_set, + bi_fre, + bi_clear, + bi_tron, + bi_troff, + bi_stack_fn, + bi_stack_stmt, + bi_erdev, + bi_erdev_s, ]; debug_assert_eq!(TABLE.len(), ids::COUNT as usize); TABLE @@ -445,7 +790,7 @@ fn bi_rnd(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result> 20) as u32 & 0xFF_FFFF; + st.saat_setzen(-x); } st.rng_next() } @@ -453,47 +798,650 @@ fn bi_rnd(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result Result, RuntimeError> { - let x = if a.is_empty() { 0.0 } else { arg_f64(a, 0)? }; - // 16 Bit aus dem Argument in Bits 8–23 des Zustands (Vorbild-Schema; - // die exakte Mischung klärt die Phase-3-Aufgabe „RND/RANDOMIZE — - // kompatibler PRNG" in PLAN.md). - let b = x.to_bits(); - let m = ((b >> 32) ^ (b >> 48)) as u16; - st.rng = ((m as u32) << 8) | (st.rng & 0xFF); +fn bi_randomize(st: &mut RtState, host: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let x = match a.first() { + Some(_) => arg_f64(a, 0)?, + // `RANDOMIZE` ohne Argument erfragt den Startwert (Vorbild). + None => { + st.screen.print("Random Number Seed (-32768 to 32767)? "); + let Some(zeile) = st.read_line(host) else { + return Err(RuntimeError(62)); // Input past end of file + }; + crate::format::val(&zeile) + } + }; + st.saat_setzen(x); Ok(None) } // ---- Konsole ------------------------------------------------------------------ -fn bi_print_val(st: &mut RtState, host: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { +fn bi_print_val(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { let v = a.first().ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; - st.print.print_value(host, v); + st.print.print_value(&mut st.screen, &mut st.dateien, v)?; Ok(None) } -fn bi_print_comma(st: &mut RtState, host: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { - st.print.print_comma(host); +fn bi_print_comma(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + st.print.print_comma(&mut st.screen, &mut st.dateien)?; Ok(None) } -fn bi_print_tab(st: &mut RtState, host: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { +fn bi_print_tab(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { let n = arg_i32(a, 0)?; - st.print.print_tab(host, n); + st.print.print_tab(&mut st.screen, &mut st.dateien, n)?; Ok(None) } -fn bi_print_spc(st: &mut RtState, host: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { +fn bi_print_spc(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { let n = arg_i32(a, 0)?; - st.print.print_spc(host, n); + st.print.print_spc(&mut st.screen, &mut st.dateien, n)?; Ok(None) } -fn bi_print_newline(st: &mut RtState, host: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { - st.print.print_newline(host); +fn bi_print_newline(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + st.print.print_newline(&mut st.screen, &mut st.dateien)?; Ok(None) } +// ---- System ---------------------------------------------------------------------- + +/// Ersatzwerte für Größen ohne Entsprechung auf heutigen Plattformen +/// (dokumentierte Abweichung, s. docs/sprachreferenz.md). +pub const FRE_ERSATZ: i32 = 65_536; +pub const STACK_ERSATZ: i32 = 8_192; + +fn bi_environ_s(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // Mit String: Wert der Variablen. Mit Zahl: der n-te Eintrag als + // `NAME=WERT` (Vorbild). + match a.first() { + Some(Value::Str(name)) => s_ok(std::env::var(name.as_ref()).unwrap_or_default()), + Some(_) => { + let n = arg_i32(a, 0)?; + if n < 1 { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + let mut alle: Vec<(String, String)> = std::env::vars().collect(); + alle.sort(); + s_ok( + alle.get(n as usize - 1) + .map(|(k, v)| format!("{k}={v}")) + .unwrap_or_default(), + ) + } + None => Err(RuntimeError::ILLEGAL_FUNCTION_CALL), + } +} + +fn bi_environ_set(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // `ENVIRON "NAME=WERT"`; ohne Wert wird die Variable entfernt. + let text = arg_str(a, 0)?; + let (name, wert) = text + .split_once('=') + .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; + if name.is_empty() { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + if wert.is_empty() { + unsafe { std::env::remove_var(name) }; + } else { + unsafe { std::env::set_var(name, wert) }; + } + Ok(None) +} + +fn bi_fre(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + Ok(Some(Value::Lng(FRE_ERSATZ))) +} + +fn bi_clear(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // `CLEAR` schließt alle Dateien und setzt den Druckzustand zurück. + // Variablen räumt der Codegenerator nicht — das übernimmt der Runner + // beim Neustart (dokumentierte Abweichung). + st.dateien.alle_schliessen()?; + st.print.ziel = crate::console::Ziel::Bildschirm; + st.print.spalte = 0; + Ok(None) +} + +fn bi_tron(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + st.trace = true; + Ok(None) +} + +fn bi_troff(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + st.trace = false; + Ok(None) +} + +fn bi_stack_fn(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + Ok(Some(Value::Lng(STACK_ERSATZ))) +} + +fn bi_stack_stmt(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // Die Stapelgröße bestimmt die Wirtsplattform; die Anweisung ist + // folgenlos (dokumentierte Abweichung). + Ok(None) +} + +fn bi_erdev(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // Gerätefehlercodes des Vorbilds gibt es nicht; 0 = kein Fehler. + Ok(Some(Value::Int(0))) +} + +fn bi_erdev_s(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + s_ok(String::new()) +} + +// ---- Datei-E/A -------------------------------------------------------------------- + +fn bi_open(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // (dateiname$, nummer, modus, reclen) + let pfad = arg_str(a, 0)?; + let nummer = arg_i32(a, 1)?; + let modus = fio::Modus::aus_text(&arg_str(a, 2)?) + .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; + let reclen = arg_i32(a, 3)?.max(1) as usize; + st.dateien.oeffnen(nummer, &pfad, modus, reclen) + .map(|_| None) +} + +fn bi_close(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + st.dateien.schliessen(arg_i32(a, 0)?).map(|_| None) +} + +fn bi_close_all(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + st.dateien.alle_schliessen().map(|_| None) +} + +/// Ausgabeziel von `PRINT` umschalten: −1 = Bildschirm, −2 = Drucker, +/// sonst die Dateinummer. +fn bi_print_ziel(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let n = arg_i32(a, 0)?; + st.print.ziel = match n { + -1 => crate::console::Ziel::Bildschirm, + -2 => crate::console::Ziel::Drucker, + n => { + if !st.dateien.ist_offen(n) { + return Err(RuntimeError(52)); + } + crate::console::Ziel::Datei(n) + } + }; + // Datei- und Druckerspalte beginnen bei jedem Wechsel neu am Zeilenanfang + // nur dann, wenn zuvor der Bildschirm aktiv war. + if n == -1 { + st.print.spalte = 0; + } + Ok(None) +} + +fn bi_write_file(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // Elemente komma-getrennt, Strings in Anführungszeichen, dann Umbruch. + let mut zeile = String::new(); + for (i, v) in a.iter().enumerate() { + if i > 0 { + zeile.push(','); + } + match v { + Value::Str(s) => zeile.push_str(&fio::write_element(s, true)), + _ => zeile.push_str(&fio::write_element(&format::format_print(v), false)), + } + } + zeile.push('\n'); + st.print.write(&mut st.screen, &mut st.dateien, &zeile)?; + Ok(None) +} + +fn bi_eof(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let e = st.dateien.get(arg_i32(a, 0)?)?.eof()?; + Ok(Some(Value::Int(if e { -1 } else { 0 }))) +} + +fn bi_lof(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let l = st.dateien.get(arg_i32(a, 0)?)?.laenge()?; + Ok(Some(Value::Lng(l as i32))) +} + +fn bi_loc(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let d = st.dateien.get(arg_i32(a, 0)?)?; + // Position der letzten Operation (1-basiert): Datensatz bzw. Byte. + Ok(Some(Value::Lng(d.position.saturating_sub(1) as i32))) +} + +fn bi_seek_f(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let d = st.dateien.get(arg_i32(a, 0)?)?; + Ok(Some(Value::Lng(d.position as i32))) +} + +fn bi_seek_stmt(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let pos = arg_i32(a, 1)?; + if pos < 1 { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + st.dateien.get(arg_i32(a, 0)?)?.position = pos as u64; + Ok(None) +} + +fn bi_freefile(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + Ok(Some(Value::Int(st.dateien.freie_nummer() as i16))) +} + +fn bi_fileattr(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let d = st.dateien.get(arg_i32(a, 0)?)?; + // 1 = Modus. Andere Kennzahlen (Betriebssystem-Handle) bildet der + // Dialekt nicht ab; sie liefern 0 (dokumentierte Abweichung). + let wert = if arg_i32(a, 1)? == 1 { + match d.modus { + fio::Modus::Input => 1, + fio::Modus::Output => 2, + fio::Modus::Random => 4, + fio::Modus::Append => 8, + fio::Modus::Binary => 32, + } + } else { + 0 + }; + Ok(Some(Value::Lng(wert))) +} + +fn bi_lock(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // Die Datei muss offen sein; echte Sperren bietet die Plattform nicht + // einheitlich (dokumentierte Abweichung). + st.dateien.get(arg_i32(a, 0)?)?; + Ok(None) +} + +fn bi_kill(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + fio::loeschen(&arg_str(a, 0)?).map(|_| None) +} + +fn bi_name(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + fio::umbenennen(&arg_str(a, 0)?, &arg_str(a, 1)?).map(|_| None) +} + +fn bi_files(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let muster = match a.first() { + Some(Value::Str(s)) if !s.is_empty() => s.to_string(), + _ => "*".to_string(), + }; + for n in fio::suchen(&muster)? { + st.screen.print(&n); + st.screen.print("\n"); + } + Ok(None) +} + +fn bi_chdir(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + fio::verzeichnis_wechseln(&arg_str(a, 0)?).map(|_| None) +} + +fn bi_chdrive(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // Laufwerksbuchstaben gibt es nur auf Windows; anderswo ist die + // Anweisung folgenlos (dokumentierte Abweichung). + let _ = arg_str(a, 0)?; + Ok(None) +} + +fn bi_mkdir(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + fio::verzeichnis_anlegen(&arg_str(a, 0)?).map(|_| None) +} + +fn bi_rmdir(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + fio::verzeichnis_entfernen(&arg_str(a, 0)?).map(|_| None) +} + +fn bi_curdir(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + s_ok(fio::aktuelles_verzeichnis()) +} + +fn bi_dir(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // `DIR$(muster$)` beginnt eine Suche, `DIR$("")` liefert den nächsten + // Treffer, zuletzt den leeren String. + let muster = arg_str(a, 0)?; + if !muster.is_empty() { + st.dir_treffer = fio::suchen(&muster)?; + st.dir_index = 0; + } + let t = st.dir_treffer.get(st.dir_index).cloned().unwrap_or_default(); + if !t.is_empty() { + st.dir_index += 1; + } + s_ok(t) +} + +fn bi_lpos(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // Spalte im Druckerpuffer (1-basiert). + let spalte = match st.print.drucker.rfind('\n') { + Some(i) => st.print.drucker[i + 1..].chars().count(), + None => st.print.drucker.chars().count(), + }; + Ok(Some(Value::Int(spalte as i16 + 1))) +} + +/// Gemeinsamer Kern von `SHELL` als Anweisung und als Funktion. +fn shell_ausfuehren(befehl: &str) -> Result { + let mut cmd = if cfg!(windows) { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", befehl]); + c + } else { + let mut c = std::process::Command::new("sh"); + c.args(["-c", befehl]); + c + }; + cmd.status() + .map(|s| s.code().unwrap_or(0)) + .map_err(|_| RuntimeError(53)) +} + +fn bi_shell_stmt(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let befehl = match a.first() { + Some(Value::Str(s)) => s.to_string(), + _ => String::new(), + }; + if befehl.is_empty() { + return Ok(None); + } + shell_ausfuehren(&befehl).map(|_| None) +} + +fn bi_shell_fn(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let code = shell_ausfuehren(&arg_str(a, 0)?)?; + Ok(Some(Value::Lng(code))) +} + +/// `MKI$`/`MKL$`/`MKS$`/`MKD$`/`MKC$` — Zahl in ihre Bytedarstellung. +/// Das zweite Argument wählt die Breite (2/4/4/8/8) und den Typ. +fn bi_mk(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let art = arg_i32(a, 1)?; + let bytes: Vec = match art { + 0 => (arg_i32(a, 0)? as i16).to_le_bytes().to_vec(), + 1 => arg_i32(a, 0)?.to_le_bytes().to_vec(), + 2 => (arg_f64(a, 0)? as f32).to_le_bytes().to_vec(), + 3 => arg_f64(a, 0)?.to_le_bytes().to_vec(), + _ => match a.first() { + Some(Value::Cur(c)) => c.to_le_bytes().to_vec(), + _ => value::f64_to_cur(arg_f64(a, 0)?)?.to_le_bytes().to_vec(), + }, + }; + // Bytes als Zeichen mit Codepoint 0–255 ablegen (verlustfrei umkehrbar). + s_ok(bytes.iter().map(|b| *b as char).collect::()) +} + +/// `CVI`/`CVL`/`CVS`/`CVD`/`CVC` — Bytedarstellung zurück in eine Zahl. +fn bi_cv(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let text = arg_str(a, 0)?; + let art = arg_i32(a, 1)?; + let breite = match art { + 0 => 2, + 1 | 2 => 4, + _ => 8, + }; + let bytes: Vec = text + .chars() + .map(|c| u8::try_from(c as u32).unwrap_or(0)) + .collect(); + if bytes.len() < breite { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + Ok(Some(match art { + 0 => Value::Int(i16::from_le_bytes([bytes[0], bytes[1]])), + 1 => Value::Lng(i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])), + 2 => Value::Sng(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])), + 3 => Value::Dbl(f64::from_le_bytes(bytes[..8].try_into().unwrap())), + _ => Value::Cur(i64::from_le_bytes(bytes[..8].try_into().unwrap())), + })) +} + +// ---- Finanzmathematik ----------------------------------------------------------- + +/// Zahlungsreihe aus einem Array-Argument lesen. +fn arg_reihe(a: &[Value], i: usize) -> Result, RuntimeError> { + match a.get(i) { + Some(Value::Arr(arr)) => Ok(arr.borrow().data.iter().map(as_f64).collect()), + _ => Err(RuntimeError::TYPE_MISMATCH), + } +} + +macro_rules! finanz_fn { + ($name:ident, |$a:ident| $ausdruck:expr) => { + fn $name(_: &mut RtState, _: &mut dyn Host, $a: &mut [Value]) -> Result, RuntimeError> { + Ok(Some(Value::Dbl($ausdruck?))) + } + }; +} + +finanz_fn!(bi_fv, |a| fin::fv(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?, arg_f64(a, 4)?)); +finanz_fn!(bi_pv, |a| fin::pv(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?, arg_f64(a, 4)?)); +finanz_fn!(bi_pmt, |a| fin::pmt(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?, arg_f64(a, 4)?)); +finanz_fn!(bi_nper, |a| fin::nper(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?, arg_f64(a, 4)?)); +finanz_fn!(bi_ipmt, |a| fin::ipmt(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?, arg_f64(a, 4)?, arg_f64(a, 5)?)); +finanz_fn!(bi_ppmt, |a| fin::ppmt(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?, arg_f64(a, 4)?, arg_f64(a, 5)?)); +finanz_fn!(bi_rate, |a| fin::rate(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?, arg_f64(a, 4)?, arg_f64(a, 5)?)); +finanz_fn!(bi_npv, |a| fin::npv(arg_f64(a, 0)?, &arg_reihe(a, 1)?)); +finanz_fn!(bi_irr, |a| fin::irr(&arg_reihe(a, 0)?, arg_f64(a, 1)?)); +finanz_fn!(bi_mirr, |a| fin::mirr(&arg_reihe(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?)); +finanz_fn!(bi_sln, |a| fin::sln(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?)); +finanz_fn!(bi_syd, |a| fin::syd(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?)); +finanz_fn!(bi_ddb, |a| fin::ddb(arg_f64(a, 0)?, arg_f64(a, 1)?, arg_f64(a, 2)?, arg_f64(a, 3)?)); + +// ---- Formatierung --------------------------------------------------------------- + +fn bi_print_using(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let fmt = arg_str(a, 0)?; + let text = crate::using::using(&fmt, &a[1..], &st.waehrung)?; + st.print.write(&mut st.screen, &mut st.dateien, &text)?; + Ok(None) +} + +fn bi_format_s(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // `FORMAT$(wert [, format$])` — ohne Format wie `LTRIM$(STR$(wert))`. + let wert = a.first().cloned().unwrap_or(Value::Int(0)); + let text = match a.get(1) { + Some(Value::Str(f)) => crate::using::using(f, std::slice::from_ref(&wert), &st.waehrung)?, + None => format::format_print(&wert).trim().to_string(), + Some(_) => return Err(RuntimeError::TYPE_MISMATCH), + }; + Ok(Some(Value::Str(Rc::from(text.as_str())))) +} + +fn bi_set_format_cc(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // Ländercode → Währungszeichen. Die Tabelle des Vorbilds ist nicht + // vollständig belegt; nicht aufgeführte Codes behalten "$" + // (dokumentierte Abweichung). + let code = arg_i32(a, 0)?; + st.waehrung.zeichen = match code { + 1 => "$", + 33 => "F", + 39 => "L", + 44 => "£", + 49 => "DM", + 81 => "¥", + _ => "$", + } + .to_string(); + Ok(None) +} + +// ---- Bildschirm ----------------------------------------------------------------- + +/// Ausgelassenes Argument (`LOCATE , 5`) kommt als -1 herein. +fn opt(a: &[Value], i: usize) -> Result, RuntimeError> { + match a.get(i) { + None => Ok(None), + Some(_) => { + let v = arg_i32(a, i)?; + Ok(if v < 0 { None } else { Some(v) }) + } + } +} + +fn bi_cls(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // `CLS`, `CLS 0` und `CLS 2` löschen bei uns dasselbe: den Textbereich. + // Einen getrennten Grafikbereich gibt es nicht (Non-Feature). + st.screen.cls(); + Ok(None) +} + +fn bi_color(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let fg = opt(a, 0)?.unwrap_or(st.screen.fg as i32); + let bg = opt(a, 1)?.unwrap_or(st.screen.bg as i32); + // Vordergrund 0–31 (16–31 = blinkend, als hell simuliert), Hintergrund 0–7. + if !(0..=31).contains(&fg) || !(0..=7).contains(&bg) { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + st.screen.set_color(fg as u8, bg as u8); + Ok(None) +} + +fn bi_locate(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let zeile = opt(a, 0)?.unwrap_or(st.screen.csrlin() as i32); + let spalte = opt(a, 1)?.unwrap_or(st.screen.pos() as i32); + // Drittes Argument steuert die Cursorsichtbarkeit; Startzeile/Endzeile + // der Cursorform (4./5.) bildet ein Terminal nicht ab und bleibt folgenlos. + if let Some(sichtbar) = opt(a, 2)? { + st.screen.cursor_visible = sichtbar != 0; + } + if zeile < 1 || spalte < 1 { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + st.screen + .locate(zeile as usize, spalte as usize) + .map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?; + Ok(None) +} + +fn bi_width(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // Die Größe bestimmt das Terminal; WIDTH hebt den Puffer höchstens an + // (dokumentierte Abweichung). Kleinere Werte bleiben folgenlos. + let cols = opt(a, 0)?.unwrap_or(st.screen.cols() as i32); + let rows = opt(a, 1)?.unwrap_or(st.screen.rows() as i32); + if cols < 1 || rows < 1 { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + st.screen.resize(cols as usize, rows as usize); + Ok(None) +} + +fn bi_view_print(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + match (opt(a, 0)?, opt(a, 1)?) { + // `VIEW PRINT` ohne Argumente: voller Bildschirm. + (None, None) => { + let rows = st.screen.rows(); + st.screen + .view_print(1, rows) + .map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?; + } + (Some(t), Some(b)) if t >= 1 && b >= t => { + st.screen + .view_print(t as usize, b as usize) + .map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?; + } + _ => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL), + } + Ok(None) +} + +fn bi_screen_stmt(_: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + // `SCREEN modus[, farbschalter[, aktiv[, sichtbar]]]`. Nur der Textmodus 0 + // existiert; Grafikmodi sind deklariertes Non-Feature. Die übrigen + // Argumente betreffen Grafikseiten und bleiben folgenlos. + match opt(a, 0)? { + None | Some(0) => Ok(None), + Some(_) => Err(RuntimeError(73)), // Advanced feature unavailable + } +} + +/// Nummer eines Funktionstasten-Makros → Index 0–11. +fn key_index(n: i32) -> Option { + match n { + 1..=10 => Some(n as usize - 1), + 30 => Some(10), + 31 => Some(11), + _ => None, + } +} + +fn bi_key_assign(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let n = arg_i32(a, 0)?; + let text = arg_str(a, 1)?; + let i = key_index(n).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; + // Höchstens 15 Zeichen; überzählige werden verworfen (Vorbild). + st.key_makros[i] = text.chars().take(15).collect(); + if st.key_zeile { + st.softkey_zeile_zeichnen(); + } + Ok(None) +} + +fn bi_key_list(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // Alle zwölf Makros untereinander, wie im Vorbild. + for m in st.key_makros.clone() { + st.screen.print(&m); + st.screen.print("\n"); + } + Ok(None) +} + +fn bi_key_display(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let ein = arg_i32(a, 0)? != 0; + st.softkey_zeile(ein); + Ok(None) +} + +fn bi_csrlin(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + Ok(Some(Value::Int(st.screen.csrlin() as i16))) +} + +fn bi_pos(st: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + Ok(Some(Value::Int(st.screen.pos() as i16))) +} + +fn bi_screen_fn(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let zeile = arg_i32(a, 0)?; + let spalte = arg_i32(a, 1)?; + let farbe = opt(a, 2)?.unwrap_or(0); + if zeile < 1 + || spalte < 1 + || zeile as usize > st.screen.rows() + || spalte as usize > st.screen.cols() + { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + let z = st.screen.cell(zeile as usize, spalte as usize); + let wert = if farbe != 0 { + // Farbattribut wie im Vorbild: Hintergrund*16 + Vordergrund. + (z.bg as i32) * 16 + z.fg as i32 + } else { + z.ch as i32 + }; + Ok(Some(Value::Int(wert.min(i16::MAX as i32) as i16))) +} + +fn bi_inkey(st: &mut RtState, host: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { + // Nicht blockierend: leerer String, wenn keine Taste anliegt. + let t = st.naechste_taste(host, false).unwrap_or_default(); + Ok(Some(Value::Str(Rc::from(t.as_str())))) +} + +fn bi_input_s(st: &mut RtState, host: &mut dyn Host, a: &mut [Value]) -> Result, RuntimeError> { + let n = arg_i32(a, 0)?; + if n < 0 { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + // Blockierend, ohne Echo auf dem Bildschirm. + let mut s = String::new(); + for _ in 0..n { + host.present(&st.screen); + let Some(t) = st.naechste_taste(host, true) else { + return Err(RuntimeError(62)); // Input past end of file + }; + s.push_str(&t); + } + Ok(Some(Value::Str(Rc::from(s.as_str())))) +} + // ---- Sonstiges ------------------------------------------------------------------ fn bi_timer(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result, RuntimeError> { @@ -505,38 +1453,106 @@ fn bi_timer(_: &mut RtState, _: &mut dyn Host, _: &mut [Value]) -> Result