From 333e794540c2400f9f37aea2b228a61d6e31eaaf Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 2 Sep 2026 09:16:01 +0200 Subject: [PATCH] Phase 1: Sprach-Frontend (Lexer, AST, Parser, Semantik) + Entscheidungen Frontend: - Lexer komplett: Typ-Suffixe, Literal-Typisierung (Entscheidung: > 7 signifikante Stellen -> DOUBLE), Hex/Oktal, Zeilenfortsetzung mit _, Strings mit ""-Escape, case-insensitive Keywords (Bibliotheksnamen bleiben Bezeichner) - AST fuer Module/Prozeduren/Anweisungen/Ausdruecke - Parser: fehlertolerant, zeilenorientiert; Kern-Anweisungssatz inkl. Bloecke, ON [LOCAL] ERROR, DEF FN (einzeilig); Datei-E/A als Phase-3-Platzhalter - Semantik: Symboltabellen, implizite Deklaration, DEFtype, OPTION EXPLICIT, Arrays, Builtin-Signaturen, Labelpruefung; Hardware-Features (PEEK/POKE/...) werden zur Compile-Zeit abgewiesen - Meilenstein: Testkorpus parst und wird typgeprueft (corpus.rs); 27 Tests Entscheidungen eingearbeitet: - Binaries heissen tb (IDE) und tbc (Compiler) - Dynamische Terminalgroesse statt 80x25 (Minimum 80x25, btop-artiger Hinweis darunter); tb-ui::screen mit resize(), Spike angepasst - Vollstaendigkeits-Leitplanke: 100% Sprache/Stdlib minus deklarierte Non-Features; Original-Doku als Guiding Principle; Inventar-Aufgabe - CURRENCY als i64-Festkomma; ISAM wird implementiert; breite Zeichen belegen 2 Zellen; GET/PUT-Strings als UTF-32; Blink als hell simuliert - LICENSE: MIT Co-Authored-By: Claude Fable 5 --- LICENSE | 21 + PLAN.md | 122 ++- README.md | 19 +- crates/tb-cli/Cargo.toml | 4 +- crates/tb-cli/src/main.rs | 10 +- crates/tb-frontend/src/ast.rs | 249 ++++- crates/tb-frontend/src/lexer.rs | 558 +++++++++- crates/tb-frontend/src/lib.rs | 42 +- crates/tb-frontend/src/parser.rs | 1517 +++++++++++++++++++++++++++- crates/tb-frontend/src/sema.rs | 1084 ++++++++++++++++++++ crates/tb-frontend/tests/corpus.rs | 27 + crates/tb-ide/Cargo.toml | 2 +- crates/tb-ide/src/main.rs | 4 +- crates/tb-ui/examples/spike.rs | 13 +- crates/tb-ui/src/screen.rs | 191 +++- docs/dateiformate.md | 10 +- docs/forms-referenz.md | 5 +- docs/ide-referenz.md | 7 +- docs/sprachreferenz.md | 46 +- docs/tbvm-design.md | 4 +- tests/compat/README.md | 2 +- 21 files changed, 3799 insertions(+), 138 deletions(-) create mode 100644 LICENSE create mode 100644 crates/tb-frontend/src/sema.rs create mode 100644 crates/tb-frontend/tests/corpus.rs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d7798cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Georg Bauer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md index 7411204..881f942 100644 --- a/PLAN.md +++ b/PLAN.md @@ -22,6 +22,20 @@ Konvention: `[ ]` offen · `[x]` erledigt · `[~]` in Arbeit Formatierung), nicht das, was „richtiger" wäre. Wo das Vorbild aber mit der heutigen Plattform kollidiert (Codepages, DOS-Hardware, Segmente), gewinnt die Plattform; solche Abweichungen werden in der Sprachreferenz dokumentiert. +- **Vollständigkeit ist das Soll** (2026-09-02): Erwartet wird eine + **100 % kompatible Sprachimplementierung und volle Standardbibliothek** + des Vorbilds — abzüglich ausschließlich der in der Sprachreferenz + explizit benannten Non-Features. Aufzählungen von Anweisungen/Funktionen + in diesem Plan sind **Beispiele, keine Scope-Definition**. Messbar wird + das über ein Vollständigkeits-Inventar (alle Keywords, Anweisungen, + Funktionen, Methoden, Eigenschaften, Ereignisse aus der Original-Hilfe) + mit Abdeckungsstatus → Aufgabe in Phase 3, Abnahmekriterium in Phase 6. + **Guiding Principle — die Original-Dokumentation führt** (2026-09-02): + Alles, was dort dokumentiert ist, wird unterstützt **oder** durch eine + explizite Fehlermeldung abgewiesen. Eine Abweisung setzt voraus, dass das + Feature vorher vom Projektinhaber als Non-Feature deklariert und in der + Sprachreferenz unter „Abweichungen" gelistet wurde. Stilles Weglassen + oder generische Syntaxfehler für dokumentierte Features sind Bugs. - **Markenrecht:** Der Name des Vorbilds wird in Code, Doku und Artefakten nicht verwendet. Wir sprechen vom „Vorbild" bzw. „dem Dialekt". - **Performance ist Anforderung, nicht Politur** (2026-09-01): @@ -37,7 +51,7 @@ Konvention: `[ ]` offen · `[x]` erledigt · `[~]` in Arbeit - **Schnelle Ausführung:** Die TBVM führt generierten Code zügig aus (Details in docs/tbvm-design.md, Abschnitt „Performance"); Messlatte via Benchmarks ab Phase 2. - - `tbdosc build --exe` erzeugt Executables **ohne** Compiler-/Linker- + - `tbc build --exe` erzeugt Executables **ohne** Compiler-/Linker- Toolchain beim Anwender (vorkompilierter Runner + angehängtes `.tbc`) — damit bleibt auch der Weg zum verteilbaren Binary im Sekundenbereich. - **Jede Phase endet mit lauffähigen Tests** gegen eine wachsende @@ -79,7 +93,7 @@ Dokumentation nicht beantwortet. → [docs/ide-referenz.md](docs/ide-referenz.md) - [x] Testkorpus-Grundstock gelegt (`tests/compat/` mit dokumentierter Sollausgabe). Der Ausbau ist Daueraufgabe jeder Phase; das - Test-Harness (`tbdosc run` + Ausgabevergleich) ist Phase-2-Aufgabe + Test-Harness (`tbc run` + Ausgabevergleich) ist Phase-2-Aufgabe ### 0.2 VM-/Runtime-Entscheidung (Aufstellung der Optionen) @@ -101,7 +115,7 @@ BASIC-Semantik, serialisierbares Kompilat, gute Fehlerortung (Zeile/Spalte). **Entscheidung (2026-09-01, bestätigt):** Eigene Stack-basierte Bytecode-VM (**Option 1, TBVM**), sowohl in der Entwicklungsphase als auch **eingebettet in -die Executables** (`tbdos`-IDE und von `tbdosc` erzeugte Programme), damit der +die Executables** (`tb`-IDE und von `tbc` erzeugte Programme), damit der schnelle Edit-Run-Turnaround überall identisch ist. Die IR/Bytecode-Schicht wird sauber vom Interpreter getrennt, sodass später ein zweites Backend (WASM via Cranelift, Option 3) ergänzt werden kann, ohne das @@ -122,6 +136,12 @@ durchgängig **UTF-8/Unicode**. Strings sind Unicode-Text, `CHR$`/`ASC` arbeiten auf Codepoints. Das ist eine bewusste Abweichung vom Vorbild und wird in der Sprachreferenz unter „Abweichungen" dokumentiert. +**Entscheidung (2026-09-02):** **Dynamische Terminalgröße** statt festem +80×25. IDE und erzeugte Programme passen sich der Fenstergröße an; +Mindestgröße ist 80×25, darunter wird nur ein Hinweis gerendert (btop-artig). +`SCREEN.Height`/`Width`, `CSRLIN`/`POS`/`LOCATE` arbeiten auf der +tatsächlichen Größe; `tb-ui::screen` unterstützt `resize()`. + - [x] 80×25-Zellenpuffer (Zeichen + Farbattribut) als eigenes Widget rendern; kleinere Terminals: Hinweis „Terminal zu klein", größere: zentriert (Letterboxing) → `tb-ui::screen`, Demo: `cargo run -p tb-ui --example spike` @@ -134,19 +154,31 @@ in der Sprachreferenz unter „Abweichungen" dokumentiert. VM-Ticks — ist Eingangsaufgabe von Phase 4.) ## Phase 1 — Sprach-Frontend (`tb-frontend`) -- [ ] Lexer inkl. Typ-Suffixe, Zeilennummern/Labels, `REM`/`'`-Kommentare, - case-insensitive Keywords, Zeilenfortsetzung mit `_` -- [ ] Offene Detailfrage aus der Sprachreferenz klären: exakte - SINGLE/DOUBLE-Schwelle suffixloser Dezimalpunkt-Literale - (Quelle widersprüchlich → per Testkorpus festlegen und in - docs/sprachreferenz.md dokumentieren) -- [ ] AST für Module, Prozeduren, Anweisungen, Ausdrücke, Deklarationen -- [ ] Parser (zeilenorientiert, fehlertolerant — die IDE prüft pro Zeile) -- [ ] Semantik: Symboltabellen, implizite Deklaration, `DEFINT`-Regeln, - Typprüfung und -konvertierung, `OPTION EXPLICIT`/`OPTION BASE`, - Array-Dimensionierung (statisch/dynamisch, `REDIM`) -- [ ] Diagnostik mit exakten Positionen und den Meldungstexten des Vorbilds -- [ ] Meilenstein: kompletter Testkorpus parst und wird typgeprüft +- [x] Lexer inkl. Typ-Suffixe, Zeilennummern/Labels, `REM`/`'`-Kommentare, + case-insensitive Keywords, Zeilenfortsetzung mit `_`, Hex-/Oktal- + Literale, Literal-Typisierung +- [x] SINGLE/DOUBLE-Schwelle suffixloser Dezimalpunkt-Literale entschieden + (> 7 signifikante Stellen → DOUBLE) und in docs/sprachreferenz.md §1 + dokumentiert +- [x] AST für Module, Prozeduren, Anweisungen, Ausdrücke, Deklarationen +- [~] Parser (zeilenorientiert, fehlertolerant — Fehler pro Anweisung + gesammelt, Synchronisation bis Anweisungsende). Kern komplett: + Zuweisung, PRINT (inkl. USING/#), INPUT/LINE INPUT, IF (Block + + einzeilig), SELECT CASE, FOR/DO/WHILE, GOTO/GOSUB/ON-GOTO, + ON [LOCAL] ERROR/RESUME, DIM/REDIM/CONST/DEFtype/OPTION/TYPE/ + DECLARE/SUB/FUNCTION/CALL, DATA/READ/RESTORE, DEF FN (einzeilig). + Offen: MID$-Anweisung, DEF FN-Blockform, Datei-E/A-Anweisungen + (werden als Platzhalter geparst → Phase 3), `$INCLUDE`-Metabefehl +- [~] Semantik: Symboltabellen, implizite Deklaration, `DEFtype`-Regeln, + Typprüfung, `OPTION EXPLICIT`, Arrays (implizit/DIM/REDIM), + Builtin-Signaturen, Label-Prüfung, Prozedur-Signaturprüfung. + Offen: `COMMON`/`SHARED` über Prozedurgrenzen, UDT-Feldtypen, + `OPTION BASE`-Auswertung, Konstantenfaltung +- [~] Diagnostik mit exakten Positionen; Meldungstexte am Vorbild + orientiert (Type mismatch, Duplicate definition, Label not defined …) + — vollständiger Abgleich mit den Compile-Meldungen des Vorbilds offen +- [x] Meilenstein: kompletter Testkorpus parst und wird typgeprüft + (`crates/tb-frontend/tests/corpus.rs`) ## Phase 2 — Bytecode und VM (`tb-vm`) - [ ] Eingangsaufgabe (aus Phase 0 übernommen): Bytecode-**Feindesign** — @@ -163,9 +195,18 @@ in der Sprachreferenz unter „Abweichungen" dokumentiert. - [ ] Benchmarks in `benches/`: Compile-Budget messen (Projekt ≈50k Zeilen < 1 s, einzelnes Modul < 50 ms) und VM-Durchsatz (Schleifen/Strings); Ergebnisse in docs/tbvm-design.md festhalten -- [ ] Meilenstein: Konsolen-Testkorpus läuft mit korrekter Ausgabe (`tbdosc run`) +- [ ] Meilenstein: Konsolen-Testkorpus läuft mit korrekter Ausgabe (`tbc run`) ## Phase 3 — Laufzeitbibliothek (`tb-runtime`) und Bildschirm (`tb-ui::screen`) + +Ziel ist die **vollständige** Standardbibliothek des Vorbilds (siehe +Leitplanke Vollständigkeit); die Aufzählungen unten sind Beispiele. + +- [ ] 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; @@ -175,7 +216,16 @@ in der Sprachreferenz unter „Abweichungen" dokumentiert. 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 + 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 @@ -221,7 +271,7 @@ in der Sprachreferenz unter „Abweichungen" dokumentiert. aus Phase 0 übernommen) und Dokumentation bekannter Terminal-Einschränkungen - [ ] Performance-Pass über die VM (nur falls nötig) -- [ ] `tbdosc build` → binäres Ergebnis: `.tbc`-Bytecode bzw. eigenständig +- [ ] `tbc build` → binäres Ergebnis: `.tbc`-Bytecode bzw. eigenständig ausführbares Programm (Bytecode + eingebetteter Runner) - [ ] Dokumentation: Sprachreferenz, Migrationshinweise, Beispielprogramme - [ ] CI (GitHub Actions: Build + Tests auf allen drei Plattformen), Releases @@ -241,23 +291,25 @@ bleibt gültig. Noch nichts davon ist beschlossen; Sammlung wächst: Forms-Engine - Standardbibliothek: Prozessaufrufe mit Pipes, Umgebungs-/Argument-Handling für CLI-Tools, JSON/CSV, HTTP-Client, Pfad-/Verzeichnisfunktionen -- Verteilung: `tbdosc build --exe` als Single-File-Tool-Baukasten +- Verteilung: `tbc build --exe` als Single-File-Tool-Baukasten --- -## Offene Fragen +## Entschiedene Fragen (2026-09-02, alle offenen Punkte geklärt) -- Umgang mit `CURRENCY` (`@`): als i64-Festkomma (×10 000) — klären, ob das - Vorbild ihn überhaupt in allen Kontexten unterstützt -- ISAM-Dateiunterstützung des Vorbilds: nachbilden oder als Nicht-Ziel erklären? -- `PEEK`/`POKE`/`CALL INTERRUPT` u. ä. Hardware-Nähe: sinnvolle Teilmenge - emulieren (z. B. Bildschirmspeicher B800) oder Laufzeitfehler? -- Unicode-Zeichen mit Darstellungsbreite ≠ 1 (Emoji, CJK): das Zellenmodell - ist strikt 1 Zeichen = 1 Zelle. Breite Zeichen ablehnen (Fehler 5), - ersetzen oder 2 Zellen belegen? -- `GET`/`PUT` mit Record-Typen unter UTF-8: feste Strings (`STRING * n`) - waren im Vorbild n Bytes — bei uns n Zeichen. Binärlayout der Records - festlegen (Vorschlag: UTF-8 mit Padding auf feste Bytelänge? Oder UTF-32?) -- Blink-Attribut (`COLOR` mit Vordergrund 16–31): ignorieren, als „hell" - darstellen oder echtes Terminal-Blink nutzen (nicht überall unterstützt)? -- Lizenzwahl (MIT im Rahmen angenommen — bestätigen) +- **CURRENCY (`@`):** voll unterstützt als i64-Festkomma (×10 000); das + Vorbild unterstützt den Typ laut Original-Hilfe vollständig. +- **ISAM:** wird implementiert (kein Non-Feature) → Aufgabe in Phase 3. +- **`PEEK`/`POKE`/`CALL INTERRUPT` u. ä. Hardware-Nähe:** nicht unterstützt; + Ablehnung bereits **zur Compile-Zeit** (Meldung „Feature unavailable") — + in der Semantik umgesetzt, dokumentiert in der Sprachreferenz. +- **Breite Unicode-Zeichen (Emoji, CJK):** belegen **zwei Zellen** → + Umsetzung in Phase 3. +- **`GET`/`PUT`-Records unter Unicode:** feste Strings als **UTF-32** + (4 Bytes/Zeichen, feste Record-Länge); Inkompatibilität der Binärdateien + zum Vorbild wird bewusst akzeptiert → Phase 3. +- **Blink-Attribut (`COLOR` 16–31):** kein echtes Blinken, Simulation als + „hell" — in `tb-ui::screen` umgesetzt. +- **Lizenz:** MIT (LICENSE im Repo). + +Neue offene Fragen werden hier gesammelt und mit Datum entschieden. diff --git a/README.md b/README.md index e00baf4..e14bd71 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,11 @@ durchgängig UTF-8 statt Codepage 437 und keine Emulation von DOS-Hardware. - **Volle Library-Kompatibilität für die DOS-Oberflächenprogrammierung**: die ereignisgesteuerte Forms-Engine mit allen Steuerelementen, Menüs, Maus- und Tastaturereignissen, nachgebildet auf einem emulierten - 80×25-Textbildschirm (16 Farben) über Ratatui. Bewusste Modernisierung: - durchgängig UTF-8/Unicode statt Codepage 437. + Textbildschirm (16 Farben) über Ratatui. Bewusste Modernisierungen: + durchgängig UTF-8/Unicode statt Codepage 437, und **dynamische + Terminalgröße** statt festem 80×25 — IDE und erzeugte Programme passen + sich der Fenstergröße an (Mindestgröße 80×25; darunter erscheint nur + ein Hinweis, wie bei btop). - **Plattformübergreifend**: läuft in Terminals unter Linux, macOS und Windows. - **Compiler + VM**: der Compiler erzeugt Bytecode für eine eigene virtuelle Maschine (TBVM); die Abwägung der Runtime-Optionen ist in @@ -50,8 +53,8 @@ durchgängig UTF-8 statt Codepage 437 und keine Emulation von DOS-Hardware. | `tb-vm` | Bytecode-Format, Codegenerator, virtuelle Maschine (TBVM) | | `tb-runtime` | Laufzeitbibliothek: Strings, Mathematik, Datei-E/A, Datum/Zeit, Fehlercodes | | `tb-ui` | Textbildschirm-Emulation und Forms-Engine auf Ratatui | -| `tb-cli` | `tbdosc`: Standalone-Compiler — wandelt Quellen und Projekte in binäre Ergebnisse um | -| `tb-ide` | `tbdos`: die integrierte Entwicklungsumgebung (TUI) zum Erstellen von Programmen | +| `tb-cli` | `tbc`: Standalone-Compiler — wandelt Quellen und Projekte in binäre Ergebnisse um | +| `tb-ide` | `tb`: die integrierte Entwicklungsumgebung (TUI) zum Erstellen von Programmen | ## Status @@ -62,8 +65,12 @@ Projektrahmen. Die Implementierungs- und Explorationsschritte sind in ``` cargo build -cargo run -p tb-cli # tbdosc — Standalone-Compiler -cargo run -p tb-ide # tbdos — IDE (TUI) +cargo run -p tb-cli # tbc — Standalone-Compiler +cargo run -p tb-ide # tb — IDE (TUI) ``` Benötigt wird nur eine aktuelle stabile Rust-Toolchain. + +## Lizenz + +MIT — siehe [LICENSE](LICENSE). diff --git a/crates/tb-cli/Cargo.toml b/crates/tb-cli/Cargo.toml index 9f0aa75..c7dcc10 100644 --- a/crates/tb-cli/Cargo.toml +++ b/crates/tb-cli/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "tb-cli" -description = "Terminal Basic: Standalone-Kommandozeilen-Compiler (tbdosc)" +description = "Terminal Basic: Standalone-Kommandozeilen-Compiler (tbc)" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true [[bin]] -name = "tbdosc" +name = "tbc" path = "src/main.rs" [dependencies] diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index 9722765..cabb120 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -1,13 +1,13 @@ -//! `tbdosc` — Standalone-Compiler von Terminal Basic. +//! `tbc` — Standalone-Compiler von Terminal Basic. //! //! Wandelt Quelldateien und Projekte in binäre Ergebnisse (`.tbc`-Bytecode //! bzw. eigenständig ausführbare Programme) um. Geplante Unterbefehle //! (siehe PLAN.md): -//! - `tbdosc build ` Kompilieren zu binärem Ergebnis -//! - `tbdosc run ` Kompilieren und sofort ausführen -//! - `tbdosc check ` Nur Syntax-/Semantikprüfung +//! - `tbc build ` Kompilieren zu binärem Ergebnis +//! - `tbc run ` Kompilieren und sofort ausführen +//! - `tbc check ` Nur Syntax-/Semantikprüfung fn main() -> anyhow::Result<()> { - println!("tbdosc — Terminal Basic Compiler (Projektrahmen, noch ohne Funktion)"); + println!("tbc — Terminal Basic Compiler (Projektrahmen, noch ohne Funktion)"); Ok(()) } diff --git a/crates/tb-frontend/src/ast.rs b/crates/tb-frontend/src/ast.rs index 88b6027..7a8804c 100644 --- a/crates/tb-frontend/src/ast.rs +++ b/crates/tb-frontend/src/ast.rs @@ -1,4 +1,247 @@ -//! AST-Definitionen: Module, Prozeduren (`SUB`/`FUNCTION`), Anweisungen, Ausdrücke, -//! benutzerdefinierte Typen (`TYPE … END TYPE`), Deklarationen. +//! AST-Definitionen: Module, Prozeduren, Anweisungen, Ausdrücke, +//! Deklarationen und benutzerdefinierte Typen. -// Platzhalter — wird in Phase 1 ausgearbeitet (siehe PLAN.md) +use crate::lexer::Suffix; +use crate::SourcePos; + +/// Typangabe in `AS`-Klauseln. +#[derive(Debug, Clone, PartialEq)] +pub enum TypeName { + Integer, + Long, + Single, + Double, + Currency, + Str, + FixedStr(i64), + Udt(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnOp { + Neg, + Not, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinOp { + Pow, Mul, Div, IntDiv, Mod, Add, Sub, + Eq, Ne, Lt, Le, Gt, Ge, + And, Or, Xor, Eqv, Imp, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + IntLit(i16), + LongLit(i32), + SingleLit(f32), + DoubleLit(f64), + CurrencyLit(i64), + StrLit(String), + /// Benannter Zugriff: Variable, Arrayelement, Funktionsaufruf oder + /// Konstante — Auflösung erfolgt in der Semantik. + Name { + name: String, + suffix: Option, + args: Option>, + pos: SourcePos, + }, + Unary { op: UnOp, operand: Box, pos: SourcePos }, + Binary { op: BinOp, lhs: Box, rhs: Box, pos: SourcePos }, + /// Ausgelassenes Argument (`LOCATE , 5`). + Missing, +} + +impl Expr { + pub fn pos(&self) -> SourcePos { + match self { + Expr::Name { pos, .. } + | Expr::Unary { pos, .. } + | Expr::Binary { pos, .. } => *pos, + _ => SourcePos::default(), + } + } +} + +/// Sprungziel: alphanumerisches Label oder Zeilennummer. +#[derive(Debug, Clone, PartialEq)] +pub enum LabelRef { + Name(String), + Line(u32), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PrintItem { + Expr(Expr), + Comma, + Semicolon, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CaseSpec { + Expr(Expr), + Range(Expr, Expr), + Is(BinOp, Expr), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CaseArm { + /// Leer = `CASE ELSE`. + pub specs: Vec, + pub body: Vec, + pub pos: SourcePos, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VarDecl { + pub name: String, + pub suffix: Option, + /// `None` = Skalar; sonst Dimensionen `(untergrenze TO obergrenze)`. + pub dims: Option, Expr)>>, + pub as_type: Option, + pub pos: SourcePos, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitKind { + For, + Do, + Sub, + Function, + Def, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OptionKind { + Explicit, + Base(u8), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum OnErrorAction { + Goto(LabelRef), + ResumeNext, + Disable, // GOTO 0 +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ResumeKind { + Retry, // RESUME [0] + Next, + Label(LabelRef), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Param { + pub name: String, + pub suffix: Option, + pub array: bool, + pub as_type: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcKind { + Sub, + Function, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProcSig { + pub kind: ProcKind, + pub name: String, + pub suffix: Option, + pub params: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Proc { + pub sig: ProcSig, + pub is_static: bool, + pub body: Vec, + pub pos: SourcePos, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Stmt { + Label(String), + LineNumber(u32), + Assign { target: Expr, value: Expr, pos: SourcePos }, + Print { + file: Option, + using: Option, + items: Vec, + pos: SourcePos, + }, + Input { + line: bool, + file: Option, + keep_cursor: bool, + prompt: Option<(String, bool)>, // (Text, mit Fragezeichen) + vars: Vec, + pos: SourcePos, + }, + If { + cond: Expr, + then_body: Vec, + elseifs: Vec<(Expr, Vec)>, + else_body: Option>, + pos: SourcePos, + }, + Select { expr: Expr, arms: Vec, pos: SourcePos }, + For { + var: Expr, + from: Expr, + to: Expr, + step: Option, + body: Vec, + pos: SourcePos, + }, + DoLoop { + pre: Option<(bool, Expr)>, // (ist UNTIL, Bedingung) + post: Option<(bool, Expr)>, + body: Vec, + pos: SourcePos, + }, + While { cond: Expr, body: Vec, pos: SourcePos }, + Goto { target: LabelRef, pos: SourcePos }, + Gosub { target: LabelRef, pos: SourcePos }, + OnGoto { expr: Expr, targets: Vec, gosub: bool, pos: SourcePos }, + Return { target: Option, pos: SourcePos }, + End, + StopStmt, + System, + Exit { kind: ExitKind, pos: SourcePos }, + Dim { shared: bool, redim: bool, decls: Vec, pos: SourcePos }, + Erase { names: Vec, pos: SourcePos }, + ConstDecl { items: Vec<(String, Option, Expr)>, pos: SourcePos }, + DefType { ty: TypeName, ranges: Vec<(char, char)>, pos: SourcePos }, + OptionStmt { kind: OptionKind, pos: SourcePos }, + TypeDecl { name: String, fields: Vec<(String, TypeName)>, pos: SourcePos }, + Declare { sig: ProcSig, pos: SourcePos }, + /// Expliziter oder impliziter Prozedur-/Builtin-Aufruf als Anweisung. + Call { name: String, suffix: Option, args: Vec, pos: SourcePos }, + OnError { local: bool, action: OnErrorAction, pos: SourcePos }, + Resume { kind: ResumeKind, pos: SourcePos }, + ErrorStmt { code: Expr, pos: SourcePos }, + Data { items: Vec, pos: SourcePos }, + ReadStmt { vars: Vec, pos: SourcePos }, + Restore { target: Option, pos: SourcePos }, + /// Einzeilige `DEF FNname(...) = ausdruck`-Definition. + DefFn { + name: String, + suffix: Option, + params: Vec, + body: Expr, + pos: SourcePos, + }, + /// Geparst, aber erst in Phase 3 implementiert (Datei-E/A u. ä.); + /// die Tokens der Anweisung wurden übersprungen. + NotYetImplemented { keyword: String, pos: SourcePos }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Module { + pub name: String, + pub body: Vec, + pub procs: Vec, +} diff --git a/crates/tb-frontend/src/lexer.rs b/crates/tb-frontend/src/lexer.rs index 6a043d5..d9bb9aa 100644 --- a/crates/tb-frontend/src/lexer.rs +++ b/crates/tb-frontend/src/lexer.rs @@ -1,13 +1,557 @@ //! Lexer: zerlegt Quelltext in Tokens. //! -//! Besonderheiten des BASIC-Dialekts, die hier abgebildet werden müssen: -//! - Typ-Suffixe an Bezeichnern und Literalen (`%`, `&`, `!`, `#`, `$`, `@`) -//! - Zeilennummern und Labels -//! - Keywords sind case-insensitiv, der Editor normalisiert später auf Großschreibung -//! - Fortsetzung/Trennung von Anweisungen mit `:` und Kommentare mit `'` und `REM` +//! Besonderheiten des Dialekts: +//! - Typ-Suffixe an Bezeichnern und Literalen (`% & ! # $ @`) +//! - Zeilennummern und Labels (löst der Parser auf) +//! - Keywords case-insensitiv; nur echte Sprach-Keywords sind reserviert, +//! Bibliotheksnamen (`CLS`, `LEFT$` …) bleiben Bezeichner und werden in +//! der Semantik als Builtins aufgelöst +//! - `:` trennt Anweisungen, `'` und `REM` leiten Kommentare ein +//! - Zeilenfortsetzung: `_` als letztes Zeichen nach Leerraum +//! - Literal-Typisierung nach Sprachreferenz (docs/sprachreferenz.md §1) + +use crate::{Diagnostic, SourcePos}; + +/// Typ-Suffix eines Bezeichners oder Literals. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Suffix { + Integer, // % + Long, // & + Single, // ! + Double, // # + Str, // $ + Currency, // @ +} + +impl Suffix { + pub fn from_char(c: char) -> Option { + match c { + '%' => Some(Suffix::Integer), + '&' => Some(Suffix::Long), + '!' => Some(Suffix::Single), + '#' => Some(Suffix::Double), + '$' => Some(Suffix::Str), + '@' => Some(Suffix::Currency), + _ => None, + } + } + pub fn as_char(self) -> char { + match self { + Suffix::Integer => '%', + Suffix::Long => '&', + Suffix::Single => '!', + Suffix::Double => '#', + Suffix::Str => '$', + Suffix::Currency => '@', + } + } +} + +/// Wert eines numerischen Literals, bereits typisiert. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum NumValue { + Int(i16), + Long(i32), + Single(f32), + Double(f64), + /// Festkomma ×10 000 + Currency(i64), +} + +/// Reservierte Sprach-Keywords (bewusst schlank: Bibliotheksfunktionen +/// und -anweisungen sind KEINE Keywords, sondern Builtins der Semantik). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kw { + And, As, Call, Case, Const, Data, Declare, Def, DefCur, DefDbl, DefInt, + DefLng, DefSng, DefStr, Dim, Do, Double, Else, ElseIf, End, Eqv, Erase, + Error, Exit, For, Function, Gosub, Goto, If, Imp, Input, Integer, Is, + Let, Line, Local, Long, Loop, Mod, Next, Not, On, Option, Or, Print, + Read, ReDim, Rem, Restore, Resume, Return, Select, Shared, Single, + Static, Step, Stop, String, Sub, System, Then, To, Type, Until, Using, + Wend, While, Xor, Currency, + // Datei-E/A-Keywords: werden geparst, aber erst in Phase 3 implementiert + Open, Close, Write, Field, Get, Put, Seek, Lset, Rset, +} + +fn keyword(upper: &str) -> Option { + use Kw::*; + Some(match upper { + "AND" => And, "AS" => As, "CALL" => Call, "CASE" => Case, + "CONST" => Const, "CURRENCY" => Currency, "DATA" => Data, + "DECLARE" => Declare, "DEF" => Def, "DEFCUR" => DefCur, + "DEFDBL" => DefDbl, "DEFINT" => DefInt, "DEFLNG" => DefLng, + "DEFSNG" => DefSng, "DEFSTR" => DefStr, "DIM" => Dim, "DO" => Do, + "DOUBLE" => Double, "ELSE" => Else, "ELSEIF" => ElseIf, "END" => End, + "EQV" => Eqv, "ERASE" => Erase, "ERROR" => Error, "EXIT" => Exit, + "FIELD" => Field, "FOR" => For, "FUNCTION" => Function, + "GET" => Get, "GOSUB" => Gosub, "GOTO" => Goto, "IF" => If, + "IMP" => Imp, "INPUT" => Input, "INTEGER" => Integer, "IS" => Is, + "LET" => Let, "LINE" => Line, "LOCAL" => Local, "LONG" => Long, + "LOOP" => Loop, "LSET" => Lset, "MOD" => Mod, "NEXT" => Next, + "NOT" => Not, "ON" => On, "OPEN" => Open, "OPTION" => Option, + "OR" => Or, "PRINT" => Print, "PUT" => Put, "READ" => Read, + "REDIM" => ReDim, "REM" => Rem, "RESTORE" => Restore, + "RESUME" => Resume, "RETURN" => Return, "RSET" => Rset, + "SEEK" => Seek, "SELECT" => Select, "SHARED" => Shared, + "SINGLE" => Single, "STATIC" => Static, "STEP" => Step, + "STOP" => Stop, "STRING" => String, "SUB" => Sub, + "SYSTEM" => System, "THEN" => Then, + "TO" => To, "TYPE" => Type, "UNTIL" => Until, "USING" => Using, + "WEND" => Wend, "WHILE" => While, "WRITE" => Write, "XOR" => Xor, + "CLOSE" => Close, + _ => return None, + }) +} #[derive(Debug, Clone, PartialEq)] -pub enum Token { - // Platzhalter — wird in Phase 1 ausgearbeitet (siehe PLAN.md) +pub enum TokenKind { + /// Bezeichner; `name` ist bereits in Großschreibung normalisiert. + Ident { name: String, suffix: Option }, + Kw(Kw), + Num(NumValue), + Str(String), + Plus, Minus, Star, Slash, Backslash, Caret, + Eq, Ne, Lt, Le, Gt, Ge, + LParen, RParen, Comma, Semicolon, Colon, Hash, + /// Ende einer logischen Zeile. + Eol, Eof, } + +#[derive(Debug, Clone, PartialEq)] +pub struct Token { + pub kind: TokenKind, + pub pos: SourcePos, +} + +pub struct LexOutput { + pub tokens: Vec, + pub diagnostics: Vec, +} + +/// Signifikante Stellen einer Ziffernfolge (führende Nullen zählen nicht). +fn significant_digits(int_part: &str, frac_part: &str) -> usize { + let all: String = int_part.chars().chain(frac_part.chars()).collect(); + let trimmed = all.trim_start_matches('0'); + trimmed.len() +} + +pub fn lex(source: &str) -> LexOutput { + let mut tokens: Vec = Vec::new(); + let mut diagnostics: Vec = Vec::new(); + let mut continuation = false; + + for (line_idx, raw_line) in source.lines().enumerate() { + let line_no = (line_idx + 1) as u32; + let chars: Vec = raw_line.chars().collect(); + let mut i = 0usize; + let mut line_continued = false; + + 'line: while i < chars.len() { + // Leerraum überspringen + while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') { + i += 1; + } + if i >= chars.len() { + break; + } + let start = i; + let pos = SourcePos { line: line_no, column: (start + 1) as u32 }; + let c = chars[i]; + + // Zeilenfortsetzung: `_` nach Leerraum, danach nur noch Leerraum + if c == '_' + && (start == 0 + || chars[start - 1] == ' ' + || chars[start - 1] == '\t') + && chars[start + 1..].iter().all(|&ch| ch == ' ' || ch == '\t') + { + line_continued = true; + break 'line; + } + + match c { + '\'' => break 'line, // Kommentar bis Zeilenende + '"' => { + i += 1; + let mut s = String::new(); + let mut closed = false; + while i < chars.len() { + if chars[i] == '"' { + if i + 1 < chars.len() && chars[i + 1] == '"' { + s.push('"'); + i += 2; + } else { + i += 1; + closed = true; + break; + } + } else { + s.push(chars[i]); + i += 1; + } + } + if !closed { + // Das Vorbild toleriert fehlende schließende + // Anführungszeichen am Zeilenende. + } + tokens.push(Token { kind: TokenKind::Str(s), pos }); + } + '&' if i + 1 < chars.len() + && matches!(chars[i + 1], 'h' | 'H' | 'o' | 'O') => + { + let hex = matches!(chars[i + 1], 'h' | 'H'); + i += 2; + let digit_start = i; + while i < chars.len() + && chars[i].is_ascii_alphanumeric() + { + i += 1; + } + let digits: String = chars[digit_start..i].iter().collect(); + let long_suffix = i < chars.len() && chars[i] == '&'; + if long_suffix { + i += 1; + } + let radix = if hex { 16 } else { 8 }; + match u32::from_str_radix(&digits, radix) { + Ok(v) => { + let kind = if long_suffix { + TokenKind::Num(NumValue::Long(v as i32)) + } else if v <= 0xFFFF { + TokenKind::Num(NumValue::Int(v as u16 as i16)) + } else { + diagnostics.push(Diagnostic { + pos, + message: "Overflow".into(), + }); + TokenKind::Num(NumValue::Long(v as i32)) + }; + tokens.push(Token { kind, pos }); + } + Err(_) => diagnostics.push(Diagnostic { + pos, + message: "Syntax error".into(), + }), + } + } + '0'..='9' | '.' if c != '.' + || (i + 1 < chars.len() && chars[i + 1].is_ascii_digit()) => + { + let int_start = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + let int_part: String = chars[int_start..i].iter().collect(); + let mut frac_part = String::new(); + let mut has_point = false; + if i < chars.len() && chars[i] == '.' { + has_point = true; + i += 1; + let fs = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + frac_part = chars[fs..i].iter().collect(); + } + // Exponent E/D + let mut exp_kind: Option = None; + let mut exp_str = String::new(); + if i < chars.len() + && matches!(chars[i], 'e' | 'E' | 'd' | 'D') + { + let save = i; + let k = chars[i].to_ascii_uppercase(); + let mut j = i + 1; + let mut e = String::new(); + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + e.push(chars[j]); + j += 1; + } + let ds = j; + while j < chars.len() && chars[j].is_ascii_digit() { + j += 1; + } + if j > ds { + for ch in &chars[ds..j] { + e.push(*ch); + } + exp_kind = Some(k); + exp_str = e; + i = j; + } else { + i = save; // kein Exponent (z. B. Variable `e`) + } + } + // Suffix + let suffix = if i < chars.len() { + Suffix::from_char(chars[i]) + } else { + None + }; + if suffix.is_some() { + i += 1; + } + let text = format!( + "{}{}{}{}", + int_part, + if has_point { "." } else { "" }, + frac_part, + match exp_kind { + Some(_) => format!("e{exp_str}"), + None => String::new(), + } + ); + let dval: f64 = text.parse().unwrap_or(0.0); + let value = match (suffix, exp_kind) { + (Some(Suffix::Integer), _) => { + if dval > i16::MAX as f64 || dval < i16::MIN as f64 { + diagnostics.push(Diagnostic { + pos, + message: "Overflow".into(), + }); + } + NumValue::Int(dval as i16) + } + (Some(Suffix::Long), _) => { + if dval > i32::MAX as f64 || dval < i32::MIN as f64 { + diagnostics.push(Diagnostic { + pos, + message: "Overflow".into(), + }); + } + NumValue::Long(dval as i32) + } + (Some(Suffix::Single), _) => NumValue::Single(dval as f32), + (Some(Suffix::Double), _) => NumValue::Double(dval), + (Some(Suffix::Currency), _) => { + NumValue::Currency((dval * 10_000.0).round() as i64) + } + (Some(Suffix::Str), _) => { + diagnostics.push(Diagnostic { + pos, + message: "Syntax error".into(), + }); + NumValue::Double(dval) + } + (None, Some('D')) => NumValue::Double(dval), + (None, Some(_)) => NumValue::Single(dval as f32), + (None, None) => { + if !has_point { + // Ganzzahl: INTEGER → LONG → Gleitkomma + if let Ok(v) = text.parse::() { + if let Ok(v16) = i16::try_from(v) { + NumValue::Int(v16) + } else if let Ok(v32) = i32::try_from(v) { + NumValue::Long(v32) + } else { + NumValue::Double(dval) + } + } else { + NumValue::Double(dval) + } + } else { + // Entscheidung (2026-09-02, siehe + // Sprachreferenz §1): > 7 signifikante + // Stellen → DOUBLE, sonst SINGLE. + if significant_digits(&int_part, &frac_part) > 7 { + NumValue::Double(dval) + } else { + NumValue::Single(dval as f32) + } + } + } + }; + tokens.push(Token { kind: TokenKind::Num(value), pos }); + } + c if c.is_alphabetic() => { + i += 1; + while i < chars.len() + && (chars[i].is_alphanumeric() + || chars[i] == '.' + || chars[i] == '_') + { + i += 1; + } + let mut name: String = chars[start..i] + .iter() + .collect::() + .to_uppercase(); + let suffix = if i < chars.len() { + Suffix::from_char(chars[i]) + } else { + None + }; + if suffix.is_some() { + i += 1; + } + if suffix.is_none() { + if let Some(kw) = keyword(&name) { + if kw == Kw::Rem { + break 'line; // REM: Rest ist Kommentar + } + tokens.push(Token { kind: TokenKind::Kw(kw), pos }); + continue; + } + } + // Bezeichner dürfen nicht mit '.' enden (a.b. → a.b + .) + while name.ends_with('.') { + name.pop(); + i -= 1; + } + tokens.push(Token { + kind: TokenKind::Ident { name, suffix }, + pos, + }); + } + _ => { + i += 1; + let kind = match c { + '+' => TokenKind::Plus, + '-' => TokenKind::Minus, + '*' => TokenKind::Star, + '/' => TokenKind::Slash, + '\\' => TokenKind::Backslash, + '^' => TokenKind::Caret, + '=' => TokenKind::Eq, + '(' => TokenKind::LParen, + ')' => TokenKind::RParen, + ',' => TokenKind::Comma, + ';' => TokenKind::Semicolon, + ':' => TokenKind::Colon, + '#' => TokenKind::Hash, + '?' => TokenKind::Kw(Kw::Print), // Editor-Kurzform + '&' => TokenKind::Kw(Kw::Long), // isoliertes & (selten) + '<' => { + if i < chars.len() && chars[i] == '=' { + i += 1; + TokenKind::Le + } else if i < chars.len() && chars[i] == '>' { + i += 1; + TokenKind::Ne + } else { + TokenKind::Lt + } + } + '>' => { + if i < chars.len() && chars[i] == '=' { + i += 1; + TokenKind::Ge + } else { + TokenKind::Gt + } + } + other => { + diagnostics.push(Diagnostic { + pos, + message: format!("Syntax error ('{other}')"), + }); + continue; + } + }; + tokens.push(Token { kind, pos }); + } + } + } + + if line_continued { + continuation = true; + } else { + if !continuation || !tokens.is_empty() { + tokens.push(Token { + kind: TokenKind::Eol, + pos: SourcePos { + line: line_no, + column: (chars.len() + 1) as u32, + }, + }); + } + continuation = false; + } + } + + tokens.push(Token { + kind: TokenKind::Eof, + pos: SourcePos { line: (source.lines().count() + 1) as u32, column: 1 }, + }); + LexOutput { tokens, diagnostics } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(src: &str) -> Vec { + lex(src).tokens.into_iter().map(|t| t.kind).collect() + } + + #[test] + fn keywords_case_insensitiv() { + let k = kinds("print If tHeN"); + assert_eq!(k[0], TokenKind::Kw(Kw::Print)); + assert_eq!(k[1], TokenKind::Kw(Kw::If)); + assert_eq!(k[2], TokenKind::Kw(Kw::Then)); + } + + #[test] + fn suffix_macht_keyword_zum_bezeichner() { + // STRING$ ist die Bibliotheksfunktion, STRING das Typ-Keyword + let k = kinds("STRING$ STRING"); + assert_eq!( + k[0], + TokenKind::Ident { name: "STRING".into(), suffix: Some(Suffix::Str) } + ); + assert_eq!(k[1], TokenKind::Kw(Kw::String)); + } + + #[test] + fn literal_typisierung() { + assert_eq!(kinds("42")[0], TokenKind::Num(NumValue::Int(42))); + assert_eq!(kinds("40000")[0], TokenKind::Num(NumValue::Long(40000))); + assert_eq!(kinds("1.5")[0], TokenKind::Num(NumValue::Single(1.5))); + assert_eq!( + kinds("3.14159265")[0], + TokenKind::Num(NumValue::Double(3.14159265)) + ); + assert_eq!(kinds("1E3")[0], TokenKind::Num(NumValue::Single(1000.0))); + assert_eq!(kinds("1D3")[0], TokenKind::Num(NumValue::Double(1000.0))); + assert_eq!(kinds("2.5@")[0], TokenKind::Num(NumValue::Currency(25000))); + assert_eq!(kinds("&HFF")[0], TokenKind::Num(NumValue::Int(255))); + assert_eq!(kinds("&HFFFF")[0], TokenKind::Num(NumValue::Int(-1))); + assert_eq!(kinds("&HFFFF&")[0], TokenKind::Num(NumValue::Long(0xFFFF))); + assert_eq!(kinds("&O777")[0], TokenKind::Num(NumValue::Int(511))); + } + + #[test] + fn strings_mit_doppelten_anfuehrungszeichen() { + assert_eq!(kinds("\"a\"\"b\"")[0], TokenKind::Str("a\"b".into())); + } + + #[test] + fn kommentare_und_rem() { + let k = kinds("PRINT 1 ' Kommentar\nREM ganze Zeile\nPRINT 2"); + // PRINT 1 EOL EOL PRINT 2 EOL EOF + assert_eq!(k.len(), 8); + assert_eq!(k[2], TokenKind::Eol); + assert_eq!(k[3], TokenKind::Eol); + } + + #[test] + fn zeilenfortsetzung() { + let k = kinds("PRINT 1, _\n 2"); + // Kein Eol zwischen 1, und 2 + assert!(matches!(k[3], TokenKind::Num(NumValue::Int(2)))); + } + + #[test] + fn bezeichner_mit_punkt_und_unterstrich() { + let k = kinds("kunde.name_2$"); + assert_eq!( + k[0], + TokenKind::Ident { + name: "KUNDE.NAME_2".into(), + suffix: Some(Suffix::Str) + } + ); + } +} diff --git a/crates/tb-frontend/src/lib.rs b/crates/tb-frontend/src/lib.rs index ef3eb41..a89a083 100644 --- a/crates/tb-frontend/src/lib.rs +++ b/crates/tb-frontend/src/lib.rs @@ -2,16 +2,48 @@ //! //! Enthält Lexer, Parser, AST-Definitionen und die semantische Analyse //! (Symboltabellen, Typprüfung, implizite Deklarationen, `DEFINT`-Regeln usw.). -//! Ausgabe des Frontends ist ein typgeprüfter AST bzw. eine Zwischen- -//! repräsentation (IR), die von `tb-vm` in Bytecode übersetzt wird. +//! Ausgabe des Frontends ist ein typgeprüfter AST, den `tb-vm` in Bytecode +//! übersetzt. +pub mod ast; pub mod lexer; pub mod parser; -pub mod ast; +pub mod sema; -/// Quelltextposition für Diagnostik (1-basiert, wie im klassischen IDE-Vorbild). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Quelltextposition für Diagnostik (1-basiert, wie im IDE-Vorbild). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct SourcePos { pub line: u32, pub column: u32, } + +/// Eine Diagnosemeldung mit Position. Die Texte folgen den (englischen) +/// Meldungen des Vorbilds, wo es eine Entsprechung gibt. +#[derive(Debug, Clone)] +pub struct Diagnostic { + pub pos: SourcePos, + pub message: String, +} + +impl std::fmt::Display for Diagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}: {}", self.pos.line, self.pos.column, self.message) + } +} + +/// Ergebnis der Frontend-Pipeline für ein Modul. +pub struct Analysis { + pub module: ast::Module, + pub diagnostics: Vec, +} + +/// Komplette Pipeline: Lexen → Parsen → semantische Prüfung. +pub fn analyze_source(module_name: &str, source: &str) -> Analysis { + let lexed = lexer::lex(source); + let mut diagnostics = lexed.diagnostics; + let parsed = parser::parse(module_name, &lexed.tokens); + diagnostics.extend(parsed.diagnostics); + diagnostics.extend(sema::check(&parsed.module)); + diagnostics.sort_by_key(|d| (d.pos.line, d.pos.column)); + Analysis { module: parsed.module, diagnostics } +} diff --git a/crates/tb-frontend/src/parser.rs b/crates/tb-frontend/src/parser.rs index 4e0154c..274854b 100644 --- a/crates/tb-frontend/src/parser.rs +++ b/crates/tb-frontend/src/parser.rs @@ -1,6 +1,1517 @@ //! Parser: baut aus dem Tokenstrom den AST. //! -//! Der Parser arbeitet zeilenorientiert (eine logische Zeile = eine oder mehrere -//! Anweisungen), wie es das IDE-Vorbild mit seiner Zeile-für-Zeile-Prüfung tut. +//! Zeilenorientiert und fehlertolerant: Bei einem Fehler wird die Diagnose +//! gesammelt und bis zum nächsten Anweisungsende (`:` oder Zeilenende) +//! synchronisiert — wie die zeilenweise Prüfung des IDE-Vorbilds. -// Platzhalter — wird in Phase 1 ausgearbeitet (siehe PLAN.md) +use crate::ast::*; +use crate::lexer::{Kw, NumValue, Token, TokenKind}; +use crate::{Diagnostic, SourcePos}; + +pub struct ParseOutput { + pub module: Module, + pub diagnostics: Vec, +} + +pub fn parse(module_name: &str, tokens: &[Token]) -> ParseOutput { + let mut p = P { toks: tokens, i: 0, diags: Vec::new(), at_line_start: true }; + let mut body = Vec::new(); + let mut procs = Vec::new(); + + loop { + p.skip_seps(); + match p.k() { + TokenKind::Eof => break, + TokenKind::Kw(Kw::Sub) | TokenKind::Kw(Kw::Function) => { + if let Some(proc) = p.parse_proc() { + procs.push(proc); + } + } + _ => { + let before = p.i; + if let Some(s) = p.parse_statement() { + body.push(s); + } + if p.i == before { + p.advance(); // Notbremse gegen Endlosschleifen + } + } + } + } + + ParseOutput { + module: Module { name: module_name.to_string(), body, procs }, + diagnostics: p.diags, + } +} + +struct P<'a> { + toks: &'a [Token], + i: usize, + diags: Vec, + at_line_start: bool, +} + +impl<'a> P<'a> { + fn k(&self) -> TokenKind { + self.toks[self.i].kind.clone() + } + fn k_at(&self, off: usize) -> TokenKind { + let j = (self.i + off).min(self.toks.len() - 1); + self.toks[j].kind.clone() + } + fn pos(&self) -> SourcePos { + self.toks[self.i].pos + } + fn advance(&mut self) { + if self.i < self.toks.len() - 1 { + self.at_line_start = matches!(self.toks[self.i].kind, TokenKind::Eol); + self.i += 1; + } + } + fn eat(&mut self, kind: &TokenKind) -> bool { + if &self.k() == kind { + self.advance(); + true + } else { + false + } + } + fn eat_kw(&mut self, kw: Kw) -> bool { + self.eat(&TokenKind::Kw(kw)) + } + fn is_kw(&self, kw: Kw) -> bool { + self.k() == TokenKind::Kw(kw) + } + fn err(&mut self, msg: impl Into) { + let pos = self.pos(); + self.diags.push(Diagnostic { pos, message: msg.into() }); + } + fn expect_kw(&mut self, kw: Kw, what: &str) -> bool { + if self.eat_kw(kw) { + true + } else { + self.err(format!("Expected: {what}")); + false + } + } + /// Bis zum Anweisungsende überspringen (Fehler-Synchronisation). + fn sync(&mut self) { + while !self.at_stmt_end() { + self.advance(); + } + } + fn at_stmt_end(&self) -> bool { + matches!( + self.k(), + TokenKind::Colon + | TokenKind::Eol + | TokenKind::Eof + | TokenKind::Kw(Kw::Else) + | TokenKind::Kw(Kw::ElseIf) + ) + } + /// Anweisungstrenner (`:`/Zeilenende) überspringen. + fn skip_seps(&mut self) { + while matches!(self.k(), TokenKind::Colon | TokenKind::Eol) { + self.advance(); + } + } + + // ---- Blöcke ----------------------------------------------------------- + + fn at_end_pair(&self, kw: Kw) -> bool { + self.is_kw(Kw::End) && self.k_at(1) == TokenKind::Kw(kw) + } + + fn parse_stmt_list(&mut self, stop: impl Fn(&P) -> bool) -> Vec { + let mut out = Vec::new(); + loop { + self.skip_seps(); + if matches!(self.k(), TokenKind::Eof) || stop(self) { + break; + } + let before = self.i; + if let Some(s) = self.parse_statement() { + out.push(s); + } + if self.i == before { + self.advance(); + } + } + out + } + + /// Anweisungen auf derselben Zeile (einzeiliges IF): bis Zeilenende + /// bzw. ELSE. + fn parse_inline_stmts(&mut self) -> Vec { + let mut out = Vec::new(); + loop { + match self.k() { + TokenKind::Eol | TokenKind::Eof | TokenKind::Kw(Kw::Else) => break, + TokenKind::Colon => { + self.advance(); + continue; + } + _ => {} + } + let before = self.i; + if let Some(s) = self.parse_statement() { + out.push(s); + } + if self.i == before { + self.advance(); + } + } + out + } + + // ---- Anweisungen ------------------------------------------------------ + + fn parse_statement(&mut self) -> Option { + let pos = self.pos(); + + // Zeilennummern und Labels nur am Zeilenanfang + if self.at_line_start { + if let TokenKind::Num(NumValue::Int(n)) = self.k() { + if n >= 0 { + self.advance(); + return Some(Stmt::LineNumber(n as u32)); + } + } + if let TokenKind::Num(NumValue::Long(n)) = self.k() { + if n >= 0 { + self.advance(); + return Some(Stmt::LineNumber(n as u32)); + } + } + if let TokenKind::Ident { name, suffix: None } = self.k() { + if self.k_at(1) == TokenKind::Colon { + self.advance(); + self.advance(); + return Some(Stmt::Label(name)); + } + } + } + + match self.k() { + TokenKind::Kw(Kw::Print) => self.parse_print(pos), + TokenKind::Kw(Kw::Input) => { + self.advance(); + self.parse_input(false, pos) + } + TokenKind::Kw(Kw::Line) => { + self.advance(); + if self.expect_kw(Kw::Input, "INPUT") { + self.parse_input(true, pos) + } else { + self.sync(); + None + } + } + TokenKind::Kw(Kw::If) => self.parse_if(pos), + TokenKind::Kw(Kw::Select) => self.parse_select(pos), + TokenKind::Kw(Kw::For) => self.parse_for(pos), + TokenKind::Kw(Kw::Do) => self.parse_do(pos), + TokenKind::Kw(Kw::While) => self.parse_while(pos), + TokenKind::Kw(Kw::Goto) => { + self.advance(); + let target = self.parse_label_ref()?; + Some(Stmt::Goto { target, pos }) + } + TokenKind::Kw(Kw::Gosub) => { + self.advance(); + let target = self.parse_label_ref()?; + Some(Stmt::Gosub { target, pos }) + } + TokenKind::Kw(Kw::Return) => { + self.advance(); + let target = if self.at_stmt_end() { + None + } else { + self.parse_label_ref() + }; + Some(Stmt::Return { target, pos }) + } + TokenKind::Kw(Kw::On) => self.parse_on(pos), + TokenKind::Kw(Kw::Resume) => { + self.advance(); + let kind = if self.eat_kw(Kw::Next) { + ResumeKind::Next + } else if self.at_stmt_end() { + ResumeKind::Retry + } else if self.k() == TokenKind::Num(NumValue::Int(0)) { + self.advance(); + ResumeKind::Retry + } else { + ResumeKind::Label(self.parse_label_ref()?) + }; + Some(Stmt::Resume { kind, pos }) + } + TokenKind::Kw(Kw::Error) => { + self.advance(); + let code = self.parse_expr()?; + Some(Stmt::ErrorStmt { code, pos }) + } + TokenKind::Kw(Kw::End) => { + if matches!( + self.k_at(1), + TokenKind::Kw(Kw::If) + | TokenKind::Kw(Kw::Select) + | TokenKind::Kw(Kw::Sub) + | TokenKind::Kw(Kw::Function) + | TokenKind::Kw(Kw::Type) + | TokenKind::Kw(Kw::Def) + ) { + self.err("Syntax error (END without matching block)"); + self.advance(); + self.advance(); + return None; + } + self.advance(); + Some(Stmt::End) + } + TokenKind::Kw(Kw::Stop) => { + self.advance(); + Some(Stmt::StopStmt) + } + TokenKind::Kw(Kw::System) => { + self.advance(); + Some(Stmt::System) + } + TokenKind::Kw(Kw::Exit) => { + self.advance(); + let kind = if self.eat_kw(Kw::For) { + ExitKind::For + } else if self.eat_kw(Kw::Do) { + ExitKind::Do + } else if self.eat_kw(Kw::Sub) { + ExitKind::Sub + } else if self.eat_kw(Kw::Function) { + ExitKind::Function + } else if self.eat_kw(Kw::Def) { + ExitKind::Def + } else { + self.err("Expected: FOR, DO, SUB, FUNCTION or DEF"); + self.sync(); + return None; + }; + Some(Stmt::Exit { kind, pos }) + } + TokenKind::Kw(Kw::Dim) => { + self.advance(); + self.parse_dim(false, pos) + } + TokenKind::Kw(Kw::ReDim) => { + self.advance(); + self.parse_dim(true, pos) + } + TokenKind::Kw(Kw::Erase) => { + self.advance(); + let mut names = Vec::new(); + loop { + if let Some(e) = self.parse_name_only() { + names.push(e); + } else { + break; + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + Some(Stmt::Erase { names, pos }) + } + TokenKind::Kw(Kw::Const) => { + self.advance(); + let mut items = Vec::new(); + loop { + let (name, suffix) = match self.k() { + TokenKind::Ident { name, suffix } => { + self.advance(); + (name, suffix) + } + _ => { + self.err("Expected: identifier"); + self.sync(); + break; + } + }; + if !self.eat(&TokenKind::Eq) { + self.err("Expected: ="); + self.sync(); + break; + } + let value = self.parse_expr()?; + items.push((name, suffix, value)); + if !self.eat(&TokenKind::Comma) { + break; + } + } + Some(Stmt::ConstDecl { items, pos }) + } + TokenKind::Kw(k @ (Kw::DefInt | Kw::DefLng | Kw::DefSng + | Kw::DefDbl | Kw::DefStr | Kw::DefCur)) => { + self.advance(); + let ty = match k { + Kw::DefInt => TypeName::Integer, + Kw::DefLng => TypeName::Long, + Kw::DefSng => TypeName::Single, + Kw::DefDbl => TypeName::Double, + Kw::DefStr => TypeName::Str, + _ => TypeName::Currency, + }; + let mut ranges = Vec::new(); + loop { + let a = match self.k() { + TokenKind::Ident { name, suffix: None } + if name.len() == 1 => + { + self.advance(); + name.chars().next().unwrap() + } + _ => { + self.err("Expected: letter"); + self.sync(); + break; + } + }; + let b = if self.eat(&TokenKind::Minus) { + match self.k() { + TokenKind::Ident { name, suffix: None } + if name.len() == 1 => + { + self.advance(); + name.chars().next().unwrap() + } + _ => { + self.err("Expected: letter"); + a + } + } + } else { + a + }; + ranges.push((a, b)); + if !self.eat(&TokenKind::Comma) { + break; + } + } + Some(Stmt::DefType { ty, ranges, pos }) + } + TokenKind::Kw(Kw::Option) => { + self.advance(); + match self.k() { + TokenKind::Ident { name, suffix: None } + if name == "EXPLICIT" => + { + self.advance(); + Some(Stmt::OptionStmt { kind: OptionKind::Explicit, pos }) + } + TokenKind::Ident { name, suffix: None } if name == "BASE" => { + self.advance(); + let base = match self.k() { + TokenKind::Num(NumValue::Int(n @ (0 | 1))) => { + self.advance(); + n as u8 + } + _ => { + self.err("Expected: 0 or 1"); + self.sync(); + 0 + } + }; + Some(Stmt::OptionStmt { kind: OptionKind::Base(base), pos }) + } + _ => { + self.err("Expected: BASE or EXPLICIT"); + self.sync(); + None + } + } + } + TokenKind::Kw(Kw::Type) => self.parse_type_decl(pos), + TokenKind::Kw(Kw::Declare) => { + self.advance(); + let sig = self.parse_proc_sig()?; + Some(Stmt::Declare { sig, pos }) + } + TokenKind::Kw(Kw::Call) => { + self.advance(); + match self.k() { + TokenKind::Ident { name, suffix } => { + self.advance(); + let args = if self.eat(&TokenKind::LParen) { + let a = self.parse_arg_list(&TokenKind::RParen); + self.eat(&TokenKind::RParen); + a + } else { + Vec::new() + }; + Some(Stmt::Call { name, suffix, args, pos }) + } + _ => { + self.err("Expected: identifier"); + self.sync(); + None + } + } + } + TokenKind::Kw(Kw::Data) => { + self.advance(); + 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(); + } + 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()); + } + } + TokenKind::Minus => cur.push('-'), + _ => {} + } + self.advance(); + } + items.push(cur.trim().to_string()); + Some(Stmt::Data { items, pos }) + } + TokenKind::Kw(Kw::Read) => { + self.advance(); + let mut vars = Vec::new(); + loop { + if let Some(e) = self.parse_name_ref() { + vars.push(e); + } else { + break; + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + Some(Stmt::ReadStmt { vars, pos }) + } + TokenKind::Kw(Kw::Restore) => { + self.advance(); + let target = if self.at_stmt_end() { + None + } else { + self.parse_label_ref() + }; + Some(Stmt::Restore { target, pos }) + } + TokenKind::Kw(Kw::Def) => self.parse_def_fn(pos), + TokenKind::Kw(Kw::Let) => { + self.advance(); + self.parse_assign_or_call(pos, true) + } + TokenKind::Kw(k @ (Kw::Open | Kw::Close | Kw::Write | Kw::Field + | Kw::Get | Kw::Put | Kw::Seek | Kw::Lset | Kw::Rset)) => { + self.advance(); + self.sync(); + Some(Stmt::NotYetImplemented { keyword: format!("{k:?}").to_uppercase(), pos }) + } + TokenKind::Kw(Kw::Static) | TokenKind::Kw(Kw::Shared) => { + // STATIC/SHARED-Deklaration in Prozeduren: wie DIM behandeln + self.advance(); + self.parse_dim(false, pos) + } + TokenKind::Ident { .. } => self.parse_assign_or_call(pos, false), + _ => { + self.err("Syntax error"); + self.sync(); + None + } + } + } + + fn parse_print(&mut self, pos: SourcePos) -> Option { + self.advance(); // PRINT + let file = if self.eat(&TokenKind::Hash) { + let e = self.parse_expr()?; + self.eat(&TokenKind::Comma); + Some(e) + } else { + None + }; + let using = if self.eat_kw(Kw::Using) { + let f = self.parse_expr()?; + if !self.eat(&TokenKind::Semicolon) { + self.err("Expected: ;"); + } + Some(f) + } else { + None + }; + let mut items = Vec::new(); + loop { + match self.k() { + TokenKind::Comma => { + self.advance(); + items.push(PrintItem::Comma); + } + TokenKind::Semicolon => { + self.advance(); + items.push(PrintItem::Semicolon); + } + _ if self.at_stmt_end() => break, + _ => match self.parse_expr() { + Some(e) => items.push(PrintItem::Expr(e)), + None => break, + }, + } + } + Some(Stmt::Print { file, using, items, pos }) + } + + fn parse_input(&mut self, line: bool, pos: SourcePos) -> Option { + let keep_cursor = self.eat(&TokenKind::Semicolon); + let file = if self.eat(&TokenKind::Hash) { + let e = self.parse_expr()?; + self.eat(&TokenKind::Comma); + Some(e) + } else { + None + }; + let mut prompt = None; + if file.is_none() { + if let TokenKind::Str(s) = self.k() { + if matches!(self.k_at(1), TokenKind::Semicolon | TokenKind::Comma) + { + self.advance(); + let with_question = self.k() == TokenKind::Semicolon; + self.advance(); + prompt = Some((s, with_question)); + } + } + } + let mut vars = Vec::new(); + loop { + if let Some(e) = self.parse_name_ref() { + vars.push(e); + } else { + break; + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + if vars.is_empty() { + self.err("Expected: variable"); + } + Some(Stmt::Input { line, file, keep_cursor, prompt, vars, pos }) + } + + fn parse_if(&mut self, pos: SourcePos) -> Option { + self.advance(); // IF + let cond = self.parse_expr()?; + if !self.expect_kw(Kw::Then, "THEN") { + self.sync(); + return None; + } + if matches!(self.k(), TokenKind::Eol) { + // Blockform + let stop = |p: &P| { + p.is_kw(Kw::ElseIf) || p.is_kw(Kw::Else) || p.at_end_pair(Kw::If) + }; + let then_body = self.parse_stmt_list(stop); + let mut elseifs = Vec::new(); + while self.eat_kw(Kw::ElseIf) { + let c = self.parse_expr()?; + self.expect_kw(Kw::Then, "THEN"); + let b = self.parse_stmt_list(stop); + elseifs.push((c, b)); + } + let else_body = if self.eat_kw(Kw::Else) { + Some(self.parse_stmt_list(|p: &P| p.at_end_pair(Kw::If))) + } else { + None + }; + if self.at_end_pair(Kw::If) { + self.advance(); + self.advance(); + } else { + self.err("Expected: END IF"); + } + Some(Stmt::If { cond, then_body, elseifs, else_body, pos }) + } else { + // Einzeilig + let then_body = if let Some(n) = self.line_number_target() { + vec![Stmt::Goto { target: n, pos }] + } else { + self.parse_inline_stmts() + }; + let else_body = if self.eat_kw(Kw::Else) { + if let Some(n) = self.line_number_target() { + Some(vec![Stmt::Goto { target: n, pos }]) + } else { + Some(self.parse_inline_stmts()) + } + } else { + None + }; + Some(Stmt::If { cond, then_body, elseifs: Vec::new(), else_body, pos }) + } + } + + fn line_number_target(&mut self) -> Option { + match self.k() { + TokenKind::Num(NumValue::Int(n)) if n >= 0 => { + self.advance(); + Some(LabelRef::Line(n as u32)) + } + TokenKind::Num(NumValue::Long(n)) if n >= 0 => { + self.advance(); + Some(LabelRef::Line(n as u32)) + } + _ => None, + } + } + + fn parse_select(&mut self, pos: SourcePos) -> Option { + self.advance(); // SELECT + self.expect_kw(Kw::Case, "CASE"); + let expr = self.parse_expr()?; + self.skip_seps(); + let mut arms = Vec::new(); + while self.is_kw(Kw::Case) { + let arm_pos = self.pos(); + self.advance(); + let mut specs = Vec::new(); + if !self.eat_kw(Kw::Else) { + loop { + if self.eat_kw(Kw::Is) { + let op = match self.k() { + TokenKind::Eq => BinOp::Eq, + TokenKind::Ne => BinOp::Ne, + TokenKind::Lt => BinOp::Lt, + TokenKind::Le => BinOp::Le, + TokenKind::Gt => BinOp::Gt, + TokenKind::Ge => BinOp::Ge, + _ => { + self.err("Expected: relational operator"); + BinOp::Eq + } + }; + self.advance(); + let e = self.parse_expr()?; + specs.push(CaseSpec::Is(op, e)); + } else { + let a = self.parse_expr()?; + if self.eat_kw(Kw::To) { + let b = self.parse_expr()?; + specs.push(CaseSpec::Range(a, b)); + } else { + specs.push(CaseSpec::Expr(a)); + } + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + } + let body = self.parse_stmt_list(|p: &P| { + p.is_kw(Kw::Case) || p.at_end_pair(Kw::Select) + }); + arms.push(CaseArm { specs, body, pos: arm_pos }); + } + if self.at_end_pair(Kw::Select) { + self.advance(); + self.advance(); + } else { + self.err("Expected: END SELECT"); + } + Some(Stmt::Select { expr, arms, pos }) + } + + fn parse_for(&mut self, pos: SourcePos) -> Option { + self.advance(); // FOR + let var = self.parse_name_only()?; + if !self.eat(&TokenKind::Eq) { + self.err("Expected: ="); + self.sync(); + return None; + } + let from = self.parse_expr()?; + self.expect_kw(Kw::To, "TO"); + let to = self.parse_expr()?; + let step = if self.eat_kw(Kw::Step) { + Some(self.parse_expr()?) + } else { + None + }; + let body = self.parse_stmt_list(|p: &P| p.is_kw(Kw::Next)); + if self.eat_kw(Kw::Next) { + // Optional: Zählvariable(n) hinter NEXT + while matches!(self.k(), TokenKind::Ident { .. }) { + self.advance(); + if !self.eat(&TokenKind::Comma) { + break; + } + } + } else { + self.err("FOR without NEXT"); + } + Some(Stmt::For { var, from, to, step, body, pos }) + } + + fn parse_do(&mut self, pos: SourcePos) -> Option { + self.advance(); // DO + let pre = if self.eat_kw(Kw::While) { + Some((false, self.parse_expr()?)) + } else if self.eat_kw(Kw::Until) { + Some((true, self.parse_expr()?)) + } else { + None + }; + let body = self.parse_stmt_list(|p: &P| p.is_kw(Kw::Loop)); + let mut post = None; + if self.eat_kw(Kw::Loop) { + if self.eat_kw(Kw::While) { + post = Some((false, self.parse_expr()?)); + } else if self.eat_kw(Kw::Until) { + post = Some((true, self.parse_expr()?)); + } + } else { + self.err("DO without LOOP"); + } + Some(Stmt::DoLoop { pre, post, body, pos }) + } + + fn parse_while(&mut self, pos: SourcePos) -> Option { + self.advance(); // WHILE + let cond = self.parse_expr()?; + let body = self.parse_stmt_list(|p: &P| p.is_kw(Kw::Wend)); + if !self.eat_kw(Kw::Wend) { + self.err("WHILE without WEND"); + } + Some(Stmt::While { cond, body, pos }) + } + + fn parse_on(&mut self, pos: SourcePos) -> Option { + self.advance(); // ON + let local = self.eat_kw(Kw::Local); + if self.eat_kw(Kw::Error) { + if self.eat_kw(Kw::Goto) { + if self.k() == TokenKind::Num(NumValue::Int(0)) { + self.advance(); + return Some(Stmt::OnError { + local, + action: OnErrorAction::Disable, + pos, + }); + } + let target = self.parse_label_ref()?; + return Some(Stmt::OnError { + local, + action: OnErrorAction::Goto(target), + pos, + }); + } + if self.eat_kw(Kw::Resume) { + self.expect_kw(Kw::Next, "NEXT"); + return Some(Stmt::OnError { + local, + action: OnErrorAction::ResumeNext, + pos, + }); + } + self.err("Expected: GOTO or RESUME NEXT"); + self.sync(); + return None; + } + if local { + self.err("Expected: ERROR"); + self.sync(); + return None; + } + // ON ausdruck GOTO/GOSUB liste (Event-Traps: Phase 3/4) + let expr = self.parse_expr()?; + let gosub = if self.eat_kw(Kw::Goto) { + false + } else if self.eat_kw(Kw::Gosub) { + true + } else { + self.err("Expected: GOTO or GOSUB"); + self.sync(); + return None; + }; + let mut targets = Vec::new(); + loop { + match self.parse_label_ref() { + Some(t) => targets.push(t), + None => break, + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + Some(Stmt::OnGoto { expr, targets, gosub, pos }) + } + + fn parse_dim(&mut self, redim: bool, pos: SourcePos) -> Option { + let shared = self.eat_kw(Kw::Shared); + let mut decls = Vec::new(); + loop { + let dpos = self.pos(); + let (name, suffix) = match self.k() { + TokenKind::Ident { name, suffix } => { + self.advance(); + (name, suffix) + } + _ => { + self.err("Expected: identifier"); + self.sync(); + break; + } + }; + let dims = if self.eat(&TokenKind::LParen) { + let mut ds = Vec::new(); + if !self.eat(&TokenKind::RParen) { + loop { + let a = self.parse_expr()?; + if self.eat_kw(Kw::To) { + let b = self.parse_expr()?; + ds.push((Some(a), b)); + } else { + ds.push((None, a)); + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + self.eat(&TokenKind::RParen); + } + Some(ds) + } else { + None + }; + let as_type = if self.eat_kw(Kw::As) { + self.parse_type_name() + } else { + None + }; + decls.push(VarDecl { name, suffix, dims, as_type, pos: dpos }); + if !self.eat(&TokenKind::Comma) { + break; + } + } + Some(Stmt::Dim { shared, redim, decls, pos }) + } + + fn parse_type_name(&mut self) -> Option { + match self.k() { + TokenKind::Kw(Kw::Integer) => { + self.advance(); + Some(TypeName::Integer) + } + TokenKind::Kw(Kw::Long) => { + self.advance(); + Some(TypeName::Long) + } + TokenKind::Kw(Kw::Single) => { + self.advance(); + Some(TypeName::Single) + } + TokenKind::Kw(Kw::Double) => { + self.advance(); + Some(TypeName::Double) + } + TokenKind::Kw(Kw::Currency) => { + self.advance(); + Some(TypeName::Currency) + } + TokenKind::Kw(Kw::String) => { + self.advance(); + if self.eat(&TokenKind::Star) { + match self.k() { + TokenKind::Num(NumValue::Int(n)) => { + self.advance(); + Some(TypeName::FixedStr(n as i64)) + } + TokenKind::Num(NumValue::Long(n)) => { + self.advance(); + Some(TypeName::FixedStr(n as i64)) + } + _ => { + self.err("Expected: constant"); + Some(TypeName::Str) + } + } + } else { + Some(TypeName::Str) + } + } + TokenKind::Ident { name, suffix: None } => { + self.advance(); + Some(TypeName::Udt(name)) + } + _ => { + self.err("Expected: type"); + None + } + } + } + + fn parse_type_decl(&mut self, pos: SourcePos) -> Option { + self.advance(); // TYPE + let name = match self.k() { + TokenKind::Ident { name, suffix: None } => { + self.advance(); + name + } + _ => { + self.err("Expected: identifier"); + self.sync(); + return None; + } + }; + let mut fields = Vec::new(); + loop { + self.skip_seps(); + if self.at_end_pair(Kw::Type) { + self.advance(); + self.advance(); + break; + } + if matches!(self.k(), TokenKind::Eof) { + self.err("Expected: END TYPE"); + break; + } + match self.k() { + TokenKind::Ident { name: fname, suffix: None } => { + self.advance(); + if self.expect_kw(Kw::As, "AS") { + if let Some(t) = self.parse_type_name() { + fields.push((fname, t)); + } + } else { + self.sync(); + } + } + _ => { + self.err("Expected: identifier"); + self.sync(); + } + } + } + Some(Stmt::TypeDecl { name, fields, pos }) + } + + fn parse_proc_sig(&mut self) -> Option { + let kind = if self.eat_kw(Kw::Sub) { + ProcKind::Sub + } else if self.eat_kw(Kw::Function) { + ProcKind::Function + } else { + self.err("Expected: SUB or FUNCTION"); + self.sync(); + return None; + }; + let (name, suffix) = match self.k() { + TokenKind::Ident { name, suffix } => { + self.advance(); + (name, suffix) + } + _ => { + self.err("Expected: identifier"); + self.sync(); + return None; + } + }; + let mut params = Vec::new(); + if self.eat(&TokenKind::LParen) { + if !self.eat(&TokenKind::RParen) { + loop { + match self.k() { + TokenKind::Ident { name: pname, suffix: psfx } => { + self.advance(); + let array = if self.eat(&TokenKind::LParen) { + self.eat(&TokenKind::RParen); + true + } else { + false + }; + let as_type = if self.eat_kw(Kw::As) { + self.parse_type_name() + } else { + None + }; + params.push(Param { + name: pname, + suffix: psfx, + array, + as_type, + }); + } + _ => { + self.err("Expected: identifier"); + break; + } + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + self.eat(&TokenKind::RParen); + } + } + Some(ProcSig { kind, name, suffix, params }) + } + + fn parse_proc(&mut self) -> Option { + let pos = self.pos(); + let sig = self.parse_proc_sig()?; + let is_static = self.eat_kw(Kw::Static); + let end_kw = match sig.kind { + ProcKind::Sub => Kw::Sub, + ProcKind::Function => Kw::Function, + }; + let body = self.parse_stmt_list(|p: &P| p.at_end_pair(end_kw)); + if self.at_end_pair(end_kw) { + self.advance(); + self.advance(); + } else { + self.err(match sig.kind { + ProcKind::Sub => "Expected: END SUB", + ProcKind::Function => "Expected: END FUNCTION", + }); + } + Some(Proc { sig, is_static, body, pos }) + } + + fn parse_def_fn(&mut self, pos: SourcePos) -> Option { + self.advance(); // DEF + let (name, suffix) = match self.k() { + TokenKind::Ident { name, suffix } if name.starts_with("FN") => { + self.advance(); + (name, suffix) + } + _ => { + self.err("Expected: FN identifier"); + self.sync(); + return None; + } + }; + let mut params = Vec::new(); + if self.eat(&TokenKind::LParen) { + if !self.eat(&TokenKind::RParen) { + loop { + match self.k() { + TokenKind::Ident { name: pname, suffix: psfx } => { + self.advance(); + params.push(Param { + name: pname, + suffix: psfx, + array: false, + as_type: None, + }); + } + _ => { + self.err("Expected: identifier"); + break; + } + } + if !self.eat(&TokenKind::Comma) { + break; + } + } + self.eat(&TokenKind::RParen); + } + } + if self.eat(&TokenKind::Eq) { + let body = self.parse_expr()?; + Some(Stmt::DefFn { name, suffix, params, body, pos }) + } else { + // Blockform DEF FN … END DEF: noch nicht implementiert + self.sync(); + let stop = |p: &P| p.at_end_pair(Kw::Def); + let _ = self.parse_stmt_list(stop); + if self.at_end_pair(Kw::Def) { + self.advance(); + self.advance(); + } + Some(Stmt::NotYetImplemented { keyword: "DEF FN (Blockform)".into(), pos }) + } + } + + fn parse_assign_or_call(&mut self, pos: SourcePos, force_assign: bool) -> Option { + let target = self.parse_name_ref()?; + if self.eat(&TokenKind::Eq) { + let value = self.parse_expr()?; + return Some(Stmt::Assign { target, value, pos }); + } + if force_assign { + self.err("Expected: ="); + self.sync(); + return None; + } + // Impliziter Aufruf: `name [arg [, arg …]]` + if let Expr::Name { name, suffix, args, .. } = target { + let mut call_args = args.unwrap_or_default(); + if !self.at_stmt_end() && call_args.is_empty() { + call_args = self.parse_arg_list_to_stmt_end(); + } + Some(Stmt::Call { name, suffix, args: call_args, pos }) + } else { + self.err("Syntax error"); + self.sync(); + None + } + } + + // ---- Namen und Argumente --------------------------------------------- + + /// Name mit optionalen Klammer-Argumenten (Variable/Array/Funktion). + fn parse_name_ref(&mut self) -> Option { + let pos = self.pos(); + match self.k() { + TokenKind::Ident { name, suffix } => { + self.advance(); + let args = if self.eat(&TokenKind::LParen) { + let a = self.parse_arg_list(&TokenKind::RParen); + self.eat(&TokenKind::RParen); + Some(a) + } else { + None + }; + Some(Expr::Name { name, suffix, args, pos }) + } + _ => { + self.err("Expected: variable"); + self.sync(); + None + } + } + } + + /// Nur ein Name ohne Argumente (FOR-Variable, ERASE). + fn parse_name_only(&mut self) -> Option { + let pos = self.pos(); + match self.k() { + TokenKind::Ident { name, suffix } => { + self.advance(); + Some(Expr::Name { name, suffix, args: None, pos }) + } + _ => { + self.err("Expected: variable"); + self.sync(); + None + } + } + } + + fn parse_label_ref(&mut self) -> Option { + match self.k() { + TokenKind::Num(NumValue::Int(n)) if n >= 0 => { + self.advance(); + Some(LabelRef::Line(n as u32)) + } + TokenKind::Num(NumValue::Long(n)) if n >= 0 => { + self.advance(); + Some(LabelRef::Line(n as u32)) + } + TokenKind::Ident { name, suffix: None } => { + self.advance(); + Some(LabelRef::Name(name)) + } + _ => { + self.err("Expected: label or line number"); + self.sync(); + None + } + } + } + + /// Argumentliste bis `closer` (nicht konsumiert); leere Plätze → Missing. + fn parse_arg_list(&mut self, closer: &TokenKind) -> Vec { + let mut args = Vec::new(); + loop { + if &self.k() == closer || self.at_stmt_end() { + if !args.is_empty() { + args.push(Expr::Missing); + } + break; + } + if self.k() == TokenKind::Comma { + self.advance(); + args.push(Expr::Missing); + continue; + } + match self.parse_expr() { + Some(e) => args.push(e), + None => break, + } + if !self.eat(&TokenKind::Comma) { + break; + } + if &self.k() == closer || self.at_stmt_end() { + args.push(Expr::Missing); + break; + } + } + args + } + + /// Argumentliste eines impliziten Aufrufs bis zum Anweisungsende. + fn parse_arg_list_to_stmt_end(&mut self) -> Vec { + let mut args = Vec::new(); + loop { + if self.at_stmt_end() { + break; + } + if self.k() == TokenKind::Comma { + self.advance(); + args.push(Expr::Missing); + continue; + } + match self.parse_expr() { + Some(e) => args.push(e), + None => break, + } + if !self.eat(&TokenKind::Comma) { + break; + } + if self.at_stmt_end() { + args.push(Expr::Missing); + break; + } + } + args + } + + // ---- Ausdrücke --------------------------------------------------------- + + fn parse_expr(&mut self) -> Option { + self.parse_bin(1) + } + + fn parse_bin(&mut self, min_prec: u8) -> Option { + let mut lhs = self.parse_prefix()?; + loop { + let (prec, op) = match self.infix_op() { + Some(x) => x, + None => break, + }; + if prec < min_prec { + break; + } + let pos = self.pos(); + self.advance(); + let rhs = self.parse_bin(prec + 1)?; + lhs = Expr::Binary { op, lhs: Box::new(lhs), rhs: Box::new(rhs), pos }; + } + Some(lhs) + } + + /// Prioritäten nach Sprachreferenz §4 (hoch = bindet stärker). + fn infix_op(&self) -> Option<(u8, BinOp)> { + Some(match self.k() { + TokenKind::Kw(Kw::Imp) => (1, BinOp::Imp), + TokenKind::Kw(Kw::Eqv) => (2, BinOp::Eqv), + TokenKind::Kw(Kw::Xor) => (3, BinOp::Xor), + TokenKind::Kw(Kw::Or) => (4, BinOp::Or), + TokenKind::Kw(Kw::And) => (5, BinOp::And), + TokenKind::Eq => (7, BinOp::Eq), + TokenKind::Ne => (7, BinOp::Ne), + TokenKind::Lt => (7, BinOp::Lt), + TokenKind::Le => (7, BinOp::Le), + TokenKind::Gt => (7, BinOp::Gt), + TokenKind::Ge => (7, BinOp::Ge), + TokenKind::Plus => (8, BinOp::Add), + TokenKind::Minus => (8, BinOp::Sub), + TokenKind::Kw(Kw::Mod) => (9, BinOp::Mod), + TokenKind::Backslash => (10, BinOp::IntDiv), + TokenKind::Star => (11, BinOp::Mul), + TokenKind::Slash => (11, BinOp::Div), + TokenKind::Caret => (13, BinOp::Pow), + _ => return None, + }) + } + + fn parse_prefix(&mut self) -> Option { + let pos = self.pos(); + match self.k() { + TokenKind::Minus => { + self.advance(); + let operand = self.parse_bin(12)?; + Some(Expr::Unary { op: UnOp::Neg, operand: Box::new(operand), pos }) + } + TokenKind::Plus => { + self.advance(); + self.parse_bin(12) + } + TokenKind::Kw(Kw::Not) => { + self.advance(); + let operand = self.parse_bin(6)?; + Some(Expr::Unary { op: UnOp::Not, operand: Box::new(operand), pos }) + } + _ => self.parse_primary(), + } + } + + fn parse_primary(&mut self) -> Option { + match self.k() { + TokenKind::Num(n) => { + self.advance(); + Some(match n { + NumValue::Int(v) => Expr::IntLit(v), + NumValue::Long(v) => Expr::LongLit(v), + NumValue::Single(v) => Expr::SingleLit(v), + NumValue::Double(v) => Expr::DoubleLit(v), + NumValue::Currency(v) => Expr::CurrencyLit(v), + }) + } + TokenKind::Str(s) => { + self.advance(); + Some(Expr::StrLit(s)) + } + TokenKind::LParen => { + self.advance(); + let e = self.parse_expr()?; + if !self.eat(&TokenKind::RParen) { + self.err("Expected: )"); + } + Some(e) + } + TokenKind::Ident { .. } => self.parse_name_ref(), + _ => { + self.err("Expected: expression"); + self.sync(); + None + } + } + } +} + +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::*; + use crate::lexer::lex; + + fn parse_ok(src: &str) -> Module { + let l = lex(src); + assert!(l.diagnostics.is_empty(), "Lexer: {:?}", l.diagnostics); + let p = parse("TEST", &l.tokens); + assert!(p.diagnostics.is_empty(), "Parser: {:?}", p.diagnostics); + p.module + } + + #[test] + fn zuweisung_und_aufruf() { + let m = parse_ok("a% = 1\nCOLOR 15, 1\nCLS"); + assert_eq!(m.body.len(), 3); + assert!(matches!(m.body[0], Stmt::Assign { .. })); + assert!(matches!(&m.body[1], Stmt::Call { name, args, .. } + if name == "COLOR" && args.len() == 2)); + assert!(matches!(&m.body[2], Stmt::Call { name, args, .. } + if name == "CLS" && args.is_empty())); + } + + #[test] + fn print_varianten() { + let m = parse_ok("PRINT \"a\", \"b\"; 1\nPRINT\nPRINT USING \"##.#\"; 1.5"); + assert!(matches!(&m.body[0], Stmt::Print { items, .. } if items.len() == 5)); + assert!(matches!(&m.body[1], Stmt::Print { items, .. } if items.is_empty())); + assert!(matches!(&m.body[2], Stmt::Print { using: Some(_), .. })); + } + + #[test] + fn if_block_und_einzeilig() { + let m = parse_ok( + "IF a > 1 THEN\nPRINT 1\nELSEIF a > 0 THEN\nPRINT 2\nELSE\nPRINT 3\nEND IF\nIF b THEN PRINT 4 ELSE PRINT 5", + ); + match &m.body[0] { + Stmt::If { elseifs, else_body, .. } => { + assert_eq!(elseifs.len(), 1); + assert!(else_body.is_some()); + } + other => panic!("erwartet IF, war {other:?}"), + } + assert!(matches!(&m.body[1], Stmt::If { else_body: Some(_), .. })); + } + + #[test] + fn select_case() { + let m = parse_ok( + "SELECT CASE i%\nCASE 1: PRINT \"eins\"\nCASE 2 TO 3, IS >= 10\nPRINT \"x\"\nCASE ELSE\nPRINT \"y\"\nEND SELECT", + ); + match &m.body[0] { + Stmt::Select { arms, .. } => { + assert_eq!(arms.len(), 3); + assert_eq!(arms[1].specs.len(), 2); + assert!(arms[2].specs.is_empty()); + } + other => panic!("erwartet SELECT, war {other:?}"), + } + } + + #[test] + fn schleifen() { + let m = parse_ok( + "FOR i = 1 TO 10 STEP 2\nNEXT i\nDO WHILE x\nLOOP\nDO\nLOOP UNTIL x\nWHILE x\nWEND", + ); + assert!(matches!(&m.body[0], Stmt::For { step: Some(_), .. })); + assert!(matches!(&m.body[1], Stmt::DoLoop { pre: Some((false, _)), post: None, .. })); + assert!(matches!(&m.body[2], Stmt::DoLoop { pre: None, post: Some((true, _)), .. })); + assert!(matches!(&m.body[3], Stmt::While { .. })); + } + + #[test] + fn prozeduren() { + let m = parse_ok( + "DECLARE SUB Foo (a%, b$)\nSUB Foo (a%, b$)\nPRINT a%; b$\nEND SUB\nFUNCTION Quad (x)\nQuad = x * x\nEND FUNCTION", + ); + assert_eq!(m.procs.len(), 2); + assert_eq!(m.procs[0].sig.params.len(), 2); + assert!(matches!(m.procs[1].sig.kind, ProcKind::Function)); + } + + #[test] + fn fehlerbehandlung() { + let m = parse_ok( + "ON ERROR GOTO Handler\nON LOCAL ERROR RESUME NEXT\nON ERROR GOTO 0\nHandler:\nRESUME NEXT", + ); + assert!(matches!(&m.body[0], Stmt::OnError { local: false, action: OnErrorAction::Goto(_), .. })); + assert!(matches!(&m.body[1], Stmt::OnError { local: true, action: OnErrorAction::ResumeNext, .. })); + assert!(matches!(&m.body[2], Stmt::OnError { action: OnErrorAction::Disable, .. })); + } + + #[test] + fn operator_prioritaet() { + let m = parse_ok("x = 1 + 2 * 3 ^ 2"); + // 1 + (2 * (3 ^ 2)) + match &m.body[0] { + Stmt::Assign { value: Expr::Binary { op: BinOp::Add, rhs, .. }, .. } => { + assert!(matches!(**rhs, Expr::Binary { op: BinOp::Mul, .. })); + } + other => panic!("unerwartet: {other:?}"), + } + } + + #[test] + fn typen_und_deklarationen() { + let m = parse_ok( + "TYPE Kunde\nName AS STRING * 30\nUmsatz AS DOUBLE\nEND TYPE\nDIM SHARED k AS Kunde, a(1 TO 10) AS INTEGER\nCONST PI = 3.14159\nDEFINT A-C, X", + ); + assert!(matches!(&m.body[0], Stmt::TypeDecl { fields, .. } if fields.len() == 2)); + assert!(matches!(&m.body[1], Stmt::Dim { shared: true, decls, .. } if decls.len() == 2)); + assert!(matches!(&m.body[3], Stmt::DefType { ranges, .. } if ranges.len() == 2)); + } +} diff --git a/crates/tb-frontend/src/sema.rs b/crates/tb-frontend/src/sema.rs new file mode 100644 index 0000000..1ce6765 --- /dev/null +++ b/crates/tb-frontend/src/sema.rs @@ -0,0 +1,1084 @@ +//! Semantische Analyse: Symboltabellen, implizite Deklaration, +//! `DEFtype`-Regeln, `OPTION EXPLICIT`/`BASE`, Typprüfung, Auflösung +//! Array-Index vs. Funktionsaufruf, Label-Prüfung, Builtin-Signaturen. +//! +//! Erste Ausbaustufe (Phase 1): bewusst nachsichtig — lieber eine Prüfung +//! auslassen als falsche Fehler melden. `COMMON`, `SHARED`-Sichtbarkeit +//! über Prozedurgrenzen und UDT-Feldtypen folgen mit Phase 2/3. + +use crate::ast::*; +use crate::lexer::Suffix; +use crate::{Diagnostic, SourcePos}; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Ty { + Int, + Lng, + Cur, + Sng, + Dbl, + Str, + FixedStr, + Udt(String), + Unknown, +} + +fn is_num(t: &Ty) -> bool { + matches!(t, Ty::Int | Ty::Lng | Ty::Cur | Ty::Sng | Ty::Dbl | Ty::Unknown) +} +fn is_str(t: &Ty) -> bool { + matches!(t, Ty::Str | Ty::FixedStr | Ty::Unknown) +} +fn rank(t: &Ty) -> u8 { + match t { + Ty::Int => 1, + Ty::Lng => 2, + Ty::Cur => 3, + Ty::Sng => 4, + Ty::Dbl => 5, + _ => 0, + } +} +fn promote(a: &Ty, b: &Ty) -> Ty { + if rank(a) >= rank(b) { a.clone() } else { b.clone() } +} + +fn suffix_ty(s: Suffix) -> Ty { + match s { + Suffix::Integer => Ty::Int, + Suffix::Long => Ty::Lng, + Suffix::Single => Ty::Sng, + Suffix::Double => Ty::Dbl, + Suffix::Str => Ty::Str, + Suffix::Currency => Ty::Cur, + } +} + +fn type_name_ty(t: &TypeName) -> Ty { + match t { + TypeName::Integer => Ty::Int, + TypeName::Long => Ty::Lng, + TypeName::Single => Ty::Sng, + TypeName::Double => Ty::Dbl, + TypeName::Currency => Ty::Cur, + TypeName::Str => Ty::Str, + TypeName::FixedStr(_) => Ty::FixedStr, + TypeName::Udt(n) => Ty::Udt(n.clone()), + } +} + +#[derive(Debug, Clone)] +struct VarInfo { + ty: Ty, + array: bool, + explicit: bool, +} + +#[derive(Default)] +struct Scope { + vars: HashMap, + labels: HashSet, + line_labels: HashSet, +} + +#[derive(Debug, Clone)] +struct ProcInfo { + kind: ProcKind, + ret: Ty, + params: Vec<(Ty, bool)>, // (Typ, ist Array) +} + +// ---- Builtin-Signaturen ---------------------------------------------------- + +#[derive(Clone, Copy)] +enum ArgK { + N, // numerisch + S, // String + A, // beliebig +} + +#[derive(Clone, Copy)] +enum RetK { + I, + L, + Cu, + Sg, + Db, + St, +} + +fn ret_ty(r: RetK) -> Ty { + match r { + RetK::I => Ty::Int, + RetK::L => Ty::Lng, + RetK::Cu => Ty::Cur, + RetK::Sg => Ty::Sng, + RetK::Db => Ty::Dbl, + RetK::St => Ty::Str, + } +} + +/// Builtin-Funktionen: Name (inkl. Suffix) → (min, max, Argtypen, Rückgabe). +fn builtin_fn(name: &str) -> Option<(u8, u8, &'static [ArgK], RetK)> { + use ArgK::*; + use RetK::*; + Some(match name { + "ABS" | "FIX" | "INT" => (1, 1, &[N], Db), + "SGN" => (1, 1, &[N], I), + "SQR" | "EXP" | "LOG" | "SIN" | "COS" | "TAN" | "ATN" => (1, 1, &[N], Db), + "CINT" => (1, 1, &[N], I), + "CLNG" => (1, 1, &[N], L), + "CSNG" => (1, 1, &[N], Sg), + "CDBL" => (1, 1, &[N], Db), + "CCUR" => (1, 1, &[N], Cu), + "RND" => (0, 1, &[N], Sg), + "ASC" => (1, 1, &[S], I), + "CHR$" => (1, 1, &[N], St), + "LEN" => (1, 1, &[A], I), + "LEFT$" | "RIGHT$" => (2, 2, &[S, N], St), + "MID$" => (2, 3, &[S, N, N], St), + "INSTR" => (2, 3, &[A, A, A], I), // Sonderfall, s. check_builtin_args + "UCASE$" | "LCASE$" | "LTRIM$" | "RTRIM$" => (1, 1, &[S], St), + "SPACE$" => (1, 1, &[N], St), + "STRING$" => (2, 2, &[N, A], St), + "STR$" => (1, 1, &[N], St), + "VAL" => (1, 1, &[S], Db), + "HEX$" | "OCT$" => (1, 1, &[N], St), + "INKEY$" => (0, 0, &[], St), + "INPUT$" => (1, 2, &[N, N], St), + "LBOUND" | "UBOUND" => (1, 2, &[A, N], L), + "CSRLIN" => (0, 0, &[], I), + "POS" => (1, 1, &[N], I), + "TAB" | "SPC" => (1, 1, &[N], St), + "DATE$" | "TIME$" => (0, 0, &[], St), + "TIMER" => (0, 0, &[], Sg), + "NOW" => (0, 0, &[], Db), + "DATESERIAL" | "TIMESERIAL" => (3, 3, &[N, N, N], Db), + "DATEVALUE" | "TIMEVALUE" => (1, 1, &[S], Db), + "DAY" | "MONTH" | "YEAR" | "WEEKDAY" | "HOUR" | "MINUTE" | "SECOND" => { + (1, 1, &[N], I) + } + "FORMAT$" => (1, 2, &[A, S], St), + "ERR" | "ERL" => (0, 0, &[], L), + "FRE" => (1, 1, &[A], L), + "EOF" => (1, 1, &[N], I), + "LOF" | "LOC" | "SEEK" => (1, 1, &[N], L), + "FREEFILE" => (0, 0, &[], I), + "ENVIRON$" => (1, 1, &[A], St), + "COMMAND$" => (0, 0, &[], St), + "DOEVENTS" => (0, 0, &[], I), + "MSGBOX" => (1, 3, &[S, N, S], I), + "INPUTBOX$" => (1, 5, &[S, S, S, N, N], St), + _ => return None, + }) +} + +/// Hardware-nahe Namen des Vorbilds, die Terminal Basic bewusst nicht +/// unterstützt (Entscheidung 2026-09-02): Ablehnung bereits zur +/// Compile-Zeit statt Laufzeitfehler 73. +fn banned_feature(name: &str) -> bool { + matches!( + name, + "PEEK" | "POKE" | "INP" | "OUT" | "WAIT" | "BLOAD" | "BSAVE" + | "VARPTR" | "VARSEG" | "SADD" | "VARPTR$" | "ABSOLUTE" + | "INTERRUPT" | "INTERRUPTX" | "IOCTL" | "IOCTL$" + | "DEF.SEG" | "FRE.SEG" + ) +} + +/// Builtin-Anweisungen (Bibliothek, keine Keywords). +fn builtin_stmt(name: &str) -> Option<(u8, u8, &'static [ArgK])> { + use ArgK::*; + Some(match name { + "CLS" => (0, 1, &[N]), + "BEEP" | "DOEVENTS" => (0, 0, &[]), + "COLOR" => (0, 3, &[N, N, N]), + "LOCATE" => (0, 5, &[N, N, N, N, N]), + "RANDOMIZE" => (0, 1, &[N]), + "SLEEP" => (0, 1, &[N]), + "WIDTH" => (0, 2, &[N, N]), + "SOUND" => (2, 2, &[N, N]), + "SWAP" => (2, 2, &[A, A]), + "KILL" | "CHDIR" | "MKDIR" | "RMDIR" => (1, 1, &[S]), + "SHELL" => (0, 1, &[S]), + "ENVIRON" => (1, 1, &[S]), + "MSGBOX" => (1, 3, &[S, N, S]), + "RESET" => (0, 0, &[]), + _ => return None, + }) +} + +// ---- Prüfung --------------------------------------------------------------- + +pub fn check(module: &Module) -> Vec { + let mut s = Sema { + diags: Vec::new(), + deftypes: [const { None }; 26], + explicit: false, + procs: HashMap::new(), + udts: HashSet::new(), + consts: HashMap::new(), + }; + s.run(module); + s.diags +} + +struct Sema { + diags: Vec, + /// `DEFtype`-Zuordnung je Anfangsbuchstabe; None = Standard (SINGLE). + deftypes: [Option; 26], + explicit: bool, + procs: HashMap, + udts: HashSet, + consts: HashMap, +} + +impl Sema { + fn err(&mut self, pos: SourcePos, msg: impl Into) { + self.diags.push(Diagnostic { pos, message: msg.into() }); + } + + fn run(&mut self, module: &Module) { + // Pass 1: Prozeduren, DECLAREs und TYPEs registrieren + for stmt in &module.body { + match stmt { + Stmt::Declare { sig, .. } => self.register_proc(sig), + Stmt::TypeDecl { name, .. } => { + self.udts.insert(name.clone()); + } + _ => {} + } + } + for proc in &module.procs { + self.register_proc(&proc.sig); + } + + // Pass 2: Modulrumpf + let mut scope = Scope::default(); + collect_labels(&module.body, &mut scope); + let body_ref: Vec<&Stmt> = module.body.iter().collect(); + for stmt in body_ref { + self.check_stmt(stmt, &mut scope, None); + } + + // Pass 3: Prozedurrümpfe + for proc in &module.procs { + let mut pscope = Scope::default(); + collect_labels(&proc.body, &mut pscope); + for p in &proc.sig.params { + let ty = self.param_ty(p); + let key = self.var_key(&p.name, &p.suffix, p.as_type.is_some()); + pscope.vars.insert( + key, + VarInfo { ty, array: p.array, explicit: true }, + ); + } + // Funktionsname als Rückgabe-Variable + if proc.sig.kind == ProcKind::Function { + let ret = self.name_ty(&proc.sig.name, &proc.sig.suffix); + let key = self.var_key(&proc.sig.name, &proc.sig.suffix, false); + pscope.vars.insert(key, VarInfo { ty: ret, array: false, explicit: true }); + } + let fn_name = match proc.sig.kind { + ProcKind::Function => Some(proc.sig.name.clone()), + ProcKind::Sub => None, + }; + for stmt in &proc.body { + self.check_stmt(stmt, &mut pscope, fn_name.as_deref()); + } + } + } + + fn param_ty(&self, p: &Param) -> Ty { + if let Some(t) = &p.as_type { + type_name_ty(t) + } else { + self.name_ty(&p.name, &p.suffix) + } + } + + fn register_proc(&mut self, sig: &ProcSig) { + let ret = match sig.kind { + ProcKind::Function => self.name_ty(&sig.name, &sig.suffix), + ProcKind::Sub => Ty::Unknown, + }; + let params = sig + .params + .iter() + .map(|p| (self.param_ty(p), p.array)) + .collect(); + self.procs.insert( + sig.name.clone(), + ProcInfo { kind: sig.kind, ret, params }, + ); + } + + /// Standardtyp eines Namens ohne Suffix (DEFtype bzw. SINGLE). + fn default_ty(&self, name: &str) -> Ty { + let first = name.chars().next().unwrap_or('A'); + if first.is_ascii_alphabetic() { + let idx = (first.to_ascii_uppercase() as u8 - b'A') as usize; + if let Some(t) = &self.deftypes[idx] { + return t.clone(); + } + } + Ty::Sng + } + + fn name_ty(&self, name: &str, suffix: &Option) -> Ty { + match suffix { + Some(s) => suffix_ty(*s), + None => self.default_ty(name), + } + } + + fn var_key(&self, name: &str, suffix: &Option, as_decl: bool) -> String { + if as_decl { + format!("{name}\u{1}AS") + } else { + let c = match suffix { + Some(s) => s.as_char(), + None => match self.default_ty(name) { + Ty::Int => '%', + Ty::Lng => '&', + Ty::Cur => '@', + Ty::Dbl => '#', + Ty::Str => '$', + _ => '!', + }, + }; + format!("{name}{c}") + } + } + + /// Variable nachschlagen; `declare_if_missing` deklariert implizit. + fn resolve_var( + &mut self, + scope: &mut Scope, + name: &str, + suffix: &Option, + array: bool, + pos: SourcePos, + ) -> Ty { + // AS-deklarierte Variable hat Vorrang, wenn kein Suffix angegeben + let as_key = format!("{name}\u{1}AS"); + if suffix.is_none() { + if let Some(v) = scope.vars.get(&as_key) { + return v.ty.clone(); + } + } else if scope.vars.contains_key(&as_key) { + self.err(pos, "Duplicate definition"); + return Ty::Unknown; + } + let key = self.var_key(name, suffix, false); + if let Some(v) = scope.vars.get(&key) { + return v.ty.clone(); + } + // UDT-Feldzugriff `a.b`: Basis auflösen + if let Some(base) = name.split('.').next() { + if base != name { + let base_as = format!("{base}\u{1}AS"); + if scope.vars.contains_key(&base_as) { + return Ty::Unknown; // Feldtypen: Phase 2 + } + } + } + if self.explicit { + self.err(pos, "Variable not defined"); + return self.name_ty(name, suffix); + } + let ty = self.name_ty(name, suffix); + scope.vars.insert( + key, + VarInfo { ty: ty.clone(), array, explicit: false }, + ); + ty + } + + // ---- Anweisungen ------------------------------------------------------- + + fn check_stmt(&mut self, stmt: &Stmt, scope: &mut Scope, fn_name: Option<&str>) { + match stmt { + Stmt::Label(_) | Stmt::LineNumber(_) | Stmt::End | Stmt::StopStmt + | Stmt::System | Stmt::Data { .. } | Stmt::Exit { .. } + | Stmt::NotYetImplemented { .. } => {} + + Stmt::Assign { target, value, pos } => { + let tt = self.lvalue_ty(target, scope); + let vt = self.expr_ty(value, scope); + self.check_assign(&tt, &vt, *pos); + } + Stmt::Print { file, using, items, .. } => { + if let Some(f) = file { + self.want_num(f, scope); + } + if let Some(u) = using { + self.want_str(u, scope); + } + for item in items { + if let PrintItem::Expr(e) = item { + self.expr_ty(e, scope); + } + } + } + Stmt::Input { file, vars, .. } => { + if let Some(f) = file { + self.want_num(f, scope); + } + for v in vars { + self.lvalue_ty(v, scope); + } + } + Stmt::If { cond, then_body, elseifs, else_body, .. } => { + self.want_num(cond, scope); + for s in then_body { + self.check_stmt(s, scope, fn_name); + } + for (c, b) in elseifs { + self.want_num(c, scope); + for s in b { + self.check_stmt(s, scope, fn_name); + } + } + if let Some(b) = else_body { + for s in b { + self.check_stmt(s, scope, fn_name); + } + } + } + Stmt::Select { expr, arms, pos: _ } => { + let st = self.expr_ty(expr, scope); + for arm in arms { + for spec in &arm.specs { + let specs: Vec<&Expr> = match spec { + CaseSpec::Expr(e) => vec![e], + CaseSpec::Range(a, b) => vec![a, b], + CaseSpec::Is(_, e) => vec![e], + }; + for e in specs { + let et = self.expr_ty(e, scope); + let ok = (is_num(&st) && is_num(&et)) + || (is_str(&st) && is_str(&et)); + if !ok { + self.err(e.pos(), "Type mismatch"); + } + } + } + for s in &arm.body { + self.check_stmt(s, scope, fn_name); + } + } + } + Stmt::For { var, from, to, step, body, pos } => { + let vt = self.lvalue_ty(var, scope); + if !is_num(&vt) { + self.err(*pos, "Type mismatch"); + } + self.want_num(from, scope); + self.want_num(to, scope); + if let Some(s) = step { + self.want_num(s, scope); + } + for s in body { + self.check_stmt(s, scope, fn_name); + } + } + Stmt::DoLoop { pre, post, body, .. } => { + if let Some((_, c)) = pre { + self.want_num(c, scope); + } + if let Some((_, c)) = post { + self.want_num(c, scope); + } + for s in body { + self.check_stmt(s, scope, fn_name); + } + } + Stmt::While { cond, body, .. } => { + self.want_num(cond, scope); + for s in body { + self.check_stmt(s, scope, fn_name); + } + } + Stmt::Goto { target, pos } | Stmt::Gosub { target, pos } => { + self.check_label(target, scope, *pos); + } + Stmt::OnGoto { expr, targets, pos, .. } => { + self.want_num(expr, scope); + for t in targets { + self.check_label(t, scope, *pos); + } + } + Stmt::Return { target, pos } => { + if let Some(t) = target { + self.check_label(t, scope, *pos); + } + } + Stmt::OnError { action, pos, .. } => { + if let OnErrorAction::Goto(t) = action { + self.check_label(t, scope, *pos); + } + } + Stmt::Resume { kind, pos } => { + if let ResumeKind::Label(t) = kind { + self.check_label(t, scope, *pos); + } + } + Stmt::ErrorStmt { code, .. } => self.want_num(code, scope), + Stmt::Dim { redim, decls, .. } => { + for d in decls { + self.declare(d, *redim, scope); + } + } + Stmt::Erase { names, .. } => { + for n in names { + if let Expr::Name { name, suffix, pos, .. } = n { + self.resolve_var(scope, name, suffix, true, *pos); + } + } + } + Stmt::ConstDecl { items, .. } => { + for (name, _suffix, value) in items { + let ty = self.expr_ty(value, scope); + self.consts.insert(name.clone(), ty); + } + } + Stmt::DefType { ty, ranges, .. } => { + for (a, b) in ranges { + let (a, b) = (a.to_ascii_uppercase(), b.to_ascii_uppercase()); + for c in a..=b { + if c.is_ascii_uppercase() { + self.deftypes[(c as u8 - b'A') as usize] = + Some(type_name_ty(ty)); + } + } + } + } + Stmt::OptionStmt { kind, .. } => { + if *kind == OptionKind::Explicit { + self.explicit = true; + } + // OPTION BASE: relevant erst für die Array-Semantik (Phase 2) + } + Stmt::TypeDecl { fields, pos, .. } => { + for (_, t) in fields { + if let TypeName::Udt(n) = t { + if !self.udts.contains(n) { + self.err(*pos, "Type not defined"); + } + } + } + } + Stmt::Declare { .. } => {} // bereits in Pass 1 registriert + Stmt::Call { name, args, pos, .. } => { + self.check_call(name, args, scope, *pos); + } + Stmt::ReadStmt { vars, .. } => { + for v in vars { + self.lvalue_ty(v, scope); + } + } + Stmt::Restore { target, pos } => { + if let Some(t) = target { + self.check_label(t, scope, *pos); + } + } + Stmt::DefFn { name, suffix, params, body, pos: _ } => { + let ret = self.name_ty(name, suffix); + let param_infos = params + .iter() + .map(|p| (self.param_ty(p), p.array)) + .collect(); + self.procs.insert( + name.clone(), + ProcInfo { kind: ProcKind::Function, ret, params: param_infos }, + ); + // Rumpf in Mini-Scope mit den Parametern prüfen + let mut fscope = Scope::default(); + for p in params { + let ty = self.param_ty(p); + let key = self.var_key(&p.name, &p.suffix, false); + fscope.vars.insert(key, VarInfo { ty, array: false, explicit: true }); + } + self.expr_ty(body, &mut fscope); + } + } + let _ = fn_name; // Rückgabezuweisung läuft über die Scope-Variable + } + + fn declare(&mut self, d: &VarDecl, redim: bool, scope: &mut Scope) { + let ty = if let Some(t) = &d.as_type { + if let TypeName::Udt(n) = t { + if !self.udts.contains(n) { + self.err(d.pos, "Type not defined"); + } + } + type_name_ty(t) + } else { + self.name_ty(&d.name, &d.suffix) + }; + if let Some(dims) = &d.dims { + for (lo, hi) in dims { + if let Some(l) = lo { + self.want_num(l, scope); + } + self.want_num(hi, scope); + } + } + let key = self.var_key(&d.name, &d.suffix, d.as_type.is_some()); + let is_array = d.dims.is_some(); + if let Some(existing) = scope.vars.get(&key) { + let redim_ok = redim && existing.array && is_array; + if existing.explicit && !redim_ok { + self.err(d.pos, "Duplicate definition"); + return; + } + } + scope.vars.insert(key, VarInfo { ty, array: is_array, explicit: true }); + } + + fn check_label(&mut self, target: &LabelRef, scope: &Scope, pos: SourcePos) { + let found = match target { + LabelRef::Name(n) => scope.labels.contains(n), + LabelRef::Line(n) => scope.line_labels.contains(n), + }; + if !found { + self.err(pos, "Label not defined"); + } + } + + fn check_call( + &mut self, + name: &str, + args: &[Expr], + scope: &mut Scope, + pos: SourcePos, + ) { + if let Some(info) = self.procs.get(name).cloned() { + if info.kind != ProcKind::Sub { + self.err(pos, "Duplicate definition"); + return; + } + if args.len() != info.params.len() { + self.err(pos, "Argument-count mismatch"); + } + for (i, a) in args.iter().enumerate() { + let at = self.expr_ty(a, scope); + if let Some((pt, _)) = info.params.get(i) { + let ok = (is_num(pt) && is_num(&at)) + || (is_str(pt) && is_str(&at)) + || matches!(pt, Ty::Udt(_) | Ty::Unknown) + || at == Ty::Unknown; + if !ok { + self.err(a.pos(), "Parameter type mismatch"); + } + } + } + return; + } + if let Some((min, max, spec)) = builtin_stmt(name) { + self.check_builtin_args(name, args, min, max, spec, scope, pos); + return; + } + if banned_feature(name) { + for a in args { + self.expr_ty(a, scope); + } + self.err(pos, "Feature unavailable"); + return; + } + self.err(pos, "Subprogram not defined"); + } + + fn check_builtin_args( + &mut self, + name: &str, + args: &[Expr], + min: u8, + max: u8, + spec: &[ArgK], + scope: &mut Scope, + pos: SourcePos, + ) { + 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"); + } + // Sonderfall INSTR([start%,] s$, such$) + let insts_with_start = name == "INSTR" && args.len() == 3; + for (i, a) in args.iter().enumerate() { + if matches!(a, Expr::Missing) { + continue; + } + let at = self.expr_ty(a, scope); + let kind = if insts_with_start { + match i { + 0 => ArgK::N, + _ => ArgK::S, + } + } else if name == "INSTR" { + ArgK::S + } else { + *spec.get(i).unwrap_or(&ArgK::A) + }; + let ok = match kind { + ArgK::N => is_num(&at), + ArgK::S => is_str(&at), + ArgK::A => true, + }; + if !ok { + self.err(a.pos(), "Type mismatch"); + } + } + } + + // ---- Ausdrücke --------------------------------------------------------- + + fn want_num(&mut self, e: &Expr, scope: &mut Scope) { + let t = self.expr_ty(e, scope); + if !is_num(&t) { + self.err(e.pos(), "Type mismatch"); + } + } + fn want_str(&mut self, e: &Expr, scope: &mut Scope) { + let t = self.expr_ty(e, scope); + if !is_str(&t) { + self.err(e.pos(), "Type mismatch"); + } + } + + fn check_assign(&mut self, target: &Ty, value: &Ty, pos: SourcePos) { + let ok = (is_num(target) && is_num(value)) + || (is_str(target) && is_str(value)) + || matches!((target, value), (Ty::Udt(a), Ty::Udt(b)) if a == b) + || *target == Ty::Unknown + || *value == Ty::Unknown; + if !ok { + self.err(pos, "Type mismatch"); + } + } + + fn lvalue_ty(&mut self, e: &Expr, scope: &mut Scope) -> Ty { + match e { + Expr::Name { name, suffix, args, pos } => { + if let Some(idx) = args { + for a in idx { + self.want_num(a, scope); + } + self.resolve_var(scope, name, suffix, true, *pos) + } else { + self.resolve_var(scope, name, suffix, false, *pos) + } + } + _ => { + self.err(e.pos(), "Variable required"); + Ty::Unknown + } + } + } + + fn expr_ty(&mut self, e: &Expr, scope: &mut Scope) -> Ty { + match e { + Expr::IntLit(_) => Ty::Int, + Expr::LongLit(_) => Ty::Lng, + Expr::SingleLit(_) => Ty::Sng, + Expr::DoubleLit(_) => Ty::Dbl, + Expr::CurrencyLit(_) => Ty::Cur, + Expr::StrLit(_) => Ty::Str, + Expr::Missing => Ty::Unknown, + Expr::Unary { op, operand, pos } => { + let t = self.expr_ty(operand, scope); + if !is_num(&t) { + self.err(*pos, "Type mismatch"); + } + match op { + UnOp::Neg => t, + UnOp::Not => Ty::Lng, + } + } + Expr::Binary { op, lhs, rhs, pos } => { + let lt = self.expr_ty(lhs, scope); + let rt = self.expr_ty(rhs, scope); + match op { + BinOp::Add => { + if is_str(<) && is_str(&rt) { + Ty::Str + } else if is_num(<) && is_num(&rt) { + promote(<, &rt) + } else { + self.err(*pos, "Type mismatch"); + Ty::Unknown + } + } + BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt + | BinOp::Ge => { + let ok = (is_num(<) && is_num(&rt)) + || (is_str(<) && is_str(&rt)); + if !ok { + self.err(*pos, "Type mismatch"); + } + Ty::Int + } + BinOp::And | BinOp::Or | BinOp::Xor | BinOp::Eqv + | BinOp::Imp => { + if !is_num(<) || !is_num(&rt) { + self.err(*pos, "Type mismatch"); + } + Ty::Lng + } + BinOp::IntDiv | BinOp::Mod => { + if !is_num(<) || !is_num(&rt) { + self.err(*pos, "Type mismatch"); + } + Ty::Lng + } + BinOp::Div | BinOp::Pow => { + if !is_num(<) || !is_num(&rt) { + self.err(*pos, "Type mismatch"); + } + Ty::Dbl + } + BinOp::Mul | BinOp::Sub => { + if !is_num(<) || !is_num(&rt) { + self.err(*pos, "Type mismatch"); + } + promote(<, &rt) + } + } + } + Expr::Name { name, suffix, args, pos } => { + self.name_expr_ty(name, suffix, args, *pos, scope) + } + } + } + + fn name_expr_ty( + &mut self, + name: &str, + suffix: &Option, + args: &Option>, + pos: SourcePos, + scope: &mut Scope, + ) -> Ty { + let full_name = match suffix { + Some(s) => format!("{name}{}", s.as_char()), + None => name.to_string(), + }; + + // 1. Konstante + if let Some(t) = self.consts.get(name).cloned() { + if args.is_some() { + self.err(pos, "Syntax error"); + } + return t; + } + + match args { + Some(idx) => { + // 2. Deklariertes Array + let as_key = format!("{name}\u{1}AS"); + let key = self.var_key(name, suffix, false); + let existing = if suffix.is_none() { + scope.vars.get(&as_key).or_else(|| scope.vars.get(&key)) + } else { + scope.vars.get(&key) + }; + if let Some(v) = existing.cloned() { + if v.array { + for a in idx { + self.want_num(a, scope); + } + return v.ty; + } + self.err(pos, "Duplicate definition"); + return Ty::Unknown; + } + // 3. Nicht unterstützte Hardware-Features → Compile-Fehler + if banned_feature(&full_name) { + for a in idx { + self.expr_ty(a, scope); + } + self.err(pos, "Feature unavailable"); + return Ty::Unknown; + } + // 4. Builtin-Funktion + if let Some((min, max, spec, ret)) = builtin_fn(&full_name) { + self.check_builtin_args(&full_name, idx, min, max, spec, scope, pos); + return ret_ty(ret); + } + // 4. FUNCTION / DEF FN + if let Some(info) = self.procs.get(name).cloned() { + if info.kind == ProcKind::Function { + if idx.len() != info.params.len() { + self.err(pos, "Argument-count mismatch"); + } + for a in idx { + self.expr_ty(a, scope); + } + return info.ret; + } + self.err(pos, "Duplicate definition"); + return Ty::Unknown; + } + // 5. Implizites Array (klassisch: DIM x(10) implizit) + for a in idx { + self.want_num(a, scope); + } + if self.explicit { + self.err(pos, "Variable not defined"); + return self.name_ty(name, suffix); + } + let ty = self.name_ty(name, suffix); + let key = self.var_key(name, suffix, false); + scope.vars.insert( + key, + VarInfo { ty: ty.clone(), array: true, explicit: false }, + ); + ty + } + None => { + // 2. Parameterlose Builtins (RND, ERR, INKEY$, TIMER …) + let as_key = format!("{name}\u{1}AS"); + let key = self.var_key(name, suffix, false); + let declared = scope.vars.contains_key(&key) + || (suffix.is_none() && scope.vars.contains_key(&as_key)); + if !declared { + if let Some((0, _, _, ret)) = builtin_fn(&full_name) { + return ret_ty(ret); + } + if let Some(info) = self.procs.get(name).cloned() { + if info.kind == ProcKind::Function && info.params.is_empty() { + return info.ret; + } + } + } + // 3. Variable (ggf. implizit deklarieren) + self.resolve_var(scope, name, suffix, false, pos) + } + } + } +} + +/// Labels rekursiv einsammeln (Sprungziele gelten je Rumpf). +fn collect_labels(stmts: &[Stmt], scope: &mut Scope) { + for s in stmts { + match s { + Stmt::Label(n) => { + scope.labels.insert(n.clone()); + } + Stmt::LineNumber(n) => { + scope.line_labels.insert(*n); + } + Stmt::If { then_body, elseifs, else_body, .. } => { + collect_labels(then_body, scope); + for (_, b) in elseifs { + collect_labels(b, scope); + } + if let Some(b) = else_body { + collect_labels(b, scope); + } + } + Stmt::Select { arms, .. } => { + for a in arms { + collect_labels(&a.body, scope); + } + } + Stmt::For { body, .. } + | Stmt::DoLoop { body, .. } + | Stmt::While { body, .. } => collect_labels(body, scope), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analyze_source; + + fn diags(src: &str) -> Vec { + analyze_source("TEST", src) + .diagnostics + .into_iter() + .map(|d| d.message) + .collect() + } + + #[test] + fn typkonflikt_wird_erkannt() { + assert!(diags("a$ = 1").contains(&"Type mismatch".to_string())); + assert!(diags("a% = \"x\"").contains(&"Type mismatch".to_string())); + assert!(diags("a = 1 + \"x\"").contains(&"Type mismatch".to_string())); + assert!(diags("s$ = \"a\" + \"b\"").is_empty()); + } + + #[test] + fn option_explicit() { + assert!(diags("OPTION EXPLICIT\nx = 1") + .contains(&"Variable not defined".to_string())); + assert!(diags("OPTION EXPLICIT\nDIM x AS INTEGER\nx = 1").is_empty()); + } + + #[test] + fn deftype_regeln() { + // DEFSTR macht s zum String + assert!(diags("DEFSTR S\ns = 5").contains(&"Type mismatch".to_string())); + assert!(diags("DEFINT I\ni = 5").is_empty()); + } + + #[test] + fn unbekanntes_unterprogramm() { + assert!(diags("Foo 1").contains(&"Subprogram not defined".to_string())); + assert!(diags("SUB Foo (a%)\nEND SUB\n' Aufruf\nFoo 1").is_empty()); + } + + #[test] + fn argumentanzahl() { + assert!(diags("PRINT LEFT$(\"a\")") + .contains(&"Argument-count mismatch".to_string())); + assert!(diags("SUB Foo (a%, b%)\nEND SUB\nFoo 1") + .contains(&"Argument-count mismatch".to_string())); + } + + #[test] + fn hardware_features_zur_compilezeit_abgelehnt() { + assert!(diags("POKE 100, 1").contains(&"Feature unavailable".to_string())); + assert!(diags("x = PEEK(100)").contains(&"Feature unavailable".to_string())); + assert!(diags("p = VARPTR(a%)").contains(&"Feature unavailable".to_string())); + } + + #[test] + fn label_pruefung() { + assert!(diags("GOTO Nirwana").contains(&"Label not defined".to_string())); + assert!(diags("Ziel:\nGOTO Ziel").is_empty()); + assert!(diags("10 PRINT 1\nGOTO 10").is_empty()); + } + + #[test] + fn funktionsaufruf_und_rueckgabe() { + let d = diags( + "FUNCTION Quad (x)\nQuad = x * x\nEND FUNCTION\ny = Quad(3)", + ); + assert!(d.is_empty(), "{d:?}"); + } + + #[test] + fn arrays_implizit_und_explizit() { + assert!(diags("DIM a(10)\na(1) = 2\nPRINT a(1)").is_empty()); + assert!(diags("b(3) = 1").is_empty()); // implizites Array + assert!(diags("DIM c(5)\nDIM c(5)") + .contains(&"Duplicate definition".to_string())); + assert!(diags("REDIM d(5)\nREDIM d(9)").is_empty()); + } + + #[test] + fn builtins_typen() { + assert!(diags("PRINT LEN(\"abc\")").is_empty()); + assert!(diags("PRINT MID$(\"abc\", 2, 1)").is_empty()); + assert!(diags("PRINT CHR$(\"x\")").contains(&"Type mismatch".to_string())); + assert!(diags("x$ = INKEY$").is_empty()); + assert!(diags("t! = TIMER").is_empty()); + } +} diff --git a/crates/tb-frontend/tests/corpus.rs b/crates/tb-frontend/tests/corpus.rs new file mode 100644 index 0000000..2520282 --- /dev/null +++ b/crates/tb-frontend/tests/corpus.rs @@ -0,0 +1,27 @@ +//! Phase-1-Meilenstein: der komplette Kompatibilitäts-Testkorpus +//! (tests/compat/*.bas) muss fehlerfrei parsen und typgeprüft werden. + +use std::fs; +use std::path::Path; + +#[test] +fn korpus_parst_und_wird_typgeprueft() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat"); + let mut checked = 0; + for entry in fs::read_dir(&dir).expect("tests/compat fehlt") { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("bas") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let src = fs::read_to_string(&path).unwrap(); + let analysis = tb_frontend::analyze_source(&name, &src); + assert!( + analysis.diagnostics.is_empty(), + "{name}: {:?}", + analysis.diagnostics + ); + checked += 1; + } + assert!(checked >= 5, "zu wenige Korpusdateien gefunden: {checked}"); +} diff --git a/crates/tb-ide/Cargo.toml b/crates/tb-ide/Cargo.toml index 95bb41c..60c2e01 100644 --- a/crates/tb-ide/Cargo.toml +++ b/crates/tb-ide/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true authors.workspace = true [[bin]] -name = "tbdos" +name = "tb" path = "src/main.rs" [dependencies] diff --git a/crates/tb-ide/src/main.rs b/crates/tb-ide/src/main.rs index 117b517..06bb1ff 100644 --- a/crates/tb-ide/src/main.rs +++ b/crates/tb-ide/src/main.rs @@ -1,4 +1,4 @@ -//! `tbdos` — die integrierte Entwicklungsumgebung von Terminal Basic (TUI). +//! `tb` — die integrierte Entwicklungsumgebung von Terminal Basic (TUI). //! //! Nachbildung der klassischen DOS-IDE: Menüleiste, Editor mit //! Syntaxprüfung pro Zeile, Formular-Designer, Direktfenster, @@ -6,6 +6,6 @@ //! Siehe PLAN.md, Phase 5. fn main() -> anyhow::Result<()> { - println!("tbdos — Terminal Basic IDE (Projektrahmen, noch ohne Funktion)"); + println!("tb — Terminal Basic IDE (Projektrahmen, noch ohne Funktion)"); Ok(()) } diff --git a/crates/tb-ui/examples/spike.rs b/crates/tb-ui/examples/spike.rs index e0cea9f..ade3dfb 100644 --- a/crates/tb-ui/examples/spike.rs +++ b/crates/tb-ui/examples/spike.rs @@ -43,9 +43,10 @@ fn testbild(s: &mut TextScreen) { fn status(s: &mut TextScreen, text: &str) { let (r, c) = (s.csrlin(), s.pos()); + let (row, width) = (s.rows(), s.cols()); s.set_color(0, 7); - s.locate(25, 1).unwrap(); - s.print(&format!("{text:<80.80}")); + s.locate(row, 1).unwrap(); + s.print(&format!("{text: anyhow::Result<()> { let mut terminal = ratatui::init(); execute!(stdout(), EnableMouseCapture)?; - let mut screen = TextScreen::new(); + let (tw, th) = crossterm::terminal::size()?; + let mut screen = TextScreen::with_size(tw as usize, th as usize); testbild(&mut screen); status(&mut screen, "Bereit. Tasten/Maus testen, Esc beendet."); @@ -79,7 +81,10 @@ fn main() -> anyhow::Result<()> { &format!("Maus: {:?} bei Spalte {}, Zeile {}", m.kind, m.column + 1, m.row + 1), ); } - Event::Resize(w, h) => status(&mut screen, &format!("Resize: {w}x{h}")), + Event::Resize(w, h) => { + screen.resize(w as usize, h as usize); + status(&mut screen, &format!("Resize: {w}x{h}")); + } _ => {} } } diff --git a/crates/tb-ui/src/screen.rs b/crates/tb-ui/src/screen.rs index 4c9049e..88b536b 100644 --- a/crates/tb-ui/src/screen.rs +++ b/crates/tb-ui/src/screen.rs @@ -1,8 +1,12 @@ -//! Textbildschirm-Emulation: 80×25-Zellenpuffer (Unicode-Zeichen + Farb- -//! attribut), Cursor, Scrollen — gerendert als Ratatui-Widget. +//! Textbildschirm-Emulation: Zellenpuffer (Unicode-Zeichen + Farbattribut), +//! Cursor, Scrollen — gerendert als Ratatui-Widget. +//! +//! Entscheidungen (siehe PLAN.md): +//! - durchgängig Unicode, keine CP437-Emulation +//! - **dynamische Größe**: der Bildschirm folgt der Terminalgröße; unterhalb +//! der Mindestgröße (80×25) wird nur ein Hinweis gerendert (btop-artig), +//! größere Terminals werden voll genutzt. //! -//! Der Puffer ist die Grundlage für `PRINT`, `LOCATE`, `COLOR`, `CLS` usw. -//! Entscheidung (siehe PLAN.md): durchgängig Unicode, keine CP437-Emulation. //! Das Zellenmodell ist strikt 1 Zeichen = 1 Zelle; Zeichen mit //! Darstellungsbreite ≠ 1 sind eine offene Frage (PLAN.md). @@ -11,8 +15,9 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::widgets::Widget; -pub const COLS: usize = 80; -pub const ROWS: usize = 25; +/// Mindestgröße; darunter wird nur ein Hinweis angezeigt. +pub const MIN_COLS: usize = 80; +pub const MIN_ROWS: usize = 25; /// Eine Bildschirmzelle: Zeichen plus klassisches Farbattribut. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,12 +42,14 @@ pub fn basic_color(n: u8) -> Color { Color::Indexed(MAP[(n & 0x0F) as usize]) } -/// Der emulierte 80×25-Textbildschirm. +/// Der emulierte Textbildschirm mit dynamischer Größe. /// -/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile 1–25, -/// Spalte 1–80), wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts. +/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile, Spalte), +/// wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts. #[derive(Debug, Clone)] pub struct TextScreen { + cols: usize, + rows: usize, cells: Vec, /// Cursorposition, 0-basiert intern. cur_row: usize, @@ -54,6 +61,9 @@ pub struct TextScreen { /// Scrollbereich (`VIEW PRINT`), 0-basiert inklusiv. view_top: usize, view_bottom: usize, + /// true, solange kein eigenes `VIEW PRINT` gesetzt ist — der + /// Scrollbereich folgt dann der Bildschirmgröße. + view_full: bool, } impl Default for TextScreen { @@ -63,16 +73,63 @@ impl Default for TextScreen { } impl TextScreen { + /// Bildschirm in Mindestgröße (80×25). pub fn new() -> Self { + Self::with_size(MIN_COLS, MIN_ROWS) + } + + /// Bildschirm in gegebener Größe (wird auf die Mindestgröße angehoben). + pub fn with_size(cols: usize, rows: usize) -> Self { + let cols = cols.max(MIN_COLS); + let rows = rows.max(MIN_ROWS); TextScreen { - cells: vec![Cell::default(); COLS * ROWS], + cols, + rows, + cells: vec![Cell::default(); cols * rows], cur_row: 0, cur_col: 0, cursor_visible: true, fg: 7, bg: 0, view_top: 0, - view_bottom: ROWS - 1, + view_bottom: rows - 1, + view_full: true, + } + } + + pub fn cols(&self) -> usize { + self.cols + } + pub fn rows(&self) -> usize { + self.rows + } + + /// An neue Terminalgröße anpassen: Inhalt bleibt oben links erhalten, + /// neue Zellen sind leer. Unterhalb der Mindestgröße bleibt der Puffer + /// bei 80×25 (das Widget zeigt dann den Zu-klein-Hinweis). + pub fn resize(&mut self, cols: usize, rows: usize) { + let cols = cols.max(MIN_COLS); + let rows = rows.max(MIN_ROWS); + if cols == self.cols && rows == self.rows { + return; + } + let mut cells = vec![Cell { ch: ' ', fg: self.fg, bg: self.bg }; cols * rows]; + for row in 0..self.rows.min(rows) { + for col in 0..self.cols.min(cols) { + cells[row * cols + col] = self.cells[row * self.cols + col]; + } + } + self.cells = cells; + self.cols = cols; + self.rows = rows; + self.cur_row = self.cur_row.min(rows - 1); + self.cur_col = self.cur_col.min(cols - 1); + if self.view_full { + self.view_top = 0; + self.view_bottom = rows - 1; + } else { + self.view_top = self.view_top.min(rows - 1); + self.view_bottom = self.view_bottom.min(rows - 1); } } @@ -81,23 +138,24 @@ impl TextScreen { pub fn cls(&mut self) { let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg }; for row in self.view_top..=self.view_bottom { - self.cells[row * COLS..(row + 1) * COLS].fill(blank); + self.cells[row * self.cols..(row + 1) * self.cols].fill(blank); } self.cur_row = self.view_top; self.cur_col = 0; } - /// `COLOR vg, hg` — Werte werden wie im Vorbild maskiert - /// (vg 0–31, wir ignorieren Blink → 0–15; hg 0–7). + /// `COLOR vg, hg` (vg 0–31, hg 0–7). Blinkende Vordergrundfarben + /// (16–31) werden als „hell" simuliert (Entscheidung 2026-09-02): + /// echtes Terminal-Blinken ist nicht überall verfügbar. pub fn set_color(&mut self, fg: u8, bg: u8) { + let fg = if fg >= 16 { (fg & 0x0F) | 8 } else { fg }; self.fg = fg & 0x0F; self.bg = bg & 0x07; } - /// `LOCATE zeile, spalte` (1-basiert). Außerhalb des Bildschirms: - /// Fehler 5 beim Aufrufer — hier wird geklemmt geprüft. + /// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5). pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> { - if row < 1 || row > ROWS || col < 1 || col > COLS { + if row < 1 || row > self.rows || col < 1 || col > self.cols { return Err(()); } self.cur_row = row - 1; @@ -117,32 +175,32 @@ impl TextScreen { /// `VIEW PRINT oben TO unten` (1-basiert). pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ()> { - if top < 1 || bottom > ROWS || top > bottom { + if top < 1 || bottom > self.rows || top > bottom { return Err(()); } self.view_top = top - 1; self.view_bottom = bottom - 1; + self.view_full = top == 1 && bottom == self.rows; Ok(()) } pub fn cell(&self, row: usize, col: usize) -> Cell { - self.cells[(row - 1) * COLS + (col - 1)] + self.cells[(row - 1) * self.cols + (col - 1)] } /// Text an der Cursorposition ausgeben: Umbruch am rechten Rand, /// Scrollen am unteren Rand des Scrollbereichs. `\n` bricht um, - /// `\r` setzt an den Zeilenanfang; andere Steuerzeichen werden - /// (noch) als normale Zeichen behandelt. + /// `\r` setzt an den Zeilenanfang. pub fn print(&mut self, text: &str) { for ch in text.chars() { match ch { '\n' => self.newline(), '\r' => self.cur_col = 0, _ => { - self.cells[self.cur_row * COLS + self.cur_col] = + self.cells[self.cur_row * self.cols + self.cur_col] = Cell { ch, fg: self.fg, bg: self.bg }; self.cur_col += 1; - if self.cur_col >= COLS { + if self.cur_col >= self.cols { self.newline(); } } @@ -165,34 +223,34 @@ impl TextScreen { pub fn scroll_up(&mut self) { let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg }; for row in self.view_top..self.view_bottom { - let (a, b) = self.cells.split_at_mut((row + 1) * COLS); - a[row * COLS..].copy_from_slice(&b[..COLS]); + let (a, b) = self.cells.split_at_mut((row + 1) * self.cols); + a[row * self.cols..].copy_from_slice(&b[..self.cols]); } - self.cells[self.view_bottom * COLS..(self.view_bottom + 1) * COLS].fill(blank); + self.cells[self.view_bottom * self.cols..(self.view_bottom + 1) * self.cols] + .fill(blank); } } -/// Rendert den Bildschirm zentriert (Letterboxing) in die verfügbare Fläche. -/// Ist das Terminal kleiner als 80×25, wird ein Hinweis angezeigt. +/// Rendert den Bildschirm oben links in die verfügbare Fläche. Ist das +/// Terminal kleiner als die Mindestgröße, erscheint nur ein Hinweis +/// (btop-artig); die Anwendung ruft bei Resize `TextScreen::resize` auf, +/// damit Puffer und Terminal deckungsgleich bleiben. impl Widget for &TextScreen { fn render(self, area: Rect, buf: &mut Buffer) { - if (area.width as usize) < COLS || (area.height as usize) < ROWS { + if (area.width as usize) < MIN_COLS || (area.height as usize) < MIN_ROWS { let msg = format!( - "Terminal zu klein: {}x{} — benötigt {}x{}", - area.width, area.height, COLS, ROWS + "Terminal zu klein: {}x{} — Minimum {}x{}", + area.width, area.height, MIN_COLS, MIN_ROWS ); - if let Some(cell) = buf.cell_mut((area.x, area.y)) { - cell.set_symbol(" "); - } buf.set_string(area.x, area.y, msg, Style::default().fg(Color::Red)); return; } - let x0 = area.x + (area.width - COLS as u16) / 2; - let y0 = area.y + (area.height - ROWS as u16) / 2; - for row in 0..ROWS { - for col in 0..COLS { - let c = self.cells[row * COLS + col]; - if let Some(cell) = buf.cell_mut((x0 + col as u16, y0 + row as u16)) { + let cols = self.cols.min(area.width as usize); + let rows = self.rows.min(area.height as usize); + for row in 0..rows { + for col in 0..cols { + let c = self.cells[row * self.cols + col]; + if let Some(cell) = buf.cell_mut((area.x + col as u16, area.y + row as u16)) { let mut s = String::new(); s.push(c.ch); cell.set_symbol(&s); @@ -204,10 +262,12 @@ impl Widget for &TextScreen { } // Cursor als invertierte Zelle darstellen (Terminal-Cursor wird in // der Forms-/Runtime-Schicht später gezielt gesteuert). - if self.cursor_visible { - let (cx, cy) = (x0 + self.cur_col as u16, y0 + self.cur_row as u16); + if self.cursor_visible && self.cur_row < rows && self.cur_col < cols { + let (cx, cy) = (area.x + self.cur_col as u16, area.y + self.cur_row as u16); if let Some(cell) = buf.cell_mut((cx, cy)) { - cell.set_style(cell.style().add_modifier(ratatui::style::Modifier::REVERSED)); + cell.set_style( + cell.style().add_modifier(ratatui::style::Modifier::REVERSED), + ); } } } @@ -248,7 +308,7 @@ mod tests { let mut s = TextScreen::new(); s.locate(25, 1).unwrap(); s.print("unten\n"); // erzwingt Scroll - assert_eq!(s.cell(24, 1).ch, 'u'); // Zeile 25 ist nach 24 gerutscht + assert_eq!(s.cell(24, 1).ch, 'u'); assert_eq!(s.cell(25, 1).ch, ' '); assert_eq!(s.csrlin(), 25); } @@ -261,7 +321,7 @@ mod tests { s.view_print(3, 5).unwrap(); s.locate(5, 1).unwrap(); s.print("a\nb"); // scrollt nur Zeilen 3–5 - assert_eq!(s.cell(1, 1).ch, 'k'); // Kopfzeile unberührt + assert_eq!(s.cell(1, 1).ch, 'k'); assert_eq!(s.cell(4, 1).ch, 'a'); assert_eq!(s.cell(5, 1).ch, 'b'); } @@ -274,6 +334,47 @@ mod tests { assert!(s.locate(25, 80).is_ok()); } + #[test] + fn dynamische_groesse_und_resize() { + let mut s = TextScreen::with_size(120, 40); + assert_eq!((s.cols(), s.rows()), (120, 40)); + s.locate(40, 1).unwrap(); + s.print("!"); + assert_eq!(s.cell(40, 1).ch, '!'); + + // Verkleinern: Inhalt oben links bleibt, Cursor wird geklemmt + s.locate(1, 1).unwrap(); + s.print("K"); + s.resize(100, 30); + assert_eq!((s.cols(), s.rows()), (100, 30)); + assert_eq!(s.cell(1, 1).ch, 'K'); + + // Unter Minimum wird auf 80×25 geklemmt + s.resize(10, 5); + assert_eq!((s.cols(), s.rows()), (80, 25)); + } + + #[test] + fn blink_wird_als_hell_simuliert() { + let mut s = TextScreen::new(); + s.set_color(17, 0); // blinkend Blau → helles Blau + assert_eq!(s.fg, 9); + s.set_color(31, 0); // blinkend Hellweiß → Hellweiß + assert_eq!(s.fg, 15); + s.set_color(7, 0); + assert_eq!(s.fg, 7); + } + + #[test] + fn resize_folgt_vollem_scrollbereich() { + let mut s = TextScreen::new(); + s.resize(90, 40); + s.locate(40, 1).unwrap(); + s.print("a\nb"); // Scroll am neuen unteren Rand + assert_eq!(s.cell(39, 1).ch, 'a'); + assert_eq!(s.cell(40, 1).ch, 'b'); + } + #[test] fn farbabbildung() { assert_eq!(basic_color(1), Color::Indexed(4)); // klassisch Blau diff --git a/docs/dateiformate.md b/docs/dateiformate.md index 3bfdfb2..e93f5d9 100644 --- a/docs/dateiformate.md +++ b/docs/dateiformate.md @@ -61,8 +61,16 @@ Reine Textdatei ohne Kopfzeile, **eine Projektdatei pro Zeile** (`.BAS`/`.FRM`; belegt durch die Originalbeispiele). Terminal Basic akzeptiert zusätzlich Kommentarzeilen mit `'`. +## Record-Dateien (`GET`/`PUT`, `OPEN … FOR RANDOM`) + +Feste Strings (`STRING * n`) in Records werden als **UTF-32LE** gespeichert +(4 Bytes pro Zeichen → feste Record-Länge bleibt erhalten). Entscheidung +2026-09-02: Die Binärdateien sind damit bewusst inkompatibel zu Dateien +des Vorbilds; numerische Felder behalten ihr klassisches Layout +(INTEGER i16, LONG i32, SINGLE f32, DOUBLE f64, CURRENCY i64, little-endian). + ## Kompilat: `.tbc` (neu, eigenes Format) Container für TBVM-Bytecode, Entwurf in [tbvm-design.md](tbvm-design.md). -`tbdosc build` erzeugt wahlweise `.tbc` oder ein eigenständiges Executable +`tbc build` erzeugt wahlweise `.tbc` oder ein eigenständiges Executable (Runner + eingebettetes `.tbc`). diff --git a/docs/forms-referenz.md b/docs/forms-referenz.md index e931b8a..c6f187f 100644 --- a/docs/forms-referenz.md +++ b/docs/forms-referenz.md @@ -139,8 +139,9 @@ DragOver-State 0 Enter · 1 Leave · 2 Over. Bei Steuerelement-Arrays steht ## SCREEN-Objekt Eigenschaften: ActiveControl, ActiveForm, AutoRedraw, ControlPanel, -Height, Width (Zeichen, read-only), MousePointer. Methoden: HIDE, SHOW -(alle sichtbaren Formulare ein-/ausblenden). `SCREEN.ControlPanel(0–17)` +Height, Width (Zeichen, read-only — bei uns die **aktuelle Terminalgröße**, +Abweichung: das Vorbild lieferte fest 25/43/50 × 80), MousePointer. +Methoden: HIDE, SHOW (alle sichtbaren Formulare ein-/ausblenden). `SCREEN.ControlPanel(0–17)` konfiguriert Systemfarben/-effekte (AccessKey-/Desktop-/Menü-/Titel- Farben, Schatten, 3D-Effekte, Desktop-Füllzeichen) — Konstanten in der Konstanten-Include-Datei. diff --git a/docs/ide-referenz.md b/docs/ide-referenz.md index e9d4d29..c162820 100644 --- a/docs/ide-referenz.md +++ b/docs/ide-referenz.md @@ -149,7 +149,7 @@ Die Umgebung führt P-Code-übersetzte Zeilen sofort aus; der separate Compiler erzeugt native EXEs (wahlweise standalone oder mit Runtime-Modul). Nur die Umgebung hat: Immediate Window, Watches/Watchpoints, Breakpoints, Stepping, History-Trace, Set Next Statement, automatische Bereichs-/ -Überlaufprüfungen. → Terminal Basic: `tbdos` (IDE) und `tbdosc` +Überlaufprüfungen. → Terminal Basic: `tb` (IDE) und `tbc` (Standalone-Compiler) teilen sich dieselbe TBVM, damit es — anders als im Vorbild — **keine** Verhaltensunterschiede zwischen IDE-Lauf und kompiliertem Programm gibt (dokumentierte Abweichung, gewollt). @@ -162,6 +162,9 @@ kompiliertem Programm gibt (dokumentierte Abweichung, gewollt). F12/Shift+F12-Wechsel bleibt erhalten. 3. Zeilenweises Syntax-Checking mit Formatierung/Normalisierung ist Editor-Standard (abschaltbar wie im Vorbild). -4. IDE-Lauf und `tbdosc`-Kompilat verhalten sich identisch (gleiche TBVM). +4. IDE-Lauf und `tbc`-Kompilat verhalten sich identisch (gleiche TBVM). 5. MS-DOS-spezifische Toolbox-Controls (Dir/Drive/File List) werden als plattformneutrale Datei-Browser-Controls nachgebildet. +6. **Dynamische Terminalgröße** (2026-09-02): die IDE nutzt das gesamte + Terminal — Desktop, Fensterverwaltung und Formulare passen sich an; + Mindestgröße 80×25, darunter nur ein Hinweis (btop-artig). diff --git a/docs/sprachreferenz.md b/docs/sprachreferenz.md index a3a7352..9be7405 100644 --- a/docs/sprachreferenz.md +++ b/docs/sprachreferenz.md @@ -11,7 +11,11 @@ Details, `ener.hlp` Fehlermeldungen; Vergleich PDS 7.1: `bas7qck.hlp`) sowie das Original-README der Professional Edition. Aussagen ohne TODO-Markierung sind daraus belegt. -**Bewusste Abweichungen vom Vorbild** stehen am Ende des Dokuments. +**Guiding Principle:** Die Original-Dokumentation führt. Jedes dort +dokumentierte Sprachelement wird unterstützt oder — nur nach expliziter +Non-Feature-Entscheidung des Projektinhabers — mit einer klaren +Fehlermeldung abgewiesen. **Bewusste Abweichungen und Non-Features** +stehen am Ende dieses Dokuments; nur diese Liste rechtfertigt Abweisungen. --- @@ -40,10 +44,12 @@ TODO-Markierung sind daraus belegt. hexadezimal `&HFF`, oktal `&O777`; Suffixe erzwingen den Typ (`10%`, `10&`, `1.5!`, `1.5#`, `2.5@`). Ohne Suffix gilt: Ganzzahl im INTEGER-Bereich → INTEGER; Hex/Oktal ohne `&`-Suffix → 16 Bit (INTEGER), mit `&` → LONG; - `E`-Exponent → SINGLE, `D`-Exponent → DOUBLE; Dezimalpunkt-Wert → - SINGLE, bei mehr als 15 Stellen DOUBLE (TODO: exakte Schwelle — Quelle - an der Stelle widersprüchlich); Ganzzahl außerhalb des LONG-Bereichs - wird Gleitkommawert (kein Fehler). + `E`-Exponent → SINGLE, `D`-Exponent → DOUBLE; Dezimalpunkt-Wert: + **Entscheidung (2026-09-02):** bis 7 signifikante Stellen → SINGLE, + darüber → DOUBLE. (Die Original-Hilfe nennt widersprüchlich eine + 15-Stellen-Schwelle, die SINGLE-Präzision verlöre; wir legen die Schwelle + auf die SINGLE-Genauigkeit — präzisionserhaltende Auslegung.) + Ganzzahl außerhalb des LONG-Bereichs wird Gleitkommawert (kein Fehler). - **String-Literale:** `"…"`; doppeltes `""` ergibt ein Anführungszeichen. ## 2. Typsystem @@ -188,9 +194,12 @@ solange ein Menü-Control den Fokus hat. Für Terminal Basic relevant: - `INKEY$` (nicht blockierend; "" wenn leer; erweiterte Tasten: 2-Zeichen-Sequenz `CHR$(0)+code` im Vorbild — Abweichung s. u.), `INPUT$(n [,#f])`. -- `LOCATE [zeile][,spalte][,cursor an/aus][,start,ende]`, `CSRLIN`, `POS(0)`. -- `COLOR [vg][,hg]` (vg 0–31: 16–31 = blinkend; hg 0–7), `CLS`, - `WIDTH` (80/40 — wir: nur 80), `VIEW PRINT oben TO unten` (Scrollbereich). +- `LOCATE [zeile][,spalte][,cursor an/aus][,start,ende]`, `CSRLIN`, `POS(0)` + — arbeiten auf der tatsächlichen Bildschirmgröße (dynamisch, s. Abweichungen). +- `COLOR [vg][,hg]` (vg 0–31: 16–31 = blinkend — bei uns als „hell" + simuliert, Entscheidung 2026-09-02; hg 0–7), `CLS`, + `WIDTH` (im Vorbild 80/40 — bei uns wirkungslos, s. Abweichungen), + `VIEW PRINT oben TO unten` (Scrollbereich). - `TAB(n)`, `SPC(n)` in PRINT-Listen. - `BEEP`, `SOUND freq, dauer` (Terminal-Bell / Nicht-Ziel, s. Abweichungen), `PLAY` (Nicht-Ziel). @@ -211,6 +220,9 @@ solange ein Menü-Control den Fokus hat. Für Terminal Basic relevant: `LOC(n)`, `LOF(n)`. - Verwaltung: `NAME alt$ AS neu$`, `KILL datei$`, `FILES [muster$]`, `CHDIR`, `MKDIR`, `RMDIR`, `FILEATTR`, `FREEFILE`. +- **ISAM** (Professional-Edition des Vorbilds): wird unterstützt + (Entscheidung 2026-09-02) — `OPEN … FOR ISAM`, Tabellen/Indizes, + `SEEKGT`-Familie usw.; Umfang wird mit dem Inventar (Phase 3) erfasst. - Pfade: plattformneutral; `/` und `\` werden akzeptiert. ## 11. Eingebaute Funktionen (Katalog) @@ -267,9 +279,15 @@ Dialoge: - String-Vergleich codepoint-weise (keine CP437-Sortierung). - Der Bildschirmpuffer speichert Unicode-Zeichen; Rahmen werden mit Unicode-Box-Drawing gezeichnet. - - Zeichen mit Darstellungsbreite ≠ 1: offen (siehe PLAN.md). -2. **Keine Hardware-Nähe:** `PEEK`/`POKE`/`INP`/`OUT`/`CALL ABSOLUTE`/ - Interrupts lösen Fehler 73 (Feature unavailable) aus. + - Zeichen mit Darstellungsbreite 2 (Emoji, CJK) **belegen zwei Zellen** + (Entscheidung 2026-09-02; Umsetzung Phase 3). + - `GET`/`PUT`-Records: feste Strings (`STRING * n`) werden als **UTF-32** + gespeichert (4 Bytes/Zeichen, feste Record-Länge) — Binärdateien sind + dadurch bewusst inkompatibel zum Vorbild (Entscheidung 2026-09-02). +2. **Keine Hardware-Nähe** (bestätigt 2026-09-02): `PEEK`/`POKE`/`INP`/ + `OUT`/`WAIT`/`BLOAD`/`BSAVE`/`VARPTR`/`VARSEG`/`SADD`/`CALL ABSOLUTE`/ + `CALL INTERRUPT`/`IOCTL` werden bereits **zur Compile-Zeit** mit der + Meldung „Feature unavailable" abgewiesen (kein Laufzeitfehler). 3. **`INKEY$` für erweiterte Tasten** liefert weiterhin `CHR$(0) + code`-Sequenzen mit den klassischen Scancodes (F1 = `CHR$(0)+";"` usw.), damit bestehender Code funktioniert. Zusätzliche moderne Tasten: @@ -282,4 +300,8 @@ Dialoge: „Invalid when forms are showing") — wir übernehmen nur den Forms-/Textmodus-Zweig. 6. **`PLAY`/`SOUND`**: `BEEP` = Terminal-Bell; Rest Nicht-Ziel (Fehler 73). -7. **`WIDTH 40`** wird nicht unterstützt (nur 80×25). +7. **Dynamische Bildschirmgröße** (2026-09-02): kein festes 80×25 — der + Bildschirm folgt der Terminalgröße (Mindestgröße 80×25, darunter nur + ein Hinweis). `CSRLIN`/`POS`/`LOCATE`/`VIEW PRINT` und das + `SCREEN`-Objekt arbeiten auf der tatsächlichen Größe. `WIDTH 40/80` + wird akzeptiert, ändert aber nichts (die Größe bestimmt das Terminal). diff --git a/docs/tbvm-design.md b/docs/tbvm-design.md index 01534a4..95fce2c 100644 --- a/docs/tbvm-design.md +++ b/docs/tbvm-design.md @@ -1,7 +1,7 @@ # TBVM — Designentwurf Eigene Stack-basierte Bytecode-VM (Entscheidung siehe PLAN.md, 0.2). -Die VM ist in `tbdos` (IDE) und in von `tbdosc` erzeugte Executables +Die VM ist in `tb` (IDE) und in von `tbc` erzeugte Executables eingebettet — identischer Code, identisches Verhalten, schneller Turnaround. ## Werte-Modell @@ -72,7 +72,7 @@ Format bleibt stabil und dokumentiert). ## Eigenständige Executables -`tbdosc build --exe` kopiert den vorkompilierten Runner (dieselbe +`tbc build --exe` kopiert den vorkompilierten Runner (dieselbe tb-vm/tb-runtime/tb-ui-Bibliothek wie die IDE) und hängt das `.tbc` als Ressource an (Anhängen ans Binary + Fußzeile mit Offset/Magic; portabel für alle drei Plattformen). Alternative — `include_bytes!` + Cargo-Build beim diff --git a/tests/compat/README.md b/tests/compat/README.md index 2073d24..0bfa171 100644 --- a/tests/compat/README.md +++ b/tests/compat/README.md @@ -9,7 +9,7 @@ Editoren dürfen sie nicht wegtrimmen; `.gitattributes` schützt die Dateien vor Zeilenenden-Konvertierung. Ab Phase 2 führt ein Test-Harness (`cargo test`) jede Datei per -`tbdosc run` aus und vergleicht die Ausgabe. Bis dahin dienen die +`tbc run` aus und vergleicht die Ausgabe. Bis dahin dienen die `.out`-Dateien als festgehaltene Verhaltensspezifikation — wo möglich am Vorbild (Emulator) verifiziert, sonst nach Referenzlage rekonstruiert und mit `TODO verify` im Programmkopf markiert.