Phase 3 (Kern): Laufzeitbibliothek, Bildschirm und Datei-E/A

Setzt den OpenSpec-Change phase-3-runtime-bildschirm um (77/77 Aufgaben).
Abdeckung laut Inventar: 195 implementiert, 37 offen, 53 Non-Feature.

Vollstaendigkeits-Inventar
- docs/inventar.md mit 285 Eintraegen aus den Themenlisten von
  bas7advr.hlp und qb45advr.hlp, je mit Status und Fundstelle
- crates/tb-frontend/tests/inventar.rs haelt die Tabelle in beide
  Richtungen gegen den Code; der Abdeckungsstand kann nicht veralten

Bruchschritt (Puffer, Host, Korpus)
- Cell/TextScreen ziehen ratatui-frei nach tb-runtime::screen; tb-ui
  behaelt Farbabbildung, Widget und den neuen Terminal-Host
- Host wechselt vom Zeichenstrom auf Anzeige des Zellenpuffers plus
  Ereignisse (Taste, Groessenaenderung, Abbruch)
- Korpusvergleich auf getrimmten Bildschirm-Snapshot; die acht
  bestehenden Sollausgaben blieben dabei unveraendert

Groessenunabhaengigkeit
- 80x25 ist nirgends mehr eine feste Grenze; Groessenaenderungen waehrend
  der Ausfuehrung werden zugestellt (Inhalt oben links erhalten, Cursor
  und VIEW PRINT geklemmt)
- Korpusnachweis: dasselbe Programm bei 80x25 und 120x40 mit je eigener
  Sollausgabe, plus ein Programm mit Groessenwechsel mitten im Lauf

Bibliothek
- Breite Unicode-Zeichen belegen zwei Zellen (Cursor, POS, Randumbruch)
- Bildschirm: CLS, COLOR, LOCATE, WIDTH, VIEW PRINT, SCREEN, CSRLIN, POS
- Tastatur: INKEY$, INPUT$, Funktionstasten-Makros (KEY n / LIST / ON)
- PRINT USING, LPRINT USING, FORMAT$, SetFormatCC
- Mathematik mit kompatiblem PRNG (gleiche Saat, gleiche Folge)
- Datum und Zeit mit Serienwerten ab 1899
- Finanzmathematik: FV#, PV#, Pmt#, IPmt#, PPmt#, NPer#, Rate#, NPV#,
  IRR#, MIRR#, SLN#, SYD#, DDB#
- Datei-E/A: sequenziell, RANDOM (Recordpuffer und UDT-Variablen),
  BINARY, FIELD/LSET/RSET, Statusfunktionen, Dateisystem, MK$/CV
- System: ENVIRON, FRE, CLEAR, TRON/TROFF, STACK, ERDEV, ERR-Anweisung

Altlasten aus Phase 2
- ON ERROR GOTO auf Modulebene ist aus Prozeduren ansprechbar
  (prozeduruebergreifender Fixup im Codegenerator)
- DATA behaelt seinen Rohtext (Gross-/Kleinschreibung, innerer Leerraum)
- Die vier TODO-verify-Zellen der Konvertierungsmatrix sind aufgeloest

Vom Inventar aufgedeckte Fehler
- Zwoelf Non-Features wies der Compiler entgegen der Phase-1-Spec nicht
  ab (CALLS, SSEG, POINT, VIEW, COM, PEN, STRIG, STICK, die
  String*-Routinen, LINE und OPEN "COMn:") -- geschlossen
- Drei Gruppen fehlten im urspruenglichen Umfang: Finanzmathematik,
  Record-Konvertierung, Rest der Dateisystemfunktionen
- bas7advr.hlp allein ist keine vollstaendige Quelle; das Inventar
  bildet die Vereinigung mit qb45advr.hlp

Neue Changes
- phase-3-isam: schliesst Phase 3 ab (Speicherschicht redb)
- phase-3-ortszeit: zieht die UTC-Abweichung zurueck

