diff --git a/Cargo.lock b/Cargo.lock index 15e6d0e..468b714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,15 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -614,6 +623,7 @@ version = "0.1.0" dependencies = [ "jiff", "log", + "redb", "thiserror", "unicode-width 0.2.0", ] diff --git a/PLAN.md b/PLAN.md index 5b93f8e..5be99e7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -22,6 +22,12 @@ 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. + **Das gilt auch gegenüber den eigenen Spezifikationen** (2026-09-04): Weicht + eine Delta-Spec bei Semantik oder Syntax vom Vorbild ab, ohne dass eine + Plattformkollision sie dazu zwingt, wird die **Spec** angeglichen, nicht das + Vorbild nachgebildet. Befund aus `phase-3-isam`, wo drei Festlegungen + (Cursorlage nach `SETINDEX` und nach `DELETE`, Fehlercode der Suche über den + NULL-Index) auf die Original-Hilfe zurückgeführt wurden. - **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 @@ -247,8 +253,8 @@ Druckzonen sowie die Korpus-Stringfunktionen (`tb-runtime`). ## Phase 3 — Laufzeitbibliothek (`tb-runtime`) und Bildschirm -**Status: Kern abgeschlossen (2026-09-04), ISAM offen.** Umgesetzt im -OpenSpec-Change `phase-3-runtime-bildschirm`; der Rest steckt im Change +**Status: abgeschlossen (2026-09-04).** Umgesetzt in den OpenSpec-Changes +`phase-3-runtime-bildschirm` (Kern), `phase-3-ortszeit` und `phase-3-isam`, mit dem Phase 3 endet. Ziel ist die **vollständige** Standardbibliothek des Vorbilds (siehe @@ -260,9 +266,9 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. `qb45advr.hlp` mit Status und Fundstelle. Ein Test (`crates/tb-frontend/tests/inventar.rs`) hält die Tabelle in beide Richtungen gegen den Code — der Abdeckungsstand kann nicht mehr - veralten. **Stand: 195 implementiert · 37 offen · 53 Non-Feature.** - Die 37 offenen sind 22 ISAM (`phase-3-isam`), 13 Forms und - Ereignissteuerung (Phase 4), `RUN` und `$INCLUDE` (Phase 5). + veralten. **Stand: 217 implementiert · 15 offen · 53 Non-Feature.** + Die 15 offenen sind 13 Forms und Ereignissteuerung (Phase 4) sowie + `RUN` und `$INCLUDE` (Phase 5). - [x] **Bildschirmpuffer in die Laufzeit** (Entscheidung 2026-09-03): `Cell`/`TextScreen` liegen ratatui-frei in `tb-runtime::screen`; `tb-ui` behält Farbabbildung, Widget und den Terminal-Host. Damit @@ -327,9 +333,14 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. alle 22 Korpusprogramme laufen ohne Laufzeitfehler 73; der Fehler tritt nur noch bei bewusst offenen Elementen auf (ISAM, Forms, Ereignissteuerung). -- [ ] **ISAM** (Change `phase-3-isam`): `OPEN … FOR ISAM`, Indizes, - Cursor, Satzoperationen, Transaktionen. Speicherschicht `redb` - (Entscheidung 2026-09-03). Damit endet Phase 3. +- [x] **ISAM** (Change `phase-3-isam`): `OPEN … FOR ISAM`, Indizes + (ein- und mehrspaltig, eindeutig/mehrdeutig, auf-/absteigend), + Cursor mit `MOVE`- und `SEEK`-Familie, Satzoperationen, + Transaktionen mit Sicherungspunkten und die Fehler 81–89. + Speicherschicht `redb` (Entscheidung 2026-09-03), ISAM-Semantik + und ordnungserhaltende Schlüsselkodierung in `tb-runtime::isam`. + Alle 22 ISAM-Elemente des Inventars sind damit implementiert. + **Damit endet Phase 3.** **Befunde dieser Phase, die den Plan korrigiert haben** @@ -353,8 +364,29 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. Kalenderrechnung bleibt Eigenimplementierung. - `LPRINT` schreibt am Programmende nach `LPT1.TXT` im Arbeitsverzeichnis; einen plattformübergreifenden Druckerkanal gibt es nicht. -- Neue Abhängigkeiten: `unicode-width` (Doppelzellen) und `jiff` - (Zeitzonenversatz). Für ISAM kommt `redb` hinzu. +- Neue Abhängigkeiten: `unicode-width` (Doppelzellen), `jiff` + (Zeitzonenversatz) und `redb` (ISAM-Speicherschicht, auf + `tb-runtime::isam` beschränkt). +- Die Original-Hilfe kennt bei `CREATEINDEX` **keine** Sortierrichtung; + absteigende Indexspalten sind die einzige dokumentierte Erweiterung + (`-` vor dem Spaltennamen), festgehalten in der Sprachreferenz. Die + Cursor-Semantik folgt sonst durchweg dem Vorbild: die Delta-Spec hatte + nach `SETINDEX` und `DELETE` zunächst einen unpositionierten Cursor + vorgesehen und wurde am 2026-09-04 an die Original-Hilfe angeglichen + (siehe Leitplanke „Referenzverhalten schlägt Eleganz"). +- Die Argumentformen der ISAM-Elemente stammen erstmals aus den + **Einzelseiten** der Original-Hilfe, nicht mehr nur aus der Themenliste. + Das korrigierte eine Annahme des Entwurfs: die Spaltenliste von + `CREATEINDEX` ist keine Zeichenkette mit Trennzeichen, sondern eine Folge + einzelner Stringargumente. Belegt in der Change-Notiz + `umfang-und-signaturen.md`; für spätere Phasen heißt das: die Themenliste + klärt den *Umfang*, die Einzelseiten klären die *Form*. +- `CLOSE` beendet **keine** laufende ISAM-Transaktion (Befund der + Verifikation, 2026-09-04). Anfangs schrieb es sie fest, womit das + Schließen irgendeiner Dateinummer die Transaktion aller anderen mit + beendete. Über das Ende einer Transaktion entscheiden allein + `COMMITTRANS` und `ROLLBACK ALL`; eine beim Programmende offene + Transaktion verfällt. ## Phase 4 — Forms-Engine (`tb-ui::forms`) - [ ] Eingangsaufgabe (aus Phase 0 übernommen): Ereignisschleifen- diff --git a/crates/tb-frontend/src/ast.rs b/crates/tb-frontend/src/ast.rs index f0b636f..10b9917 100644 --- a/crates/tb-frontend/src/ast.rs +++ b/crates/tb-frontend/src/ast.rs @@ -355,3 +355,36 @@ pub struct Module { pub body: Vec, pub procs: Vec, } + +/// Sentinel-Kennung für `ROLLBACK ALL`. Echte Sicherungspunkte zählen ab 1, +/// `0` bedeutet „letzter Sicherungspunkt bzw. Transaktionsbeginn" — der +/// negative Wert kann deshalb mit keiner Kennung kollidieren. +pub const ROLLBACK_ALL: i32 = -1; + +/// ISAM-Anweisungen: Namen, die der Parser als Anweisung mit optionalem +/// `#` vor der Dateinummer liest. Die zugehörigen Funktionen (`GETINDEX$`, +/// `SAVEPOINT`, `SETMEM`, `BOF`) stehen nicht hier — sie sind gewöhnliche +/// Ausdrücke. +pub fn ist_isam_anweisung(name: &str) -> bool { + matches!( + name, + "CREATEINDEX" + | "DELETEINDEX" + | "SETINDEX" + | "INSERT" + | "RETRIEVE" + | "UPDATE" + | "DELETE" + | "DELETETABLE" + | "MOVEFIRST" + | "MOVELAST" + | "MOVENEXT" + | "MOVEPREVIOUS" + | "SEEKEQ" + | "SEEKGT" + | "SEEKGE" + | "BEGINTRANS" + | "COMMITTRANS" + | "ROLLBACK" + ) +} diff --git a/crates/tb-frontend/src/hir.rs b/crates/tb-frontend/src/hir.rs index c8cba0c..2d8f298 100644 --- a/crates/tb-frontend/src/hir.rs +++ b/crates/tb-frontend/src/hir.rs @@ -330,6 +330,30 @@ pub enum Builtin { Doevents, Sleep, Beep, + // ISAM (Change `phase-3-isam`) + IsamOpen, + IsamCreateIndex, + IsamDeleteIndex, + IsamSetIndex, + IsamGetIndexS, + IsamInsert, + IsamRetrieve, + IsamUpdate, + IsamDelete, + IsamDeleteTable, + IsamMoveFirst, + IsamMoveLast, + IsamMoveNext, + IsamMovePrevious, + IsamSeekEq, + IsamSeekGt, + IsamSeekGe, + IsamBeginTrans, + IsamCommitTrans, + IsamRollback, + IsamSavepoint, + IsamSetmem, + IsamBof, } #[derive(Debug, Clone)] diff --git a/crates/tb-frontend/src/parser.rs b/crates/tb-frontend/src/parser.rs index c204e84..aaedced 100644 --- a/crates/tb-frontend/src/parser.rs +++ b/crates/tb-frontend/src/parser.rs @@ -710,6 +710,20 @@ impl<'a> P<'a> { } Some(Stmt::LockStmt { unlock, file, from, to, pos }) } + // ISAM-Anweisungen: `NAME [#]n [, arg …]`. Sie unterscheiden sich + // von einem gewöhnlichen impliziten Aufruf nur durch das + // erlaubte `#` vor der Dateinummer; alles Weitere (Signatur, + // Absenkung) läuft über die Builtin-Anweisungstabelle. + // `=` und `(` schließen die Anweisungsdeutung aus: dann ist der + // Name eine Variable bzw. ein Array (`DELETE = 1`, `DELETE(2) = 1`). + // Keine ISAM-Syntaxform beginnt mit einer Klammer. + TokenKind::Ident { ref name, suffix: None } + if ist_isam_anweisung(name) + && self.k_at(1) != TokenKind::Eq + && self.k_at(1) != TokenKind::LParen => + { + self.parse_isam(name.clone(), pos) + } TokenKind::Ident { .. } => self.parse_assign_or_call(pos, false), _ => { self.err("Syntax error"); @@ -719,6 +733,31 @@ impl<'a> P<'a> { } } + /// ISAM-Anweisung: `NAME [#]dateinummer [, argument …]`. + /// Syntaxformen siehe `openspec/changes/phase-3-isam/umfang-und-signaturen.md`. + fn parse_isam(&mut self, name: String, pos: SourcePos) -> Option { + self.advance(); + // `ROLLBACK ALL` — hier ist `ALL` Schlüsselwort, kein Bezeichner. + // Es senkt auf die Sentinel-Kennung ab, damit `ROLLBACK` eine + // einzige Signatur behält. + if name == "ROLLBACK" { + if let TokenKind::Ident { name: ref w, suffix: None } = self.k() { + if w == "ALL" { + self.advance(); + let args = vec![Expr::LongLit(ROLLBACK_ALL)]; + return Some(Stmt::Call { name, suffix: None, args, pos }); + } + } + } + self.eat(&TokenKind::Hash); + let args = if self.at_stmt_end() { + Vec::new() + } else { + self.parse_arg_list_to_stmt_end() + }; + Some(Stmt::Call { name, suffix: None, args, pos }) + } + fn parse_print(&mut self, pos: SourcePos, printer: bool) -> Option { self.advance(); // PRINT bzw. LPRINT let file = if !printer && self.eat(&TokenKind::Hash) { diff --git a/crates/tb-frontend/src/sema.rs b/crates/tb-frontend/src/sema.rs index 2959ba6..8f50e30 100644 --- a/crates/tb-frontend/src/sema.rs +++ b/crates/tb-frontend/src/sema.rs @@ -171,6 +171,7 @@ enum ArgK { N, // numerisch S, // String A, // beliebig + R, // Satzvariable eines benutzerdefinierten Typs (ISAM) } #[derive(Clone, Copy)] @@ -274,6 +275,11 @@ fn builtin_fn(name: &str) -> Option<(u8, u8, &'static [ArgK], RetK)> { "STACK" => (0, 0, &[], L), "ERDEV" => (0, 0, &[], I), "ERDEV$" => (0, 0, &[], St), + // ISAM-Funktionen (Formen siehe umfang-und-signaturen.md des Changes) + "GETINDEX$" => (1, 1, &[N], St), + "BOF" => (1, 1, &[N], I), + "SAVEPOINT" => (0, 0, &[], I), + "SETMEM" => (1, 1, &[N], L), _ => return None, }) } @@ -345,6 +351,27 @@ fn builtin_stmt(name: &str) -> Option<(u8, u8, &'static [ArgK])> { "STACK" => (0, 1, &[N]), // `CALL SetFormatCC(49)` — Währungsformat nach Ländercode. "SETFORMATCC" => (1, 1, &[N]), + // ---- ISAM-Anweisungen ------------------------------------------- + // Argumentformen wortgetreu aus der Original-Hilfe; belegt in + // openspec/changes/phase-3-isam/umfang-und-signaturen.md. + // + // CREATEINDEX ist variadisch: Dateinummer, Indexname, + // Eindeutigkeitskennzeichen, dann ein Stringargument je Spalte. + // Die Original-Hilfe nennt keine Obergrenze für die Spaltenzahl, also + // steht hier auch keine; die Argumenttypen ab dem vierten prüft + // `check_and_lower_builtin_args` gesondert. + "CREATEINDEX" => (4, u8::MAX, &[N, S, N, S]), + "DELETEINDEX" => (2, 2, &[N, S]), + // Ohne Indexnamen wird der NULL-Index (Einfügereihenfolge) gesetzt. + "SETINDEX" => (1, 2, &[N, S]), + "INSERT" | "RETRIEVE" | "UPDATE" => (2, 2, &[N, R]), + "DELETE" => (1, 1, &[N]), + "DELETETABLE" => (2, 2, &[S, S]), + "MOVEFIRST" | "MOVELAST" | "MOVENEXT" | "MOVEPREVIOUS" => (1, 1, &[N]), + // Dateinummer, dann ein Schlüsselwert je Indexspalte. + "SEEKEQ" | "SEEKGT" | "SEEKGE" => (2, 10, &[N, A, A, A, A, A, A, A, A, A]), + "BEGINTRANS" | "COMMITTRANS" => (0, 0, &[]), + "ROLLBACK" => (0, 1, &[N]), _ => return None, }) } @@ -377,6 +404,7 @@ pub fn lower(module: &Module) -> (Option, Vec) { data_marks_name: HashMap::new(), data_marks_line: HashMap::new(), hir_procs: Vec::new(), + isam_typ: HashMap::new(), }; let hir = s.run(module); (hir, s.diags) @@ -394,6 +422,10 @@ struct Sema { /// Benutzerdefinierte Typen: Name → Index, Definitionen geordnet. udt_ids: HashMap, udt_defs: Vec, + /// Satztyp je ISAM-Dateinummer aus `OPEN … FOR ISAM typ tabelle AS #n` + /// mit literaler Nummer — Grundlage der Satztypprüfung von `INSERT`, + /// `RETRIEVE` und `UPDATE`. + isam_typ: HashMap, /// Konstanten: Typ und gefalteter Wert. consts: HashMap)>, /// Modulvariablen (Schlüssel wie `var_key`). @@ -1512,12 +1544,40 @@ impl Sema { // Vorgabe-Recordlänge des Vorbilds. None => HExpr::Lng(128), }; - if let Some((ty_name, _table)) = isam { - if !ty_name.is_empty() && !self.udt_ids.contains_key(ty_name) { + if let Some((ty_name, table)) = isam { + let Some(&udt) = self.udt_ids.get(ty_name) else { self.err(*pos, "Type not defined"); + return; + }; + // Die Laufzeit braucht die Spaltennamen; `UdtLayout` + // führt nur die Feldtypen. Sie reisen deshalb als + // Stringargument mit — ohne Änderung am `.tbc`-Format. + let spalten = self.udt_defs[udt as usize] + .fields + .iter() + .map(|(n, _)| n.clone()) + .collect::>() + .join(","); + // Für die Satztypprüfung (Aufgabe 2.3) merken, welcher + // Typ an dieser Dateinummer hängt — nur bei literaler + // Nummer, sonst ist sie erst zur Laufzeit bekannt. + if let Some(ConstVal::Num(n)) = self.fold_const(number) { + self.isam_typ.insert(n as i32, ty_name.clone()); } - // ISAM kommt mit dem Change `phase-3-isam`. - self.push(out, scope, HStmtKind::Unsupported("OPEN FOR ISAM")); + self.push( + out, + scope, + HStmtKind::BuiltinStmt { + b: Builtin::IsamOpen, + args: vec![ + fe, + ne, + HExpr::Str(table.clone()), + HExpr::Str(spalten), + HExpr::Lng(udt as i32), + ], + }, + ); return; } // Ohne `FOR`-Klausel gilt RANDOM (Vorbild). @@ -2433,6 +2493,13 @@ impl Sema { let a = self.lower_opt_args(args, &lowered); self.push(out, scope, HStmtKind::BuiltinStmt { b, args: a }); } + // ---- ISAM ------------------------------------------------------ + "CREATEINDEX" | "DELETEINDEX" | "SETINDEX" | "INSERT" | "RETRIEVE" + | "UPDATE" | "DELETE" | "DELETETABLE" | "MOVEFIRST" | "MOVELAST" + | "MOVENEXT" | "MOVEPREVIOUS" | "SEEKEQ" | "SEEKGT" | "SEEKGE" + | "BEGINTRANS" | "COMMITTRANS" | "ROLLBACK" => { + self.lower_isam_stmt(name, args, lowered, scope, out) + } // Bildschirm-/Datei-/System-Anweisungen späterer Phasen. _ => self.push(out, scope, HStmtKind::Unsupported(match name { "RUN" => "RUN", @@ -2443,6 +2510,68 @@ impl Sema { } } + /// ISAM-Anweisungen absenken. Die Dateinummer ist überall das erste + /// Argument (bei `DELETETABLE` stattdessen der Datenbankname); alle + /// weiteren Argumente reisen unverändert durch, weil die Laufzeit sie + /// gegen das Tabellenlayout prüfen muss. + #[allow(clippy::too_many_arguments)] + fn lower_isam_stmt( + &mut self, + name: &str, + args: &[Expr], + lowered: Vec<(HExpr, Ty)>, + scope: &mut Scope, + out: &mut Vec, + ) { + let b = match name { + "CREATEINDEX" => Builtin::IsamCreateIndex, + "DELETEINDEX" => Builtin::IsamDeleteIndex, + "SETINDEX" => Builtin::IsamSetIndex, + "INSERT" => Builtin::IsamInsert, + "RETRIEVE" => Builtin::IsamRetrieve, + "UPDATE" => Builtin::IsamUpdate, + "DELETE" => Builtin::IsamDelete, + "DELETETABLE" => Builtin::IsamDeleteTable, + "MOVEFIRST" => Builtin::IsamMoveFirst, + "MOVELAST" => Builtin::IsamMoveLast, + "MOVENEXT" => Builtin::IsamMoveNext, + "MOVEPREVIOUS" => Builtin::IsamMovePrevious, + "SEEKEQ" => Builtin::IsamSeekEq, + "SEEKGT" => Builtin::IsamSeekGt, + "SEEKGE" => Builtin::IsamSeekGe, + "BEGINTRANS" => Builtin::IsamBeginTrans, + "COMMITTRANS" => Builtin::IsamCommitTrans, + _ => Builtin::IsamRollback, + }; + + // Satzargument gegen den Typ der Dateinummer prüfen, sofern beide + // literal bekannt sind (Aufgabe 2.3). Ist die Nummer erst zur + // Laufzeit bekannt, prüft die Laufzeit gegen das Tabellenlayout. + if matches!(name, "INSERT" | "RETRIEVE" | "UPDATE") { + if let (Some(ConstVal::Num(n)), Some((_, Ty::Udt(hat)))) = + (args.first().and_then(|a| self.fold_const(a)), lowered.get(1)) + { + if let Some(soll) = self.isam_typ.get(&(n as i32)) { + if soll != hat { + self.err(args[1].pos(), "Type mismatch"); + } + } + } + } + + let mut hargs = Vec::with_capacity(lowered.len()); + for (i, (e, t)) in lowered.into_iter().enumerate() { + // Erstes Argument ist die Dateinummer (bei DELETETABLE ein Name). + let ist_nummer = i == 0 && name != "DELETETABLE"; + if ist_nummer || (name == "ROLLBACK" && i == 0) { + hargs.push(self.conv_num(e, &t, NumTy::Lng)); + } else { + hargs.push(e); + } + } + self.push(out, scope, HStmtKind::BuiltinStmt { b, args: hargs }); + } + /// Ausgabeziel von `PRINT` umschalten (−1 Bildschirm, −2 Drucker, /// sonst Dateinummer). fn push_ziel(&mut self, out: &mut Vec, scope: &mut Scope, n: HExpr) { @@ -2497,6 +2626,8 @@ impl Sema { } // Sonderfall INSTR([start%,] s$, such$) let instr_with_start = name == "INSTR" && args.len() == 3; + // Sonderfall CREATEINDEX: beliebig viele Spaltennamen ab Argument 4. + let createindex = name == "CREATEINDEX"; let mut out = Vec::new(); for (i, a) in args.iter().enumerate() { if matches!(a, Expr::Missing) { @@ -2509,7 +2640,7 @@ impl Sema { 0 => ArgK::N, _ => ArgK::S, } - } else if name == "INSTR" { + } else if name == "INSTR" || (createindex && i >= 3) { ArgK::S } else { *spec.get(i).unwrap_or(&ArgK::A) @@ -2518,6 +2649,7 @@ impl Sema { ArgK::N => is_num(&at), ArgK::S => is_str(&at), ArgK::A => true, + ArgK::R => matches!(at, Ty::Udt(_) | Ty::Unknown), }; if !ok { self.err(a.pos(), "Type mismatch"); @@ -3369,6 +3501,20 @@ impl Sema { } } "FREEFILE" => b(Builtin::Freefile, vec![], Ty::Int, self), + // --- ISAM-Funktionen --- + "GETINDEX$" => { + let f = conv_arg!(0, NumTy::Lng); + b(Builtin::IsamGetIndexS, vec![f], Ty::Str, self) + } + "BOF" => { + let f = conv_arg!(0, NumTy::Lng); + b(Builtin::IsamBof, vec![f], Ty::Int, self) + } + "SAVEPOINT" => b(Builtin::IsamSavepoint, vec![], Ty::Int, self), + "SETMEM" => { + let n = conv_arg!(0, NumTy::Lng); + b(Builtin::IsamSetmem, vec![n], Ty::Lng, self) + } "ENVIRON$" => b(Builtin::EnvironS, vec![take(&lowered, 0).0], Ty::Str, self), "FRE" => b(Builtin::Fre, vec![], Ty::Lng, self), "STACK" => b(Builtin::StackFn, vec![], Ty::Lng, self), @@ -3833,4 +3979,106 @@ mod tests { // BYVAL (Klammern) konvertiert stattdessen assert!(diags("SUB Foo (x&)\nEND SUB\nn% = 1\nFoo (n%)").is_empty()); } + + // ---- ISAM (Change `phase-3-isam`) ----------------------------------- + + /// Satztyp und geöffnete Tabelle als Vorspann für die ISAM-Tests. + const ISAM_KOPF: &str = "TYPE KundeTyp\n Nummer AS LONG\n Name AS STRING * 20\n END TYPE\n DIM k AS KundeTyp\n OPEN \"db\" FOR ISAM KundeTyp \"Kunden\" AS #1\n"; + + /// Aufgabe 2.1: die Anweisung parst mit `#`-Dateinummer diagnose-frei. + #[test] + fn isam_anweisung_parst_ohne_diagnose() { + let d = diags(&format!("{ISAM_KOPF}SETINDEX #1, \"Name\"")); + assert!(d.is_empty(), "SETINDEX soll diagnose-frei parsen: {d:?}"); + // Auch ohne `#` (die Original-Hilfe schreibt es optional). + let d = diags(&format!("{ISAM_KOPF}SETINDEX 1, \"Name\"")); + assert!(d.is_empty(), "{d:?}"); + } + + /// Aufgabe 2.2: falsche Argumentanzahl wird gemeldet und nennt das + /// Element (Guiding Principle). + #[test] + fn isam_argumentanzahl_nennt_das_element() { + let d = diags(&format!("{ISAM_KOPF}SEEKGT #1")); + assert!( + d.iter().any(|m| m.contains("SEEKGT")), + "Diagnose muss SEEKGT nennen: {d:?}" + ); + assert!( + d.iter().any(|m| m.contains("Argument-count mismatch")), + "{d:?}" + ); + } + + /// Aufgabe 2.3: das Satzargument wird gegen den Tabellentyp geprüft. + #[test] + fn isam_satzargument_wird_typgeprueft() { + let d = diags(&format!("{ISAM_KOPF}DIM x AS STRING\nRETRIEVE #1, x$")); + assert!( + d.contains(&"Type mismatch".to_string()), + "String statt Satzvariable muss auffallen: {d:?}" + ); + // Ein anderer benutzerdefinierter Typ passt ebenfalls nicht. + let quelle = format!( + "TYPE Anderer\n z AS INTEGER\nEND TYPE\n{ISAM_KOPF} DIM a AS Anderer\nRETRIEVE #1, a" + ); + assert!( + diags(&quelle).contains(&"Type mismatch".to_string()), + "fremder Satztyp muss auffallen: {:?}", + diags(&quelle) + ); + // Der richtige Typ ist diagnose-frei. + let d = diags(&format!("{ISAM_KOPF}RETRIEVE #1, k")); + assert!(d.is_empty(), "{d:?}"); + } + + /// Aufgabe 2.5: kein ISAM-Element endet als unbekannter Bezeichner — + /// ein Programm, das jedes Element genau einmal verwendet. + #[test] + fn kein_isam_element_ist_unbekannter_bezeichner() { + let quelle = format!( + "{ISAM_KOPF} CREATEINDEX #1, \"NachName\", 0, \"Name\"\n CREATEINDEX #1, \"Zwei\", 1, \"Name\", \"-Nummer\"\n SETINDEX #1, \"NachName\"\n i$ = GETINDEX$(1)\n INSERT #1, k\n MOVEFIRST #1\n MOVELAST #1\n MOVENEXT #1\n MOVEPREVIOUS #1\n SEEKEQ #1, \"a\"\n SEEKGT #1, \"a\"\n SEEKGE #1, \"a\"\n RETRIEVE #1, k\n UPDATE #1, k\n DELETE #1\n e% = EOF(1)\n b% = BOF(1)\n BEGINTRANS\n s% = SAVEPOINT\n ROLLBACK s%\n ROLLBACK\n ROLLBACK ALL\n COMMITTRANS\n m& = SETMEM(1024)\n DELETEINDEX #1, \"NachName\"\n CLOSE #1\n DELETETABLE \"db\", \"Kunden\"\n" + ); + let d = diags(&quelle); + assert!( + !d.iter().any(|m| m.contains("not defined")), + "ISAM-Element wird als unbekannter Bezeichner abgewiesen: {d:?}" + ); + assert!(d.is_empty(), "unerwartete Diagnosen: {d:?}"); + } + + /// `ROLLBACK ALL` senkt auf die Sentinel-Kennung ab und ist damit von + /// `ROLLBACK kennung` unterscheidbar. + #[test] + fn rollback_all_wird_als_sentinel_abgesenkt() { + let a = crate::analyze_source("TEST", "BEGINTRANS\nROLLBACK ALL"); + assert!(a.diagnostics.is_empty(), "{:?}", a.diagnostics); + // `ALL` darf keine Variable werden — sonst zaehlte OPTION EXPLICIT es an. + let d = diags("OPTION EXPLICIT\nBEGINTRANS\nROLLBACK ALL"); + assert!(d.is_empty(), "{d:?}"); + } + + /// `CREATEINDEX` ist in der Spaltenzahl unbegrenzt — die Original-Hilfe + /// nennt keine Obergrenze, also darf die Signatur auch keine setzen. + #[test] + fn createindex_ohne_obergrenze_der_spaltenzahl() { + let felder: String = + (1..=12).map(|i| format!(" F{i} AS LONG\n")).collect(); + let spalten: String = + (1..=12).map(|i| format!(", \"F{i}\"")).collect(); + let quelle = format!( + "TYPE Breit\n{felder}END TYPE\n DIM b AS Breit\n OPEN \"db\" FOR ISAM Breit \"Tab\" AS #1\n CREATEINDEX #1, \"Viel\", 0{spalten}" + ); + let d = diags(&quelle); + assert!(d.is_empty(), "zwölfspaltiger Index muss zulässig sein: {d:?}"); + // Ein Spaltenname muss trotzdem ein String sein. + let quelle = format!( + "TYPE Breit\n{felder}END TYPE\n DIM b AS Breit\n OPEN \"db\" FOR ISAM Breit \"Tab\" AS #1\n CREATEINDEX #1, \"Viel\", 0, \"F1\", 42" + ); + assert!( + diags(&quelle).contains(&"Type mismatch".to_string()), + "Zahl als Spaltenname muss auffallen: {:?}", + diags(&quelle) + ); + } } diff --git a/crates/tb-runtime/Cargo.toml b/crates/tb-runtime/Cargo.toml index e7b1a7a..e25865e 100644 --- a/crates/tb-runtime/Cargo.toml +++ b/crates/tb-runtime/Cargo.toml @@ -11,3 +11,4 @@ unicode-width.workspace = true jiff.workspace = true thiserror.workspace = true log.workspace = true +redb = "4" diff --git a/crates/tb-runtime/src/builtins.rs b/crates/tb-runtime/src/builtins.rs index 4651caa..49fcd6a 100644 --- a/crates/tb-runtime/src/builtins.rs +++ b/crates/tb-runtime/src/builtins.rs @@ -25,6 +25,8 @@ pub struct RtState { pub screen: TextScreen, /// Offene Dateien nach Dateinummer. pub dateien: crate::fileio::Dateien, + /// ISAM-Zustand: Tabellenbindungen, Cursor, laufende Transaktion. + pub isam: crate::isam::Isam, pub print: PrintState, /// Noch nicht abgeholte Tastendrücke in `INKEY$`-Form. pub tasten: VecDeque, @@ -60,6 +62,7 @@ impl Default for RtState { RtState { screen: TextScreen::new(), dateien: Default::default(), + isam: Default::default(), print: PrintState::default(), tasten: VecDeque::new(), abbruch: false, @@ -359,7 +362,31 @@ pub mod ids { pub const STACK_STMT: u16 = 122; pub const ERDEV: u16 = 123; pub const ERDEV_S: u16 = 124; - pub const COUNT: u16 = 125; + // ISAM (Change `phase-3-isam`) + pub const ISAM_OPEN: u16 = 125; + pub const ISAM_CREATE_INDEX: u16 = 126; + pub const ISAM_DELETE_INDEX: u16 = 127; + pub const ISAM_SET_INDEX: u16 = 128; + pub const ISAM_GET_INDEX_S: u16 = 129; + pub const ISAM_INSERT: u16 = 130; + pub const ISAM_RETRIEVE: u16 = 131; + pub const ISAM_UPDATE: u16 = 132; + pub const ISAM_DELETE: u16 = 133; + pub const ISAM_DELETE_TABLE: u16 = 134; + pub const ISAM_MOVE_FIRST: u16 = 135; + pub const ISAM_MOVE_LAST: u16 = 136; + pub const ISAM_MOVE_NEXT: u16 = 137; + pub const ISAM_MOVE_PREVIOUS: u16 = 138; + pub const ISAM_SEEK_EQ: u16 = 139; + pub const ISAM_SEEK_GT: u16 = 140; + pub const ISAM_SEEK_GE: u16 = 141; + pub const ISAM_BEGIN_TRANS: u16 = 142; + pub const ISAM_COMMIT_TRANS: u16 = 143; + pub const ISAM_ROLLBACK: u16 = 144; + pub const ISAM_SAVEPOINT: u16 = 145; + pub const ISAM_SETMEM: u16 = 146; + pub const ISAM_BOF: u16 = 147; + pub const COUNT: u16 = 148; } /// Dispatch-Tabelle in Index-Reihenfolge. @@ -490,6 +517,30 @@ pub fn builtin_table() -> &'static [BuiltinFn] { bi_stack_stmt, bi_erdev, bi_erdev_s, + // ISAM + bi_isam_open, + bi_isam_create_index, + bi_isam_delete_index, + bi_isam_set_index, + bi_isam_get_index, + bi_isam_insert, + bi_isam_retrieve, + bi_isam_update, + bi_isam_delete, + bi_isam_delete_table, + bi_isam_move_first, + bi_isam_move_last, + bi_isam_move_next, + bi_isam_move_previous, + bi_isam_seek_eq, + bi_isam_seek_gt, + bi_isam_seek_ge, + bi_isam_begin_trans, + bi_isam_commit_trans, + bi_isam_rollback, + bi_isam_savepoint, + bi_isam_setmem, + bi_isam_bof, ]; debug_assert_eq!(TABLE.len(), ids::COUNT as usize); TABLE @@ -951,10 +1002,15 @@ fn bi_open(st: &mut RtState, _: &mut dyn Host, a: &mut [Value]) -> Result