Neue Abhaengigkeit: unicode-width.
Dokumentiert: sprachreferenz.md und tbvm-design.md sind TODO-frei,
docs/bibliothek.md neu, PLAN.md fortgeschrieben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 06:54:19 +02:00
parent a6f005d023
commit 52ccbb5848
94 changed files with 10421 additions and 792 deletions

View File

@@ -0,0 +1,487 @@
//! `PRINT USING`, `LPRINT USING` und `FORMAT$`: Formatzeichenketten des
//! Vorbilds.
//!
//! Die Formatzeichenkette beschreibt Felder; jeder Wert füllt das nächste
//! Feld. Sind noch Werte übrig, wenn die Zeichenkette zu Ende ist, beginnt
//! sie von vorn. Passt ein Wert nicht in sein Feld, wird er vollständig
//! ausgegeben und mit `%` gekennzeichnet.
use crate::errors::RuntimeError;
use crate::value::{as_f64, Value};
/// Numerisches Feld.
#[derive(Debug, Clone, Default, PartialEq)]
struct NumFeld {
/// Ziffernstellen vor dem Dezimalpunkt (ohne die Stellen aus `$$`/`**`).
vor: usize,
/// Ziffernstellen nach dem Dezimalpunkt.
nach: usize,
/// Dezimalpunkt vorhanden?
punkt: bool,
/// Tausendertrennung (`,` vor dem Punkt).
tausender: bool,
/// `+` vorangestellt bzw. nachgestellt.
plus_vorn: bool,
plus_hinten: bool,
/// `-` nachgestellt (nur für negative Werte).
minus_hinten: bool,
/// `$$` — mitlaufendes Währungszeichen.
waehrung: bool,
/// `**` — führende Stellen mit `*` füllen.
sterne: bool,
/// `^^^^` — Exponentialform.
exponent: bool,
}
/// Stringfeld.
#[derive(Debug, Clone, PartialEq)]
enum StrFeld {
/// `!` — nur das erste Zeichen.
Erstes,
/// `\ \` — feste Länge (Anzahl Zeichen inklusive beider Schrägstriche).
Fest(usize),
/// `&` — der ganze String.
Ganz,
}
#[derive(Debug, Clone, PartialEq)]
enum Teil {
Literal(String),
Zahl(NumFeld),
Text(StrFeld),
}
/// Währungszeichen für `$$` (siehe `SetFormatCC`).
#[derive(Debug, Clone)]
pub struct Waehrung {
pub zeichen: String,
}
impl Default for Waehrung {
fn default() -> Self {
Waehrung { zeichen: "$".into() }
}
}
// ---- Zerlegen der Formatzeichenkette ---------------------------------------
fn zerlege(fmt: &str) -> Vec<Teil> {
let z: Vec<char> = fmt.chars().collect();
let mut teile = Vec::new();
let mut lit = String::new();
let mut i = 0;
let schiebe = |lit: &mut String, teile: &mut Vec<Teil>| {
if !lit.is_empty() {
teile.push(Teil::Literal(std::mem::take(lit)));
}
};
while i < z.len() {
match z[i] {
// `_` macht das Folgezeichen literal.
'_' if i + 1 < z.len() => {
lit.push(z[i + 1]);
i += 2;
}
'!' => {
schiebe(&mut lit, &mut teile);
teile.push(Teil::Text(StrFeld::Erstes));
i += 1;
}
'&' => {
schiebe(&mut lit, &mut teile);
teile.push(Teil::Text(StrFeld::Ganz));
i += 1;
}
// `\ \` — Leerzeichen zwischen zwei Schrägstrichen.
'\\' => {
let mut j = i + 1;
while j < z.len() && z[j] == ' ' {
j += 1;
}
if j < z.len() && z[j] == '\\' {
schiebe(&mut lit, &mut teile);
teile.push(Teil::Text(StrFeld::Fest(j - i + 1)));
i = j + 1;
} else {
lit.push('\\');
i += 1;
}
}
'#' | '+' | '.' | '*' | '$' => {
if let Some((feld, weiter)) = zahlfeld(&z, i) {
schiebe(&mut lit, &mut teile);
teile.push(Teil::Zahl(feld));
i = weiter;
} else {
lit.push(z[i]);
i += 1;
}
}
c => {
lit.push(c);
i += 1;
}
}
}
schiebe(&mut lit, &mut teile);
teile
}
/// Versucht ab `start` ein numerisches Feld zu lesen.
fn zahlfeld(z: &[char], start: usize) -> Option<(NumFeld, usize)> {
let mut f = NumFeld::default();
let mut i = start;
if z[i] == '+' {
f.plus_vorn = true;
i += 1;
}
// `**$`, `**`, `$$` belegen selbst Ziffernstellen.
if i + 1 < z.len() && z[i] == '*' && z[i + 1] == '*' {
f.sterne = true;
f.vor += 2;
i += 2;
if i < z.len() && z[i] == '$' {
f.waehrung = true;
f.vor += 1;
i += 1;
}
} else if i + 1 < z.len() && z[i] == '$' && z[i + 1] == '$' {
f.waehrung = true;
f.vor += 2;
i += 2;
}
let ziffern_start = i;
while i < z.len() {
match z[i] {
'#' => {
f.vor += 1;
i += 1;
}
',' if i + 1 < z.len() && (z[i + 1] == '#' || z[i + 1] == '.') => {
f.tausender = true;
i += 1;
}
_ => break,
}
}
// Ohne jede Ziffernstelle ist es kein Zahlfeld.
if i == ziffern_start && !f.waehrung && !f.sterne {
return None;
}
if i < z.len() && z[i] == '.' {
f.punkt = true;
i += 1;
while i < z.len() && z[i] == '#' {
f.nach += 1;
i += 1;
}
}
// Exponentialform: vier oder mehr `^`.
let exp_start = i;
while i < z.len() && z[i] == '^' {
i += 1;
}
if i - exp_start >= 4 {
f.exponent = true;
} else {
i = exp_start;
}
if i < z.len() && z[i] == '+' {
f.plus_hinten = true;
i += 1;
} else if i < z.len() && z[i] == '-' {
f.minus_hinten = true;
i += 1;
}
Some((f, i))
}
// ---- Werte formatieren ------------------------------------------------------
fn zahl_formatieren(f: &NumFeld, wert: f64, waehrung: &Waehrung) -> String {
let negativ = wert < 0.0 || (wert == 0.0 && wert.is_sign_negative());
let betrag = wert.abs();
if f.exponent {
return exponent_formatieren(f, wert);
}
// Ziffern runden (kaufmännisch zur geraden Zahl wie sonst auch).
let gerundet = runde(betrag, f.nach);
let mut ganz = format!("{:.0}", gerundet.trunc());
let bruch = if f.punkt {
let b = gerundet - gerundet.trunc();
let s = format!("{:.*}", f.nach, b);
// "0.25" → "25"
s.split_once('.').map(|(_, r)| r.to_string()).unwrap_or_default()
} else {
String::new()
};
// Die Tausendertrennung fügt Zeichen hinzu, die keine Ziffernstelle
// belegen; der Überlauf entscheidet sich an der Ziffernzahl.
let ziffern = ganz.chars().count();
if f.tausender {
ganz = tausender_gruppieren(&ganz);
}
// Vorzeichen mit eigener Position: `+` vorn bzw. `+`/`-` hinten stehen
// neben dem Zahlenfeld, nicht darin. Ohne Vorzeichenangabe teilt sich ein
// `-` die Ziffernstellen.
let (vorz_vorn, vorz_hinten, minus_im_feld) = if f.plus_vorn {
(if negativ { "-" } else { "+" }.to_string(), String::new(), false)
} else if f.plus_hinten {
(String::new(), if negativ { "-" } else { "+" }.to_string(), false)
} else if f.minus_hinten {
(String::new(), if negativ { "-" } else { " " }.to_string(), false)
} else {
(String::new(), String::new(), negativ)
};
// Inhalt der Ziffernstellen, rechtsbündig: Vorzeichen (falls im Feld),
// Währungszeichen, Ziffern. `$$`/`**` haben ihre Stellen mitgebracht.
let mut inhalt = String::new();
if minus_im_feld {
inhalt.push('-');
}
if f.waehrung {
inhalt.push_str(&waehrung.zeichen);
}
inhalt.push_str(&ganz);
let ueberlauf = ziffern + usize::from(minus_im_feld) + usize::from(f.waehrung) > f.vor;
let gefuellt = if ueberlauf {
inhalt
} else {
// Trennzeichen belegen keine Ziffernstelle und können die Breite
// überschreiten, ohne Überlauf zu sein.
let fehlt = f.vor.saturating_sub(inhalt.chars().count());
let fueller = if f.sterne { '*' } else { ' ' };
let mut t: String = std::iter::repeat_n(fueller, fehlt).collect();
t.push_str(&inhalt);
t
};
let mut out = String::new();
if ueberlauf {
out.push('%');
}
out.push_str(&vorz_vorn);
out.push_str(&gefuellt);
if f.punkt {
out.push('.');
out.push_str(&bruch);
}
out.push_str(&vorz_hinten);
out
}
/// Kaufmännische Rundung zur geraden Zahl auf `stellen` Nachkommastellen.
fn runde(x: f64, stellen: usize) -> f64 {
let faktor = 10f64.powi(stellen as i32);
let skaliert = x * faktor;
let ab = skaliert.floor();
let rest = skaliert - ab;
let gerundet = if (rest - 0.5).abs() < f64::EPSILON * skaliert.abs().max(1.0) {
if (ab as i64) % 2 == 0 {
ab
} else {
ab + 1.0
}
} else {
skaliert.round()
};
gerundet / faktor
}
fn tausender_gruppieren(s: &str) -> String {
let z: Vec<char> = s.chars().collect();
let mut out = String::new();
for (i, c) in z.iter().enumerate() {
if i > 0 && (z.len() - i) % 3 == 0 {
out.push(',');
}
out.push(*c);
}
out
}
fn exponent_formatieren(f: &NumFeld, wert: f64) -> String {
let stellen = f.nach;
let s = format!("{:.*E}", stellen, wert);
// Rust liefert `1.5E3`; das Vorbild schreibt `1.5E+03`.
match s.split_once('E') {
Some((m, e)) => {
let (vz, betrag) = match e.strip_prefix('-') {
Some(r) => ('-', r),
None => ('+', e.trim_start_matches('+')),
};
format!("{m}E{vz}{betrag:0>2}")
}
None => s,
}
}
fn text_formatieren(f: &StrFeld, s: &str) -> String {
match f {
StrFeld::Erstes => s.chars().take(1).collect(),
StrFeld::Ganz => s.to_string(),
StrFeld::Fest(n) => {
let mut t: String = s.chars().take(*n).collect();
let fehlt = n.saturating_sub(t.chars().count());
t.extend(std::iter::repeat_n(' ', fehlt));
t
}
}
}
// ---- Einstiegspunkt ---------------------------------------------------------
/// Werte nach `fmt` formatieren. Die Zeichenkette wird so oft wiederholt,
/// wie Werte übrig sind; nach dem letzten Wert folgen noch die Literale bis
/// zum nächsten Feld.
pub fn using(fmt: &str, werte: &[Value], waehrung: &Waehrung) -> Result<String, RuntimeError> {
let teile = zerlege(fmt);
let hat_feld = teile
.iter()
.any(|t| matches!(t, Teil::Zahl(_) | Teil::Text(_)));
if !hat_feld {
if werte.is_empty() {
return Ok(fmt.to_string());
}
// Werte ohne Feld sind ein Bedienfehler (Vorbild: Fehler 5).
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
}
let mut out = String::new();
let mut i = 0usize; // Index im Teile-Vektor
let mut w = 0usize; // Index im Wertevektor
// Schutz gegen Endlosläufe bei Formaten ohne Fortschritt.
let mut runden = 0usize;
while w < werte.len() {
if i >= teile.len() {
i = 0;
runden += 1;
if runden > werte.len() + 1 {
break;
}
}
match &teile[i] {
Teil::Literal(l) => out.push_str(l),
Teil::Zahl(f) => {
if matches!(werte[w], Value::Str(_)) {
return Err(RuntimeError::TYPE_MISMATCH);
}
let v = as_f64(&werte[w]);
out.push_str(&zahl_formatieren(f, v, waehrung));
w += 1;
}
Teil::Text(f) => {
let Value::Str(s) = &werte[w] else {
return Err(RuntimeError::TYPE_MISMATCH);
};
out.push_str(&text_formatieren(f, s));
w += 1;
}
}
i += 1;
}
// Nachlaufende Literale bis zum nächsten Feld.
while i < teile.len() {
match &teile[i] {
Teil::Literal(l) => out.push_str(l),
_ => break,
}
i += 1;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
fn u(fmt: &str, werte: &[Value]) -> String {
using(fmt, werte, &Waehrung::default()).unwrap()
}
fn z(x: f64) -> Value {
Value::Dbl(x)
}
fn s(x: &str) -> Value {
Value::Str(Rc::from(x))
}
#[test]
fn numerisches_feld_mit_nachkommastellen() {
assert_eq!(u("###.##", &[z(3.14159)]), " 3.14");
assert_eq!(u("##.##", &[z(-1.5)]), "-1.50");
}
#[test]
fn feldueberlauf_bekommt_prozentzeichen() {
let r = u("##", &[z(12345.0)]);
assert!(r.starts_with('%'), "{r}");
assert!(r.contains("12345"), "{r}");
}
#[test]
fn format_wird_wiederverwendet() {
assert_eq!(u("## ", &[z(1.0), z(2.0), z(3.0)]), " 1 2 3 ");
}
#[test]
fn tausendertrennung() {
assert_eq!(u("#######", &[z(1234567.0)]), "1234567");
assert_eq!(u("##,#####", &[z(1234567.0)]), "1,234,567");
// Mehr Ziffern als Stellen → Überlauf mit %-Präfix.
assert!(u("##,####", &[z(1234567.0)]).starts_with('%'));
}
#[test]
fn vorzeichenfuehrung() {
assert_eq!(u("+###", &[z(12.0)]), "+ 12");
assert_eq!(u("###-", &[z(-12.0)]), " 12-");
assert_eq!(u("###-", &[z(12.0)]), " 12 ");
}
#[test]
fn waehrung_und_sterne() {
// `$$` bringt zwei Stellen mit, eine davon ist das Zeichen selbst.
assert_eq!(u("$$###", &[z(12.0)]), " $12");
assert_eq!(u("**###", &[z(12.0)]), "***12");
}
#[test]
fn stringfelder() {
assert_eq!(u("!", &[s("abc")]), "a");
assert_eq!(u("&", &[s("abc")]), "abc");
assert_eq!(u("\\ \\", &[s("abcdef")]), "abcd");
assert_eq!(u("\\ \\", &[s("ab")]), "ab ");
}
#[test]
fn literale_und_unterstrich() {
assert_eq!(u("Wert: ##!", &[z(7.0), s("x")]), "Wert: 7x");
assert_eq!(u("_###", &[z(7.0)]), "# 7");
}
#[test]
fn exponentialform() {
assert_eq!(u("#.##^^^^", &[z(1234.0)]), "1.23E+03");
}
#[test]
fn ohne_feld_aber_mit_werten_ist_fehler_5() {
let e = using("nur Text", &[z(1.0)], &Waehrung::default()).unwrap_err();
assert_eq!(e, RuntimeError::ILLEGAL_FUNCTION_CALL);
}
#[test]
fn ohne_feld_und_ohne_werte_bleibt_der_text() {
assert_eq!(u("nur Text", &[]), "nur Text");
}
}