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

@@ -7,5 +7,6 @@ license.workspace = true
authors.workspace = true
[dependencies]
unicode-width.workspace = true
thiserror.workspace = true
log.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -1,112 +1,199 @@
//! Konsolen-Druckzustand: Spaltenverfolgung, PRINT-Formatierung,
//! 14-Zeichen-Druckzonen, TAB/SPC. Wirkung ausschließlich über `Host`.
//! Konsolen-Druckzustand: PRINT-Formatierung, 14-Zeichen-Druckzonen, TAB/SPC.
//! Wirkung ausschließlich auf dem [`TextScreen`].
//!
//! Die Druckspalte wird nicht mehr eigenständig mitgezählt, sondern vom
//! Bildschirm gelesen (`POS`). Damit stimmen Druckzonen und Cursor auch
//! nach `LOCATE`, `CLS` oder einem Umbruch am rechten Rand überein.
use crate::errors::RuntimeError;
use crate::format::format_print;
use crate::host::Host;
use crate::fileio::Dateien;
use crate::screen::TextScreen;
use crate::value::Value;
/// Breite einer Druckzone (Vorbild: 14 Zeichen).
pub const ZONE_WIDTH: usize = 14;
/// Druckzustand (Spalte 0-basiert, in Zeichen).
/// Wohin `PRINT` schreibt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Ziel {
#[default]
Bildschirm,
/// `PRINT #n` — in eine offene Datei.
Datei(i32),
/// `LPRINT` — in den Druckerkanal.
Drucker,
}
/// PRINT-Verhalten. Auf dem Bildschirm lebt die Spalte im Bildschirm; für
/// Datei und Drucker wird sie hier mitgezählt, damit Druckzonen, `TAB` und
/// `SPC` dort genauso wirken.
#[derive(Default)]
pub struct PrintState {
pub col: usize,
pub ziel: Ziel,
/// Spalte (0-basiert) für Datei- und Druckerausgabe.
pub spalte: usize,
/// Druckerpuffer (Grundlage von `LPOS`); wird beim Programmende
/// ausgegeben.
pub drucker: String,
}
impl PrintState {
/// Text ausgeben und Spalte nachführen.
pub fn write(&mut self, host: &mut dyn Host, s: &str) {
host.write(s);
/// Aktuelle Druckspalte (0-basiert) des eingestellten Ziels.
pub fn col(&self, screen: &TextScreen) -> usize {
match self.ziel {
Ziel::Bildschirm => screen.pos() - 1,
_ => self.spalte,
}
}
/// Text an das eingestellte Ziel ausgeben.
pub fn write(
&mut self,
screen: &mut TextScreen,
dateien: &mut Dateien,
s: &str,
) -> Result<(), RuntimeError> {
match self.ziel {
Ziel::Bildschirm => screen.print(s),
Ziel::Drucker => {
self.drucker.push_str(s);
self.spalte_nachfuehren(s);
}
Ziel::Datei(n) => {
dateien.get(n)?.schreiben(s)?;
self.spalte_nachfuehren(s);
}
}
Ok(())
}
/// Spalte nach ausgegebenem Text nachführen (nur für Nicht-Bildschirm).
fn spalte_nachfuehren(&mut self, s: &str) {
match s.rfind('\n') {
Some(i) => self.col = s[i + 1..].chars().count(),
None => self.col += s.chars().count(),
Some(i) => self.spalte = s[i + 1..].chars().count(),
None => self.spalte += s.chars().count(),
}
}
/// Ein PRINT-Element: Zahlen mit Vorzeichenspalte und nachgestelltem
/// Leerzeichen, Strings unverändert.
pub fn print_value(&mut self, host: &mut dyn Host, v: &Value) {
match v {
Value::Str(s) => {
let s = s.clone();
self.write(host, &s);
}
_ => {
let s = format_print(v);
self.write(host, &s);
}
}
pub fn print_value(
&mut self,
screen: &mut TextScreen,
dateien: &mut Dateien,
v: &Value,
) -> Result<(), RuntimeError> {
let text = match v {
Value::Str(s) => s.to_string(),
_ => format_print(v),
};
self.write(screen, dateien, &text)
}
/// `,` — Sprung zur nächsten Druckzone (belegte Zone → übernächste).
pub fn print_comma(&mut self, host: &mut dyn Host) {
let next = (self.col / ZONE_WIDTH + 1) * ZONE_WIDTH;
let pad = next - self.col;
self.write(host, &" ".repeat(pad));
pub fn print_comma(
&mut self,
screen: &mut TextScreen,
dateien: &mut Dateien,
) -> Result<(), RuntimeError> {
let col = self.col(screen);
let next = (col / ZONE_WIDTH + 1) * ZONE_WIDTH;
self.write(screen, dateien, &" ".repeat(next - col))
}
/// `TAB(n)` — zur Spalte n (1-basiert); liegt der Cursor bereits
/// dahinter, zuerst Zeilenumbruch (Vorbild).
pub fn print_tab(&mut self, host: &mut dyn Host, n: i32) {
pub fn print_tab(
&mut self,
screen: &mut TextScreen,
dateien: &mut Dateien,
n: i32,
) -> Result<(), RuntimeError> {
let target = (n.max(1) as usize) - 1;
if self.col > target {
self.write(host, "\n");
if self.col(screen) > target {
self.write(screen, dateien, "\n")?;
}
if target > self.col {
let pad = target - self.col;
self.write(host, &" ".repeat(pad));
let col = self.col(screen);
if target > col {
self.write(screen, dateien, &" ".repeat(target - col))?;
}
Ok(())
}
/// `SPC(n)` — n Leerzeichen.
pub fn print_spc(&mut self, host: &mut dyn Host, n: i32) {
pub fn print_spc(
&mut self,
screen: &mut TextScreen,
dateien: &mut Dateien,
n: i32,
) -> Result<(), RuntimeError> {
if n > 0 {
self.write(host, &" ".repeat(n as usize));
self.write(screen, dateien, &" ".repeat(n as usize))?;
}
Ok(())
}
pub fn print_newline(&mut self, host: &mut dyn Host) {
self.write(host, "\n");
pub fn print_newline(
&mut self,
screen: &mut TextScreen,
dateien: &mut Dateien,
) -> Result<(), RuntimeError> {
self.write(screen, dateien, "\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::CaptureHost;
/// Zeile `z` (1-basiert) als getrimmter Text.
fn zeile(s: &TextScreen, z: usize) -> String {
let t: String = (1..=s.cols()).map(|c| s.cell(z, c).ch).collect();
t.trim_end().to_string()
}
fn ps() -> (PrintState, TextScreen, Dateien) {
(PrintState::default(), TextScreen::new(), Dateien::default())
}
#[test]
fn druckzonen() {
let mut ps = PrintState::default();
let mut h = CaptureHost::default();
// "a" (1 Zeichen) , → Spalte 14
ps.write(&mut h, "a");
ps.print_comma(&mut h);
ps.write(&mut h, "b");
assert_eq!(h.output, "a b");
assert_eq!(ps.col, 15);
let (mut ps, mut s, mut d) = ps();
// "a" (1 Zeichen) , → Spalte 15 (1-basiert)
ps.write(&mut s, &mut d, "a").unwrap();
ps.print_comma(&mut s, &mut d).unwrap();
ps.write(&mut s, &mut d, "b").unwrap();
assert_eq!(zeile(&s, 1), "a b");
assert_eq!(s.pos(), 16);
}
#[test]
fn volle_zone_springt_zur_uebernaechsten() {
let mut ps = PrintState::default();
let mut h = CaptureHost::default();
ps.write(&mut h, "12345678901234"); // 14 Zeichen, Zone voll
ps.print_comma(&mut h);
ps.write(&mut h, "x");
// x beginnt in Spalte 29 (1-basiert) = Index 28
assert_eq!(h.output.chars().count(), 29);
assert!(h.output.ends_with("x"));
let (mut ps, mut s, mut d) = ps();
ps.write(&mut s, &mut d, "12345678901234").unwrap(); // Zone voll
ps.print_comma(&mut s, &mut d).unwrap();
ps.write(&mut s, &mut d, "x").unwrap();
// x beginnt in Spalte 29 (1-basiert)
assert_eq!(s.cell(1, 29).ch, 'x');
assert_eq!(s.pos(), 30);
}
#[test]
fn zahlen_mit_vorzeichenspalte() {
let mut ps = PrintState::default();
let mut h = CaptureHost::default();
ps.print_value(&mut h, &Value::Int(1));
ps.print_value(&mut h, &Value::Int(-2));
ps.print_newline(&mut h);
assert_eq!(h.output, " 1 -2 \n");
let (mut ps, mut s, mut d) = ps();
ps.print_value(&mut s, &mut d, &Value::Int(1)).unwrap();
ps.print_value(&mut s, &mut d, &Value::Int(-2)).unwrap();
ps.print_newline(&mut s, &mut d).unwrap();
assert_eq!(zeile(&s, 1), " 1 -2");
assert_eq!(s.csrlin(), 2);
}
#[test]
fn druckzone_folgt_dem_cursor_nach_locate() {
let (mut ps, mut s, mut d) = ps();
s.locate(3, 5).unwrap();
ps.print_comma(&mut s, &mut d).unwrap(); // Spalte 4 → nächste Zone 14
assert_eq!(s.pos(), 15);
}
}

View File

@@ -1,3 +1,199 @@
//! Datums- und Zeitfunktionen der Laufzeitbibliothek.
//!
//! Serielle Datumswerte folgen der Zählweise des Vorbilds: der ganzzahlige
//! Anteil zählt Tage ab dem 30.12.1899 (Serie 2 = 1.1.1900), der
//! Nachkommaanteil ist die Tageszeit. `WEEKDAY` liefert 1 für Sonntag.
//!
//! ponytail: Die Uhr läuft in UTC — die Standardbibliothek kennt keine
//! Zeitzonen, und eine Zeitzonenbibliothek wäre die erste schwere
//! Abhängigkeit der Laufzeit. Die Abweichung steht in der Sprachreferenz.
//! Ceiling: sobald lokale Zeit gefordert ist, hier eine Zeitzonenquelle
//! einhängen — der Rest des Moduls rechnet bereits auf Sekunden seit Epoche.
// Platzhalter — wird in Phase 3 ausgearbeitet (siehe PLAN.md)
/// Tage zwischen dem 1.1.1970 (Unix) und dem 30.12.1899 (Serie 0).
const EPOCH_OFFSET_TAGE: i64 = 25_569;
/// Tage → (Jahr, Monat, Tag) ab Unix-Epoche (Howard Hinnant).
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(y + i64::from(m <= 2), m, d)
}
/// (Jahr, Monat, Tag) → Tage ab Unix-Epoche.
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = y - i64::from(m <= 2);
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let mp = if m > 2 { m - 3 } else { m + 9 } as u64;
let doy = (153 * mp + 2) / 5 + u64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64 - 719_468
}
/// Gültige Kalenderangabe? (Monat 112, Tag im Monat.)
pub fn gueltig(y: i64, m: i64, d: i64) -> bool {
if !(1..=12).contains(&m) || d < 1 {
return false;
}
let tage = tage_im_monat(y, m as u32);
d <= tage as i64
}
fn tage_im_monat(y: i64, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if schaltjahr(y) => 29,
2 => 28,
_ => 0,
}
}
fn schaltjahr(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
/// Serieller Wert aus Jahr/Monat/Tag.
pub fn serial_aus_ymd(y: i64, m: u32, d: u32) -> f64 {
(days_from_civil(y, m, d) + EPOCH_OFFSET_TAGE) as f64
}
/// (Jahr, Monat, Tag) aus einem seriellen Wert.
pub fn ymd_aus_serial(s: f64) -> (i64, u32, u32) {
civil_from_days(s.floor() as i64 - EPOCH_OFFSET_TAGE)
}
/// Tageszeit eines seriellen Werts als (Stunde, Minute, Sekunde).
pub fn hms_aus_serial(s: f64) -> (u32, u32, u32) {
// Auf die Sekunde runden, damit 0.5 exakt 12:00:00 ergibt.
let sekunden = ((s - s.floor()) * 86_400.0).round() as i64;
let sekunden = sekunden.rem_euclid(86_400);
(
(sekunden / 3600) as u32,
((sekunden / 60) % 60) as u32,
(sekunden % 60) as u32,
)
}
/// Wochentag: 1 = Sonntag … 7 = Samstag.
pub fn weekday(s: f64) -> u32 {
// Der 1.1.1970 war ein Donnerstag (Index 4 in dieser Zählung).
let tage = s.floor() as i64 - EPOCH_OFFSET_TAGE;
(tage.rem_euclid(7) as u32 + 4) % 7 + 1
}
/// Tageszeit als serieller Bruchteil.
pub fn serial_aus_hms(h: i64, m: i64, s: i64) -> f64 {
(h * 3600 + m * 60 + s) as f64 / 86_400.0
}
/// Sekunden seit der Unix-Epoche (UTC).
pub fn jetzt_sekunden() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// `NOW` — aktueller Zeitpunkt als serieller Wert.
pub fn jetzt_serial(offset_sekunden: i64) -> f64 {
let s = jetzt_sekunden() + offset_sekunden;
s as f64 / 86_400.0 + EPOCH_OFFSET_TAGE as f64
}
/// `DATEVALUE` — Datum aus einem Text lesen (`MM-TT-JJJJ`, `MM/TT/JJJJ`,
/// zweistelliges Jahr wird als 19xx/20xx gedeutet wie beim Vorbild).
pub fn datevalue(t: &str) -> Option<f64> {
let teile: Vec<&str> = t
.trim()
.split(['-', '/', '.'])
.filter(|p| !p.is_empty())
.collect();
if teile.len() != 3 {
return None;
}
let m: i64 = teile[0].trim().parse().ok()?;
let d: i64 = teile[1].trim().parse().ok()?;
let mut y: i64 = teile[2].trim().parse().ok()?;
if y < 100 {
y += if y < 80 { 2000 } else { 1900 };
}
if !gueltig(y, m, d) {
return None;
}
Some(serial_aus_ymd(y, m as u32, d as u32))
}
/// `TIMEVALUE` — Tageszeit aus einem Text lesen (`HH:MM[:SS]`).
pub fn timevalue(t: &str) -> Option<f64> {
let teile: Vec<&str> = t.trim().split(':').collect();
if !(2..=3).contains(&teile.len()) {
return None;
}
let h: i64 = teile[0].trim().parse().ok()?;
let m: i64 = teile[1].trim().parse().ok()?;
let s: i64 = teile.get(2).map_or(Ok(0), |x| x.trim().parse()).ok()?;
if !(0..24).contains(&h) || !(0..60).contains(&m) || !(0..60).contains(&s) {
return None;
}
Some(serial_aus_hms(h, m, s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serienwert_und_rueckumwandlung() {
let s = serial_aus_ymd(1992, 3, 15);
assert_eq!(ymd_aus_serial(s), (1992, 3, 15));
}
#[test]
fn zaehlweise_des_vorbilds() {
// Serie 2 = 1. Januar 1900.
assert_eq!(serial_aus_ymd(1900, 1, 1), 2.0);
assert_eq!(ymd_aus_serial(2.0), (1900, 1, 1));
}
#[test]
fn wochentag_sonntag_ist_eins() {
// 15. März 1992 war ein Sonntag.
assert_eq!(weekday(serial_aus_ymd(1992, 3, 15)), 1);
// 16. März 1992 war ein Montag.
assert_eq!(weekday(serial_aus_ymd(1992, 3, 16)), 2);
}
#[test]
fn tageszeit() {
let s = serial_aus_hms(13, 45, 30);
assert_eq!(hms_aus_serial(s), (13, 45, 30));
assert_eq!(hms_aus_serial(0.5), (12, 0, 0));
}
#[test]
fn gueltigkeitspruefung() {
assert!(gueltig(1992, 2, 29)); // Schaltjahr
assert!(!gueltig(1993, 2, 29));
assert!(!gueltig(1992, 13, 1));
assert!(!gueltig(1992, 4, 31));
}
#[test]
fn text_in_serienwert() {
assert_eq!(datevalue("03-15-1992"), Some(serial_aus_ymd(1992, 3, 15)));
assert_eq!(datevalue("3/15/92"), Some(serial_aus_ymd(1992, 3, 15)));
assert_eq!(datevalue("13-01-1992"), None);
assert_eq!(timevalue("13:45:30"), Some(serial_aus_hms(13, 45, 30)));
assert_eq!(timevalue("13:45"), Some(serial_aus_hms(13, 45, 0)));
assert_eq!(timevalue("25:00"), None);
}
}

View File

@@ -1,3 +1,660 @@
//! Datei-E/A der Laufzeitbibliothek (sequenziell, Random Access, binär).
//! Datei-E/A: `OPEN`/`CLOSE`, sequenzieller, wahlfreier und binärer Zugriff,
//! Recordpuffer mit Festtextfeldern, Positions- und Statusfunktionen sowie
//! die Dateisystem-Anweisungen.
//!
//! Feste Strings in Recordpuffern liegen als **UTF-32** (4 Bytes je Zeichen),
//! damit `STRING * n` unabhängig vom Inhalt genau `4 · n` Bytes belegt und
//! die Recordlänge konstant bleibt (Entscheidung 2026-09-02). Binärdateien
//! sind dadurch bewusst nicht mit denen des Vorbilds austauschbar.
// Platzhalter — wird in Phase 3 ausgearbeitet (siehe PLAN.md)
use std::collections::BTreeMap;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::errors::RuntimeError;
/// Zugriffsart einer offenen Datei.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Modus {
Input,
Output,
Append,
Random,
Binary,
}
impl Modus {
/// Aus der Kurzsyntax `OPEN "I", #1, …`.
pub fn aus_text(s: &str) -> Option<Modus> {
match s.trim().to_ascii_uppercase().chars().next()? {
'I' => Some(Modus::Input),
'O' => Some(Modus::Output),
'A' => Some(Modus::Append),
'R' => Some(Modus::Random),
'B' => Some(Modus::Binary),
_ => None,
}
}
}
/// Ein Feld des Recordpuffers (`FIELD`): die gebundene Variable und ihr
/// Bereich im Puffer.
#[derive(Debug, Clone)]
pub struct Feld {
pub referenz: crate::value::VarRef,
pub start: usize,
pub laenge: usize,
}
/// Eine offene Datei.
#[derive(Debug)]
pub struct Datei {
pub modus: Modus,
pub pfad: PathBuf,
datei: File,
/// Recordlänge (`RANDOM`); bei `BINARY` immer 1.
pub reclen: usize,
/// Recordpuffer für `FIELD`/`GET`/`PUT`.
pub puffer: Vec<u8>,
pub felder: Vec<Feld>,
/// Nächste Position: Datensatz bei `RANDOM`, Byte bei `BINARY`,
/// jeweils 1-basiert.
pub position: u64,
/// Zeilenpuffer für sequenzielles Lesen.
leser: Option<BufReader<File>>,
}
impl Datei {
fn oeffnen(pfad: &Path, modus: Modus, reclen: usize) -> Result<Datei, RuntimeError> {
let mut opt = OpenOptions::new();
match modus {
Modus::Input => {
opt.read(true);
}
Modus::Output => {
opt.write(true).create(true).truncate(true);
}
Modus::Append => {
opt.write(true).create(true).append(true);
}
Modus::Random | Modus::Binary => {
opt.read(true).write(true).create(true);
}
}
let datei = opt.open(pfad).map_err(fehler_aus_io)?;
let leser = match modus {
Modus::Input => Some(BufReader::new(
File::open(pfad).map_err(fehler_aus_io)?,
)),
_ => None,
};
let reclen = if modus == Modus::Random { reclen.max(1) } else { 1 };
Ok(Datei {
modus,
pfad: pfad.to_path_buf(),
datei,
reclen,
puffer: vec![b' '; reclen],
felder: Vec::new(),
position: 1,
leser,
})
}
/// Dateigröße in Bytes.
pub fn laenge(&self) -> Result<u64, RuntimeError> {
self.datei.metadata().map(|m| m.len()).map_err(fehler_aus_io)
}
/// `EOF` — bei sequenziellem Lesen: nichts mehr im Puffer; sonst:
/// Position hinter dem letzten Datensatz bzw. Byte.
pub fn eof(&mut self) -> Result<bool, RuntimeError> {
match self.modus {
Modus::Input => {
let l = self.leser.as_mut().ok_or(RuntimeError(52))?;
Ok(l.fill_buf().map_err(fehler_aus_io)?.is_empty())
}
Modus::Random => Ok((self.position - 1) * self.reclen as u64 >= self.laenge()?),
Modus::Binary => Ok(self.position > self.laenge()?),
// Beim Schreiben ist das Ende immer erreicht.
Modus::Output | Modus::Append => Ok(true),
}
}
/// Text anhängen (`PRINT #`, `WRITE #`).
pub fn schreiben(&mut self, text: &str) -> Result<(), RuntimeError> {
if !matches!(self.modus, Modus::Output | Modus::Append) {
return Err(RuntimeError(54)); // Bad file mode
}
self.datei
.write_all(text.as_bytes())
.map_err(fehler_aus_io)
}
/// Eine Zeile lesen (ohne Zeilenende); `None` = Dateiende.
pub fn zeile_lesen(&mut self) -> Result<Option<String>, RuntimeError> {
if self.modus != Modus::Input {
return Err(RuntimeError(54));
}
let l = self.leser.as_mut().ok_or(RuntimeError(52))?;
let mut roh = Vec::new();
let n = l.read_until(b'\n', &mut roh).map_err(fehler_aus_io)?;
if n == 0 {
return Ok(None);
}
while roh.last() == Some(&b'\n') || roh.last() == Some(&b'\r') {
roh.pop();
}
Ok(Some(String::from_utf8_lossy(&roh).into_owned()))
}
/// Datensatz `nr` (1-basiert) in den Puffer lesen.
pub fn record_lesen(&mut self, nr: u64) -> Result<(), RuntimeError> {
let offset = (nr - 1) * self.reclen as u64;
self.datei
.seek(SeekFrom::Start(offset))
.map_err(fehler_aus_io)?;
self.puffer = vec![b' '; self.reclen];
// Ein unvollständig gelesener Satz bleibt mit Leerzeichen gefüllt.
let mut gelesen = 0;
while gelesen < self.reclen {
match self.datei.read(&mut self.puffer[gelesen..]) {
Ok(0) => break,
Ok(n) => gelesen += n,
Err(e) => return Err(fehler_aus_io(e)),
}
}
self.position = nr + 1;
Ok(())
}
/// Puffer als Datensatz `nr` schreiben.
pub fn record_schreiben(&mut self, nr: u64) -> Result<(), RuntimeError> {
let offset = (nr - 1) * self.reclen as u64;
self.datei
.seek(SeekFrom::Start(offset))
.map_err(fehler_aus_io)?;
let puffer = std::mem::take(&mut self.puffer);
self.datei.write_all(&puffer).map_err(fehler_aus_io)?;
self.puffer = puffer;
self.position = nr + 1;
Ok(())
}
/// `n` Bytes ab Position `pos` (1-basiert) lesen.
pub fn bytes_lesen(&mut self, pos: u64, n: usize) -> Result<Vec<u8>, RuntimeError> {
self.datei
.seek(SeekFrom::Start(pos - 1))
.map_err(fehler_aus_io)?;
let mut buf = vec![0u8; n];
let mut gelesen = 0;
while gelesen < n {
match self.datei.read(&mut buf[gelesen..]) {
Ok(0) => break,
Ok(k) => gelesen += k,
Err(e) => return Err(fehler_aus_io(e)),
}
}
if gelesen < n {
return Err(RuntimeError(62)); // Input past end of file
}
self.position = pos + n as u64;
Ok(buf)
}
/// Bytes ab Position `pos` (1-basiert) schreiben.
pub fn bytes_schreiben(&mut self, pos: u64, daten: &[u8]) -> Result<(), RuntimeError> {
self.datei
.seek(SeekFrom::Start(pos - 1))
.map_err(fehler_aus_io)?;
self.datei.write_all(daten).map_err(fehler_aus_io)?;
self.position = pos + daten.len() as u64;
Ok(())
}
fn schliessen(&mut self) -> Result<(), RuntimeError> {
self.datei.flush().map_err(fehler_aus_io)
}
}
/// Tabelle der offenen Dateien, nach Dateinummer.
#[derive(Debug, Default)]
pub struct Dateien {
offen: BTreeMap<i32, Datei>,
}
impl Dateien {
/// `OPEN` — Datei an eine Nummer binden.
pub fn oeffnen(
&mut self,
nummer: i32,
pfad: &str,
modus: Modus,
reclen: usize,
) -> Result<(), RuntimeError> {
if nummer < 1 {
return Err(RuntimeError(52)); // Bad file name or number
}
if self.offen.contains_key(&nummer) {
return Err(RuntimeError(55)); // File already open
}
let p = pfad_normieren(pfad);
if modus == Modus::Input && !p.exists() {
return Err(RuntimeError(53)); // File not found
}
self.offen.insert(nummer, Datei::oeffnen(&p, modus, reclen)?);
Ok(())
}
pub fn get(&mut self, nummer: i32) -> Result<&mut Datei, RuntimeError> {
self.offen.get_mut(&nummer).ok_or(RuntimeError(52))
}
pub fn ist_offen(&self, nummer: i32) -> bool {
self.offen.contains_key(&nummer)
}
/// `CLOSE #n` — eine Datei schließen.
pub fn schliessen(&mut self, nummer: i32) -> Result<(), RuntimeError> {
match self.offen.remove(&nummer) {
Some(mut d) => d.schliessen(),
None => Ok(()), // CLOSE auf eine geschlossene Nummer ist folgenlos
}
}
/// `CLOSE` / `RESET` — alle Dateien schließen.
pub fn alle_schliessen(&mut self) -> Result<(), RuntimeError> {
let nummern: Vec<i32> = self.offen.keys().copied().collect();
for n in nummern {
self.schliessen(n)?;
}
Ok(())
}
/// Zu welcher Datei und welchem Bereich gehört diese Variable?
pub fn feld_finden(&self, r: &crate::value::VarRef) -> Option<(i32, usize, usize)> {
for (n, d) in &self.offen {
for f in &d.felder {
if gleiche_referenz(&f.referenz, r) {
return Some((*n, f.start, f.laenge));
}
}
}
None
}
/// `FREEFILE` — kleinste freie Nummer.
pub fn freie_nummer(&self) -> i32 {
(1..).find(|n| !self.offen.contains_key(n)).unwrap_or(1)
}
}
/// Zeigen zwei Referenzen auf denselben Speicherort?
fn gleiche_referenz(a: &crate::value::VarRef, b: &crate::value::VarRef) -> bool {
use crate::value::VarRef::*;
match (a, b) {
(Global(x), Global(y)) => x == y,
(Stack(x), Stack(y)) => x == y,
(Elem(x, i), Elem(y, j)) => std::rc::Rc::ptr_eq(x, y) && i == j,
(Field(x, p), Field(y, q)) => std::rc::Rc::ptr_eq(x, y) && p == q,
_ => false,
}
}
// ---- Recordpuffer: feste Strings als UTF-32 ---------------------------------
/// Bytes je Zeichen eines festen Strings im Recordpuffer.
pub const UTF32_BREITE: usize = 4;
/// Festen String in den Puffer schreiben: links- bzw. rechtsbündig,
/// überlange Werte werden abgeschnitten.
pub fn feld_setzen(puffer: &mut [u8], start: usize, laenge: usize, wert: &str, rechts: bool) {
let zeichen: Vec<char> = wert.chars().take(laenge).collect();
let fehlt = laenge - zeichen.len();
let mut alle: Vec<char> = Vec::with_capacity(laenge);
if rechts {
alle.extend(std::iter::repeat_n(' ', fehlt));
alle.extend(zeichen);
} else {
alle.extend(zeichen);
alle.extend(std::iter::repeat_n(' ', fehlt));
}
for (i, c) in alle.iter().enumerate() {
let off = start + i * UTF32_BREITE;
if off + UTF32_BREITE <= puffer.len() {
puffer[off..off + UTF32_BREITE].copy_from_slice(&(*c as u32).to_le_bytes());
}
}
}
/// Festen String aus dem Puffer lesen.
pub fn feld_lesen(puffer: &[u8], start: usize, laenge: usize) -> String {
let mut s = String::with_capacity(laenge);
for i in 0..laenge {
let off = start + i * UTF32_BREITE;
if off + UTF32_BREITE > puffer.len() {
break;
}
let code = u32::from_le_bytes([
puffer[off],
puffer[off + 1],
puffer[off + 2],
puffer[off + 3],
]);
s.push(char::from_u32(code).unwrap_or(' '));
}
s
}
// ---- Werte im Recordpuffer --------------------------------------------------
use crate::value::{RecordObj, TypeInit, UdtLayout, Value};
/// Bytebreite eines Feldtyps im Recordpuffer. Feste Strings zählen
/// `UTF32_BREITE` Bytes je Zeichen; Strings variabler Länge sind in Records
/// nicht zulässig (Vorbild: nur `STRING * n`).
pub fn breite(t: &TypeInit, layouts: &[UdtLayout]) -> Option<usize> {
Some(match t {
TypeInit::Int => 2,
TypeInit::Lng | TypeInit::Sng => 4,
TypeInit::Dbl | TypeInit::Cur => 8,
TypeInit::FixedStr(n) => *n as usize * UTF32_BREITE,
TypeInit::Udt(i) => {
let felder = &layouts.get(*i as usize)?.fields;
let mut summe = 0;
for f in felder {
summe += breite(f, layouts)?;
}
summe
}
TypeInit::Str | TypeInit::Empty => return None,
})
}
/// Wert in den Puffer schreiben; liefert die belegte Bytezahl.
pub fn wert_schreiben(
puffer: &mut [u8],
offset: usize,
wert: &Value,
typ: &TypeInit,
layouts: &[UdtLayout],
) -> Result<usize, RuntimeError> {
let n = breite(typ, layouts).ok_or(RuntimeError::TYPE_MISMATCH)?;
if offset + n > puffer.len() {
return Err(RuntimeError(59)); // Bad record length
}
match (typ, wert) {
(TypeInit::Int, v) => puffer[offset..offset + 2]
.copy_from_slice(&(crate::value::as_f64(v) as i16).to_le_bytes()),
(TypeInit::Lng, v) => puffer[offset..offset + 4]
.copy_from_slice(&(crate::value::as_f64(v) as i32).to_le_bytes()),
(TypeInit::Sng, v) => puffer[offset..offset + 4]
.copy_from_slice(&(crate::value::as_f64(v) as f32).to_le_bytes()),
(TypeInit::Dbl, v) => {
puffer[offset..offset + 8].copy_from_slice(&crate::value::as_f64(v).to_le_bytes())
}
(TypeInit::Cur, Value::Cur(c)) => {
puffer[offset..offset + 8].copy_from_slice(&c.to_le_bytes())
}
(TypeInit::Cur, v) => puffer[offset..offset + 8]
.copy_from_slice(&crate::value::f64_to_cur(crate::value::as_f64(v))?.to_le_bytes()),
(TypeInit::FixedStr(len), Value::Str(s)) => {
feld_setzen(puffer, offset, *len as usize, s, false)
}
(TypeInit::Udt(i), Value::Rec(r)) => {
let felder = &layouts.get(*i as usize).ok_or(RuntimeError::TYPE_MISMATCH)?.fields;
let rec = r.borrow();
let mut off = offset;
for (ft, fv) in felder.iter().zip(rec.fields.iter()) {
off += wert_schreiben(puffer, off, fv, ft, layouts)?;
}
}
_ => return Err(RuntimeError::TYPE_MISMATCH),
}
Ok(n)
}
/// Wert aus dem Puffer lesen.
pub fn wert_lesen(
puffer: &[u8],
offset: usize,
typ: &TypeInit,
layouts: &[UdtLayout],
) -> Result<(Value, usize), RuntimeError> {
let n = breite(typ, layouts).ok_or(RuntimeError::TYPE_MISMATCH)?;
if offset + n > puffer.len() {
return Err(RuntimeError(59));
}
let b = &puffer[offset..offset + n];
let v = match typ {
TypeInit::Int => Value::Int(i16::from_le_bytes([b[0], b[1]])),
TypeInit::Lng => Value::Lng(i32::from_le_bytes([b[0], b[1], b[2], b[3]])),
TypeInit::Sng => Value::Sng(f32::from_le_bytes([b[0], b[1], b[2], b[3]])),
TypeInit::Dbl => Value::Dbl(f64::from_le_bytes(b[..8].try_into().unwrap())),
TypeInit::Cur => Value::Cur(i64::from_le_bytes(b[..8].try_into().unwrap())),
TypeInit::FixedStr(len) => {
Value::Str(std::rc::Rc::from(feld_lesen(puffer, offset, *len as usize).as_str()))
}
TypeInit::Udt(i) => {
let felder = &layouts.get(*i as usize).ok_or(RuntimeError::TYPE_MISMATCH)?.fields;
let mut off = offset;
let mut werte = Vec::with_capacity(felder.len());
for ft in felder {
let (v, k) = wert_lesen(puffer, off, ft, layouts)?;
werte.push(v);
off += k;
}
Value::Rec(std::rc::Rc::new(std::cell::RefCell::new(RecordObj {
fields: werte,
})))
}
TypeInit::Str | TypeInit::Empty => return Err(RuntimeError::TYPE_MISMATCH),
};
Ok((v, n))
}
// ---- Pfade und Dateisystem --------------------------------------------------
/// Pfadtrennzeichen des Vorbilds (`\`) auf die Plattform abbilden.
pub fn pfad_normieren(p: &str) -> PathBuf {
if std::path::MAIN_SEPARATOR == '\\' {
PathBuf::from(p)
} else {
PathBuf::from(p.replace('\\', "/"))
}
}
fn fehler_aus_io(e: std::io::Error) -> RuntimeError {
use std::io::ErrorKind::*;
match e.kind() {
NotFound => RuntimeError(53), // File not found
PermissionDenied => RuntimeError(70), // Permission denied
AlreadyExists => RuntimeError(58), // File already exists
_ => RuntimeError(57), // Device I/O error
}
}
/// `KILL` — Datei löschen.
pub fn loeschen(pfad: &str) -> Result<(), RuntimeError> {
std::fs::remove_file(pfad_normieren(pfad)).map_err(fehler_aus_io)
}
/// `NAME alt AS neu`.
pub fn umbenennen(alt: &str, neu: &str) -> Result<(), RuntimeError> {
let (a, n) = (pfad_normieren(alt), pfad_normieren(neu));
if !a.exists() {
return Err(RuntimeError(53));
}
if n.exists() {
return Err(RuntimeError(58)); // File already exists
}
std::fs::rename(a, n).map_err(fehler_aus_io)
}
/// `MKDIR` / `RMDIR` / `CHDIR`.
pub fn verzeichnis_anlegen(pfad: &str) -> Result<(), RuntimeError> {
let p = pfad_normieren(pfad);
if p.parent().is_some_and(|e| !e.as_os_str().is_empty() && !e.exists()) {
return Err(RuntimeError(76)); // Path not found
}
std::fs::create_dir(p).map_err(fehler_aus_io)
}
pub fn verzeichnis_entfernen(pfad: &str) -> Result<(), RuntimeError> {
std::fs::remove_dir(pfad_normieren(pfad)).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => RuntimeError(76),
_ => fehler_aus_io(e),
})
}
pub fn verzeichnis_wechseln(pfad: &str) -> Result<(), RuntimeError> {
std::env::set_current_dir(pfad_normieren(pfad)).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => RuntimeError(76),
_ => fehler_aus_io(e),
})
}
/// `CURDIR$`.
pub fn aktuelles_verzeichnis() -> String {
std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Verzeichnissuche für `DIR$` und `FILES`: alle Namen, die auf das Muster
/// passen (`*` und `?` wie beim Vorbild).
pub fn suchen(muster: &str) -> Result<Vec<String>, RuntimeError> {
let p = pfad_normieren(muster);
let (verzeichnis, maske) = match p.file_name() {
Some(n) => (
p.parent()
.filter(|e| !e.as_os_str().is_empty())
.map(|e| e.to_path_buf())
.unwrap_or_else(|| PathBuf::from(".")),
n.to_string_lossy().into_owned(),
),
None => (p.clone(), "*".to_string()),
};
let eintraege = std::fs::read_dir(&verzeichnis).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => RuntimeError(76),
_ => fehler_aus_io(e),
})?;
let mut namen: Vec<String> = eintraege
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| passt(n, &maske))
.collect();
namen.sort();
Ok(namen)
}
/// Musterabgleich mit `*` (beliebig viele Zeichen) und `?` (genau eines).
pub fn passt(name: &str, muster: &str) -> bool {
let n: Vec<char> = name.chars().collect();
let m: Vec<char> = muster.chars().collect();
fn rek(n: &[char], m: &[char]) -> bool {
match m.first() {
None => n.is_empty(),
Some('*') => rek(n, &m[1..]) || (!n.is_empty() && rek(&n[1..], m)),
Some('?') => !n.is_empty() && rek(&n[1..], &m[1..]),
Some(c) => {
!n.is_empty()
&& n[0].eq_ignore_ascii_case(c)
&& rek(&n[1..], &m[1..])
}
}
}
rek(&n, &m)
}
/// Elemente einer `INPUT #`-Zeile trennen: Komma trennt, Anführungszeichen
/// klammern, Leerraum am Rand fällt weg.
pub fn felder_trennen(zeile: &str) -> Vec<String> {
let mut out = Vec::new();
let mut aktuell = String::new();
let mut in_quote = false;
let mut hatte_quote = false;
for c in zeile.chars() {
match c {
'"' if !in_quote && aktuell.trim().is_empty() => {
in_quote = true;
hatte_quote = true;
aktuell.clear();
}
'"' if in_quote => in_quote = false,
',' if !in_quote => {
out.push(if hatte_quote {
std::mem::take(&mut aktuell)
} else {
std::mem::take(&mut aktuell).trim().to_string()
});
hatte_quote = false;
}
_ => aktuell.push(c),
}
}
out.push(if hatte_quote {
aktuell
} else {
aktuell.trim().to_string()
});
out
}
/// Ein Element für `WRITE #` aufbereiten: Strings in Anführungszeichen.
pub fn write_element(text: &str, ist_string: bool) -> String {
if ist_string {
format!("\"{text}\"")
} else {
text.trim().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn muster_mit_stern_und_fragezeichen() {
assert!(passt("test.txt", "*.txt"));
assert!(passt("test.txt", "t?st.txt"));
assert!(!passt("test.txt", "*.bas"));
assert!(passt("TEST.TXT", "*.txt")); // Vorbild: Groß/klein egal
assert!(passt("a", "*"));
}
#[test]
fn festes_feld_ist_inhaltsunabhaengig_gleich_lang() {
let mut p = vec![0u8; 10 * UTF32_BREITE];
feld_setzen(&mut p, 0, 10, "abc", false);
assert_eq!(feld_lesen(&p, 0, 10), "abc ");
feld_setzen(&mut p, 0, 10, "äöüßÄÖÜ", false);
assert_eq!(feld_lesen(&p, 0, 10), "äöüßÄÖÜ ");
// Der Puffer hat in beiden Fällen dieselbe Länge.
assert_eq!(p.len(), 40);
}
#[test]
fn rset_schneidet_ab_und_richtet_rechts_aus() {
let mut p = vec![0u8; 5 * UTF32_BREITE];
feld_setzen(&mut p, 0, 5, "ABCDEFG", true);
assert_eq!(feld_lesen(&p, 0, 5), "ABCDE");
feld_setzen(&mut p, 0, 5, "xy", true);
assert_eq!(feld_lesen(&p, 0, 5), " xy");
}
#[test]
fn felder_einer_input_zeile() {
assert_eq!(felder_trennen("a,b,c"), vec!["a", "b", "c"]);
assert_eq!(felder_trennen(" 1 , 2 "), vec!["1", "2"]);
assert_eq!(felder_trennen("\"a,b\",5"), vec!["a,b", "5"]);
}
#[test]
fn modus_aus_kurzsyntax() {
assert_eq!(Modus::aus_text("I"), Some(Modus::Input));
assert_eq!(Modus::aus_text("output"), Some(Modus::Output));
assert_eq!(Modus::aus_text("x"), None);
}
}

View File

@@ -0,0 +1,377 @@
//! Finanzmathematische Funktionen des Vorbilds (`FV#`, `PV#`, `Pmt#`,
//! `IPmt#`, `PPmt#`, `NPer#`, `Rate#`, `NPV#`, `IRR#`, `MIRR#`, `SLN#`,
//! `SYD#`, `DDB#`).
//!
//! Vorzeichenkonvention wie beim Vorbild: ausgehende Zahlungen sind negativ,
//! eingehende positiv. Das Fälligkeitsargument (`faellig`) ist 0 für
//! nachschüssige und 1 für vorschüssige Zahlung. Gerechnet wird in DOUBLE.
//!
//! `Rate#` und `IRR#` nähern sich iterativ; die Abbruchbedingung steht in
//! [`ITERATIONEN`] und [`TOLERANZ`] und ist in docs/bibliothek.md
//! dokumentiert.
use crate::errors::RuntimeError;
/// Höchstzahl der Näherungsschritte von `Rate#` und `IRR#`.
pub const ITERATIONEN: usize = 100;
/// Abbruchschranke der Näherung (Betrag der Restabweichung).
pub const TOLERANZ: f64 = 1e-9;
type R = Result<f64, RuntimeError>;
fn ungueltig() -> RuntimeError {
RuntimeError::ILLEGAL_FUNCTION_CALL
}
/// `(1 + zins)^perioden`.
fn faktor(zins: f64, perioden: f64) -> f64 {
(1.0 + zins).powf(perioden)
}
/// Barwertfaktor der Rentenzahlungen.
fn rentenfaktor(zins: f64, perioden: f64, faellig: f64) -> f64 {
if zins == 0.0 {
perioden
} else {
(1.0 + zins * faellig) * (faktor(zins, perioden) - 1.0) / zins
}
}
/// `FV#` — Endwert.
pub fn fv(zins: f64, perioden: f64, zahlung: f64, barwert: f64, faellig: f64) -> R {
if zins <= -1.0 {
return Err(ungueltig());
}
Ok(-(barwert * faktor(zins, perioden) + zahlung * rentenfaktor(zins, perioden, faellig)))
}
/// `PV#` — Barwert.
pub fn pv(zins: f64, perioden: f64, zahlung: f64, endwert: f64, faellig: f64) -> R {
if zins <= -1.0 {
return Err(ungueltig());
}
Ok(-(endwert + zahlung * rentenfaktor(zins, perioden, faellig)) / faktor(zins, perioden))
}
/// `Pmt#` — Zahlung je Periode.
pub fn pmt(zins: f64, perioden: f64, barwert: f64, endwert: f64, faellig: f64) -> R {
if perioden == 0.0 || zins <= -1.0 {
return Err(ungueltig());
}
let rf = rentenfaktor(zins, perioden, faellig);
if rf == 0.0 {
return Err(ungueltig());
}
Ok(-(endwert + barwert * faktor(zins, perioden)) / rf)
}
/// `NPer#` — Anzahl Perioden.
pub fn nper(zins: f64, zahlung: f64, barwert: f64, endwert: f64, faellig: f64) -> R {
if zins <= -1.0 {
return Err(ungueltig());
}
if zins == 0.0 {
if zahlung == 0.0 {
return Err(ungueltig());
}
return Ok(-(barwert + endwert) / zahlung);
}
let z = zahlung * (1.0 + zins * faellig);
let zaehler = z - endwert * zins;
let nenner = barwert * zins + z;
if nenner == 0.0 || zaehler / nenner <= 0.0 {
return Err(ungueltig());
}
Ok((zaehler / nenner).ln() / (1.0 + zins).ln())
}
/// `IPmt#` — Zinsanteil der Periode `periode` (1-basiert).
pub fn ipmt(
zins: f64,
periode: f64,
perioden: f64,
barwert: f64,
endwert: f64,
faellig: f64,
) -> R {
if periode < 1.0 || periode > perioden {
return Err(ungueltig());
}
let zahlung = pmt(zins, perioden, barwert, endwert, faellig)?;
if faellig != 0.0 && periode == 1.0 {
// Vorschüssig fällt in der ersten Periode kein Zins an.
return Ok(0.0);
}
let vorher = if faellig != 0.0 {
fv(zins, periode - 2.0, zahlung, barwert, faellig)?
} else {
fv(zins, periode - 1.0, zahlung, barwert, faellig)?
};
Ok(vorher * zins)
}
/// `PPmt#` — Tilgungsanteil der Periode.
pub fn ppmt(
zins: f64,
periode: f64,
perioden: f64,
barwert: f64,
endwert: f64,
faellig: f64,
) -> R {
let zahlung = pmt(zins, perioden, barwert, endwert, faellig)?;
let zinsanteil = ipmt(zins, periode, perioden, barwert, endwert, faellig)?;
Ok(zahlung - zinsanteil)
}
/// `Rate#` — Zinssatz je Periode, iterativ genähert.
pub fn rate(
perioden: f64,
zahlung: f64,
barwert: f64,
endwert: f64,
faellig: f64,
schaetzung: f64,
) -> R {
if perioden <= 0.0 {
return Err(ungueltig());
}
// Sekantenverfahren auf f(r) = FV(r) Sollendwert.
let f = |r: f64| -> Option<f64> {
if r <= -1.0 {
return None;
}
Some(barwert * faktor(r, perioden) + zahlung * rentenfaktor(r, perioden, faellig) + endwert)
};
let mut r0 = schaetzung;
let mut r1 = if schaetzung == 0.0 { 0.1 } else { schaetzung * 1.1 };
let (mut f0, mut f1) = match (f(r0), f(r1)) {
(Some(a), Some(b)) => (a, b),
_ => return Err(ungueltig()),
};
for _ in 0..ITERATIONEN {
if f1.abs() < TOLERANZ {
return Ok(r1);
}
if (f1 - f0).abs() < f64::MIN_POSITIVE {
break;
}
let r2 = r1 - f1 * (r1 - r0) / (f1 - f0);
let Some(f2) = f(r2) else { break };
r0 = r1;
f0 = f1;
r1 = r2;
f1 = f2;
}
if f1.abs() < TOLERANZ * 1e3 {
Ok(r1)
} else {
Err(ungueltig())
}
}
/// `NPV#` — Kapitalwert einer Zahlungsreihe (erste Zahlung am Ende der
/// ersten Periode).
pub fn npv(zins: f64, werte: &[f64]) -> R {
if zins <= -1.0 || werte.is_empty() {
return Err(ungueltig());
}
let mut summe = 0.0;
let mut nenner = 1.0 + zins;
for w in werte {
summe += w / nenner;
nenner *= 1.0 + zins;
}
Ok(summe)
}
/// `IRR#` — interner Zinsfuß, iterativ genähert.
pub fn irr(werte: &[f64], schaetzung: f64) -> R {
// Ohne Vorzeichenwechsel gibt es keine Lösung.
let hat_plus = werte.iter().any(|w| *w > 0.0);
let hat_minus = werte.iter().any(|w| *w < 0.0);
if !hat_plus || !hat_minus {
return Err(ungueltig());
}
let f = |r: f64| -> Option<f64> {
if r <= -1.0 {
return None;
}
let mut summe = 0.0;
let mut nenner = 1.0;
for w in werte {
summe += w / nenner;
nenner *= 1.0 + r;
}
Some(summe)
};
let mut r0 = schaetzung;
let mut r1 = if schaetzung == 0.0 { 0.1 } else { schaetzung * 1.1 };
let (mut f0, mut f1) = match (f(r0), f(r1)) {
(Some(a), Some(b)) => (a, b),
_ => return Err(ungueltig()),
};
for _ in 0..ITERATIONEN {
if f1.abs() < TOLERANZ {
return Ok(r1);
}
if (f1 - f0).abs() < f64::MIN_POSITIVE {
break;
}
let r2 = r1 - f1 * (r1 - r0) / (f1 - f0);
let Some(f2) = f(r2) else { break };
r0 = r1;
f0 = f1;
r1 = r2;
f1 = f2;
}
if f1.abs() < TOLERANZ * 1e3 {
Ok(r1)
} else {
Err(ungueltig())
}
}
/// `MIRR#` — modifizierter interner Zinsfuß.
pub fn mirr(werte: &[f64], finanzierungszins: f64, wiederanlagezins: f64) -> R {
let n = werte.len();
if n < 2 || finanzierungszins <= -1.0 || wiederanlagezins <= -1.0 {
return Err(ungueltig());
}
let negative: Vec<f64> = werte.iter().map(|w| w.min(0.0)).collect();
let positive: Vec<f64> = werte.iter().map(|w| w.max(0.0)).collect();
let bw_neg = npv(finanzierungszins, &negative)?;
let bw_pos = npv(wiederanlagezins, &positive)?;
if bw_neg == 0.0 {
return Err(ungueltig());
}
let n = n as f64;
let verhaeltnis = -bw_pos * (1.0 + wiederanlagezins).powf(n) / (bw_neg * (1.0 + finanzierungszins));
if verhaeltnis <= 0.0 {
return Err(ungueltig());
}
Ok(verhaeltnis.powf(1.0 / (n - 1.0)) - 1.0)
}
/// `SLN#` — lineare Abschreibung.
pub fn sln(anschaffung: f64, restwert: f64, nutzungsdauer: f64) -> R {
if nutzungsdauer == 0.0 {
return Err(ungueltig());
}
Ok((anschaffung - restwert) / nutzungsdauer)
}
/// `SYD#` — arithmetisch-degressive Abschreibung.
pub fn syd(anschaffung: f64, restwert: f64, nutzungsdauer: f64, periode: f64) -> R {
if nutzungsdauer <= 0.0 || periode < 1.0 || periode > nutzungsdauer {
return Err(ungueltig());
}
Ok((anschaffung - restwert) * (nutzungsdauer - periode + 1.0) * 2.0
/ (nutzungsdauer * (nutzungsdauer + 1.0)))
}
/// `DDB#` — geometrisch-degressive Abschreibung (doppelter linearer Satz).
pub fn ddb(anschaffung: f64, restwert: f64, nutzungsdauer: f64, periode: f64) -> R {
if nutzungsdauer <= 0.0 || periode < 1.0 || periode > nutzungsdauer {
return Err(ungueltig());
}
let mut buchwert = anschaffung;
let mut betrag = 0.0;
let satz = 2.0 / nutzungsdauer;
for _ in 0..(periode as u64) {
betrag = (buchwert * satz).min(buchwert - restwert).max(0.0);
buchwert -= betrag;
}
Ok(betrag)
}
#[cfg(test)]
mod tests {
use super::*;
fn nah(a: f64, b: f64) {
assert!((a - b).abs() < 1e-6, "{a} ≠ {b}");
}
#[test]
fn endwert_einer_rentenzahlung() {
// 10 Perioden, 5 % Zins, Zahlung 100 nachschüssig, Barwert 0.
nah(fv(0.05, 10.0, -100.0, 0.0, 0.0).unwrap(), 1257.789253);
}
#[test]
fn barwert_und_endwert_sind_gegenlaeufig() {
let b = pv(0.05, 10.0, -100.0, 0.0, 0.0).unwrap();
nah(fv(0.05, 10.0, -100.0, b, 0.0).unwrap(), 0.0);
}
#[test]
fn zins_und_tilgung_ergeben_die_zahlung() {
let (r, n, bw) = (0.05, 10.0, 1000.0);
let z = pmt(r, n, bw, 0.0, 0.0).unwrap();
for periode in 1..=10 {
let i = ipmt(r, periode as f64, n, bw, 0.0, 0.0).unwrap();
let t = ppmt(r, periode as f64, n, bw, 0.0, 0.0).unwrap();
nah(i + t, z);
}
}
#[test]
fn zinssatz_wird_zurueckgerechnet() {
let z = pmt(0.05, 10.0, 1000.0, 0.0, 0.0).unwrap();
nah(rate(10.0, z, 1000.0, 0.0, 0.0, 0.1).unwrap(), 0.05);
}
#[test]
fn nper_zaehlt_die_perioden() {
let z = pmt(0.05, 10.0, 1000.0, 0.0, 0.0).unwrap();
nah(nper(0.05, z, 1000.0, 0.0, 0.0).unwrap(), 10.0);
}
#[test]
fn kapitalwert_und_interner_zinsfuss() {
let reihe = [-1000.0, 400.0, 400.0, 400.0];
let r = irr(&reihe, 0.1).unwrap();
// Bei r ist der Kapitalwert (inkl. Anfangsauszahlung) null.
let mut summe = 0.0;
let mut nenner = 1.0;
for w in reihe {
summe += w / nenner;
nenner *= 1.0 + r;
}
nah(summe, 0.0);
nah(npv(0.05, &[100.0, 100.0]).unwrap(), 185.941043);
}
#[test]
fn irr_ohne_vorzeichenwechsel_ist_fehler() {
assert!(irr(&[100.0, 200.0], 0.1).is_err());
assert!(irr(&[-100.0, -200.0], 0.1).is_err());
}
#[test]
fn abschreibungen() {
nah(sln(1000.0, 100.0, 10.0).unwrap(), 90.0);
// SYD: Summe über alle Perioden = Abschreibungsvolumen.
let summe: f64 = (1..=10).map(|p| syd(1000.0, 100.0, 10.0, p as f64).unwrap()).sum();
nah(summe, 900.0);
nah(syd(1000.0, 100.0, 10.0, 1.0).unwrap(), 163.636363);
nah(ddb(1000.0, 100.0, 10.0, 1.0).unwrap(), 200.0);
nah(ddb(1000.0, 100.0, 10.0, 2.0).unwrap(), 160.0);
}
#[test]
fn ungueltige_argumente() {
assert!(sln(1.0, 0.0, 0.0).is_err());
assert!(syd(1.0, 0.0, 10.0, 11.0).is_err());
assert!(ipmt(0.05, 11.0, 10.0, 1000.0, 0.0, 0.0).is_err());
assert!(pmt(0.05, 0.0, 1000.0, 0.0, 0.0).is_err());
assert!(nper(0.0, 0.0, 1000.0, 0.0, 0.0).is_err());
}
#[test]
fn mirr_liegt_zwischen_den_zinsen() {
let r = mirr(&[-1000.0, 400.0, 400.0, 400.0], 0.05, 0.06).unwrap();
assert!(r > 0.0 && r < 0.3, "{r}");
}
}

View File

@@ -1,79 +1,117 @@
//! `Host`-Abstraktion für Konsolen-E/A (Design-Entscheidung D3):
//! Alle Konsolenwirkungen der VM laufen über dieses Trait. Host-Aufrufe
//! dürfen blockieren; die Abbruchprüfung (Strg+Untbr) obliegt dem Host.
//! `Host`-Abstraktion für den Textbildschirm.
//!
//! Der Host ist bewusst dumm: er zeigt den Zellenpuffer an und liefert
//! Ereignisse. Alle Bildschirmwirkungen entstehen in der Laufzeitbibliothek
//! auf [`TextScreen`] — ein Zeichenstrom könnte `LOCATE`, `COLOR`, `CSRLIN`
//! oder das Zurücklesen per `SCREEN(z, s)` nicht transportieren
//! (Entscheidung 2026-09-03, D2).
//!
//! Host-Aufrufe dürfen blockieren; kooperative Zustellpunkte bringt erst die
//! Ereignisschleife der Forms-Engine (Phase 4).
use std::collections::VecDeque;
use std::io::{BufRead, Write as _};
use crate::screen::TextScreen;
/// Was vom Terminal hereinkommt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ereignis {
/// Tastendruck in `INKEY$`-Form: ein Zeichen für Zeichentasten,
/// Nullzeichen + Kennung für Sondertasten.
Taste(String),
/// Die Darstellungsfläche hat ihre Größe geändert.
Groesse { cols: usize, rows: usize },
/// Abbruchwunsch (Strg+Untbr).
Abbruch,
/// Eingabeende — es kommt nichts mehr.
Ende,
}
/// Steuerzeichen in `INKEY$`-Form (Kodierung des Vorbilds).
pub mod taste {
pub const ENTER: &str = "\r";
pub const BACKSPACE: &str = "\u{8}";
pub const ESC: &str = "\u{1b}";
pub const TAB: &str = "\t";
/// Sondertaste: Nullzeichen + Kennung (F1 = 59, F2 = 60 …).
pub fn sonder(code: u8) -> String {
format!("\0{}", code as char)
}
}
pub trait Host {
/// Text ausgeben (ohne implizite Zeilenumbrüche).
fn write(&mut self, s: &str);
/// Eine Eingabezeile lesen (ohne Zeilenende); `None` = Eingabeende.
fn read_line(&mut self) -> Option<String>;
/// Abbruchwunsch (Strg+Untbr)? Wird an Anweisungsgrenzen geprüft.
fn interrupted(&mut self) -> bool {
false
}
/// `SLEEP` — blockierend im Host.
fn sleep(&mut self, _secs: f64) {}
/// Aktuellen Bildschirmzustand anzeigen.
fn present(&mut self, screen: &TextScreen);
/// Nächstes Ereignis holen. Mit `blockierend = false` sofort `None`,
/// wenn nichts anliegt (Grundlage von `INKEY$`).
fn next_event(&mut self, blockierend: bool) -> Option<Ereignis>;
}
/// Konsolen-Host für `tbc run`: stdout/stdin.
#[derive(Default)]
pub struct ConsoleHost;
impl Host for ConsoleHost {
fn write(&mut self, s: &str) {
let mut out = std::io::stdout().lock();
let _ = out.write_all(s.as_bytes());
let _ = out.flush();
}
fn read_line(&mut self) -> Option<String> {
let mut line = String::new();
match std::io::stdin().lock().read_line(&mut line) {
Ok(0) => None,
Ok(_) => {
while line.ends_with('\n') || line.ends_with('\r') {
line.pop();
}
Some(line)
}
Err(_) => None,
}
}
fn sleep(&mut self, secs: f64) {
if secs > 0.0 {
std::thread::sleep(std::time::Duration::from_secs_f64(secs));
}
}
}
/// Capture-Host für Tests: zeichnet die Ausgabe byte-genau auf und
/// liefert vorbereitete Eingabezeilen.
/// Host ohne Terminal: hält den Bildschirmzustand fest und speist
/// vorbereitete Ereignisse ein. Grundlage des Korpus-Harness.
#[derive(Default)]
pub struct CaptureHost {
pub output: String,
pub input: VecDeque<String>,
/// Zuletzt angezeigter Bildschirm.
pub screen: Option<TextScreen>,
/// Wie oft angezeigt wurde (Nachweis der Zustellpunkte).
pub presents: usize,
ereignisse: VecDeque<Ereignis>,
/// Ereignisse, die erst nach der n-ten Anzeige eingereiht werden —
/// so lässt sich eine Größenänderung mitten im Lauf nachstellen.
verzoegert: Vec<(usize, Ereignis)>,
}
impl CaptureHost {
/// Host mit vorbereiteten Eingabezeilen: jede Zeile wird zu ihren
/// Zeichen plus Enter.
pub fn with_input(lines: &[&str]) -> Self {
CaptureHost {
output: String::new(),
input: lines.iter().map(|s| s.to_string()).collect(),
let mut h = CaptureHost::default();
for l in lines {
h.tippe(l);
}
h
}
/// Text als Folge von Tastendrücken anhängen, abgeschlossen mit Enter.
pub fn tippe(&mut self, text: &str) {
for c in text.chars() {
self.ereignisse.push_back(Ereignis::Taste(c.to_string()));
}
self.ereignisse
.push_back(Ereignis::Taste(taste::ENTER.to_string()));
}
/// Beliebiges Ereignis einreihen (Größenänderung, Abbruch, Sondertaste).
pub fn ereignis(&mut self, e: Ereignis) {
self.ereignisse.push_back(e);
}
/// Ereignis erst nach der `nach`-ten Anzeige einreihen.
pub fn ereignis_nach(&mut self, nach: usize, e: Ereignis) {
self.verzoegert.push((nach, e));
}
}
impl Host for CaptureHost {
fn write(&mut self, s: &str) {
self.output.push_str(s);
fn present(&mut self, screen: &TextScreen) {
self.screen = Some(screen.clone());
self.presents += 1;
let faellig = self.presents;
let mut i = 0;
while i < self.verzoegert.len() {
if self.verzoegert[i].0 <= faellig {
let (_, e) = self.verzoegert.remove(i);
self.ereignisse.push_back(e);
} else {
i += 1;
}
}
}
fn read_line(&mut self) -> Option<String> {
self.input.pop_front()
fn next_event(&mut self, _blockierend: bool) -> Option<Ereignis> {
self.ereignisse.pop_front()
}
}
@@ -82,11 +120,55 @@ mod tests {
use super::*;
#[test]
fn capture_host_zeichnet_bytegenau_auf() {
fn capture_host_haelt_zeichen_und_attribut() {
let mut h = CaptureHost::default();
h.write(" 1 2 ");
h.write("\n");
h.write("x");
assert_eq!(h.output, " 1 2 \nx");
let mut s = TextScreen::new();
s.locate(5, 10).unwrap();
s.set_color(14, 1);
s.print("x");
h.present(&s);
let gehalten = h.screen.as_ref().unwrap();
let z = gehalten.cell(5, 10);
assert_eq!(z.ch, 'x');
assert_eq!((z.fg, z.bg), (14, 1));
assert_eq!(h.presents, 1);
}
#[test]
fn verzoegertes_ereignis_kommt_nach_der_anzeige() {
let mut h = CaptureHost::default();
h.ereignis_nach(2, Ereignis::Groesse { cols: 120, rows: 40 });
let s = TextScreen::new();
h.present(&s);
assert_eq!(h.next_event(false), None);
h.present(&s);
assert_eq!(
h.next_event(false),
Some(Ereignis::Groesse { cols: 120, rows: 40 })
);
}
#[test]
fn groessenaenderung_und_abbruch_als_ereignis() {
let mut h = CaptureHost::default();
h.ereignis(Ereignis::Groesse { cols: 120, rows: 40 });
h.ereignis(Ereignis::Abbruch);
assert_eq!(
h.next_event(true),
Some(Ereignis::Groesse { cols: 120, rows: 40 })
);
assert_eq!(h.next_event(true), Some(Ereignis::Abbruch));
}
#[test]
fn vorbereitete_zeile_wird_zu_tasten() {
let mut h = CaptureHost::with_input(&["ab"]);
assert_eq!(h.next_event(true), Some(Ereignis::Taste("a".into())));
assert_eq!(h.next_event(true), Some(Ereignis::Taste("b".into())));
assert_eq!(
h.next_event(true),
Some(Ereignis::Taste(taste::ENTER.into()))
);
assert_eq!(h.next_event(false), None);
}
}

View File

@@ -9,7 +9,11 @@ pub mod console; // Druckzustand: Zonen, TAB/SPC, Zahlenausgabe
pub mod errors; // Laufzeitfehler-Codes und -Meldungen des Vorbilds
pub mod fileio; // OPEN/CLOSE/PRINT#/INPUT#/GET/PUT, sequenziell/random/binär
pub mod format; // Zahlendarstellung (PRINT/STR$) und VAL
pub mod host; // Host-Trait (Konsole, Capture) — Entscheidung D3
pub mod host;
pub mod screen;
pub mod finanz; // Finanzmathematik (FV#, PV#, Pmt#, IRR# …)
pub mod using; // PRINT USING / FORMAT$ — Formatzeichenketten
pub mod snapshot; // Bildschirmzustand als vergleichbarer Text (Korpus) // Zellenpuffer des Textbildschirms (ohne Terminalbezug) // Host-Trait (Konsole, Capture) — Entscheidung D3
pub mod datetime; // DATE$, TIME$, TIMER
pub mod math; // Arithmetik, Rundung (Banker's Rounding), RND/RANDOMIZE …
pub mod strings; // LEFT$, MID$, INSTR, STR$, VAL, Formatierung mit PRINT USING …

View File

@@ -0,0 +1,557 @@
//! Textbildschirm-Emulation: Zellenpuffer (Unicode-Zeichen + Farbattribut),
//! Cursor, Scrollen.
//!
//! Der Puffer liegt in der Laufzeitbibliothek und ist frei von jeder
//! Terminal-Abhängigkeit: `PRINT`, `LOCATE`, `CLS`, `COLOR` und `SCREEN()`
//! sind Bibliothekselemente und müssen ihn erreichen, und jedes
//! Bildschirmverhalten bleibt ohne Terminal prüfbar. Die Darstellung liegt in
//! `tb-ui` (`ScreenWidget`).
//!
//! Entscheidungen (siehe PLAN.md):
//! - durchgängig Unicode, keine CP437-Emulation
//! - **dynamische Größe**: der Bildschirm folgt der Terminalgröße; unterhalb
//! der Mindestgröße (80×25) wird nur ein Hinweis gerendert (btop-artig),
//! größere Terminals werden voll genutzt.
//!
//! Zeichen belegen so viele Zellen, wie ihre Darstellungsbreite verlangt
//! (Entscheidung 2026-09-02): schmale Zeichen eine, breite (Emoji, CJK)
//! zwei. Die zweite Zelle ist als Fortsetzung markiert und wird nie
//! eigenständig beschrieben.
use unicode_width::UnicodeWidthChar;
/// Mindestgröße; darunter wird nur ein Hinweis angezeigt.
pub const MIN_COLS: usize = 80;
pub const MIN_ROWS: usize = 25;
/// Eine Bildschirmzelle: Zeichen plus klassisches Farbattribut.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
/// Vordergrund 015 (klassische Palette).
pub fg: u8,
/// Hintergrund 07.
pub bg: u8,
/// Zweite Hälfte eines breiten Zeichens. Solche Zellen tragen kein
/// eigenes Zeichen und werden bei der Darstellung übersprungen.
pub fortsetzung: bool,
}
impl Default for Cell {
fn default() -> Self {
Cell { ch: ' ', fg: 7, bg: 0, fortsetzung: false }
}
}
/// Der emulierte Textbildschirm mit dynamischer Größe.
///
/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile, Spalte),
/// wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts.
#[derive(Debug, Clone)]
pub struct TextScreen {
cols: usize,
rows: usize,
cells: Vec<Cell>,
/// Cursorposition, 0-basiert intern.
cur_row: usize,
cur_col: usize,
pub cursor_visible: bool,
/// Aktuelle Ausgabefarben (`COLOR`).
pub fg: u8,
pub bg: u8,
/// Scrollbereich (`VIEW PRINT`), 0-basiert inklusiv.
view_top: usize,
view_bottom: usize,
/// true, solange kein eigenes `VIEW PRINT` gesetzt ist — der
/// Scrollbereich folgt dann der Bildschirmgröße.
view_full: bool,
/// Je Zeile die größte beschriebene Spalte (0-basiert), `None` für
/// unberührte Zeilen. Grundlage des getrimmten Snapshots: nachgestellte
/// Leerzeichen, die `PRINT` selbst ausgibt, zählen als belegt, ein nie
/// beschriebener Zeilenrest nicht.
belegt: Vec<Option<usize>>,
/// Seit der letzten Anzeige verändert? Der Host wird nur dann zum
/// Neuzeichnen aufgefordert.
veraendert: bool,
}
impl Default for TextScreen {
fn default() -> Self {
Self::new()
}
}
impl TextScreen {
/// Bildschirm in Mindestgröße (80×25).
pub fn new() -> Self {
Self::with_size(MIN_COLS, MIN_ROWS)
}
/// Bildschirm in gegebener Größe (wird auf die Mindestgröße angehoben).
pub fn with_size(cols: usize, rows: usize) -> Self {
let cols = cols.max(MIN_COLS);
let rows = rows.max(MIN_ROWS);
TextScreen {
cols,
rows,
cells: vec![Cell::default(); cols * rows],
cur_row: 0,
cur_col: 0,
cursor_visible: true,
fg: 7,
bg: 0,
view_top: 0,
view_bottom: rows - 1,
view_full: true,
belegt: vec![None; rows],
veraendert: true,
}
}
pub fn cols(&self) -> usize {
self.cols
}
pub fn rows(&self) -> usize {
self.rows
}
/// An neue Terminalgröße anpassen: Inhalt bleibt oben links erhalten,
/// neue Zellen sind leer. Unterhalb der Mindestgröße bleibt der Puffer
/// bei 80×25 (das Widget zeigt dann den Zu-klein-Hinweis).
pub fn resize(&mut self, cols: usize, rows: usize) {
self.veraendert = true;
let cols = cols.max(MIN_COLS);
let rows = rows.max(MIN_ROWS);
if cols == self.cols && rows == self.rows {
return;
}
let mut cells =
vec![Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false }; cols * rows];
for row in 0..self.rows.min(rows) {
for col in 0..self.cols.min(cols) {
cells[row * cols + col] = self.cells[row * self.cols + col];
}
}
self.cells = cells;
let mut belegt = vec![None; rows];
for r in 0..self.rows.min(rows) {
belegt[r] = self.belegt[r].map(|c| c.min(cols - 1));
}
self.belegt = belegt;
self.cols = cols;
self.rows = rows;
self.cur_row = self.cur_row.min(rows - 1);
self.cur_col = self.cur_col.min(cols - 1);
if self.view_full || self.view_top > rows - 1 {
// Ohne eigenes VIEW PRINT folgt der Bereich dem Bildschirm; ein
// Bereich, der ganz vom Schirm gefallen ist, wäre leer und fällt
// auf den vollen Bildschirm zurück.
self.view_top = 0;
self.view_bottom = rows - 1;
self.view_full = true;
} else {
self.view_bottom = self.view_bottom.min(rows - 1);
}
}
/// `CLS`: Scrollbereich mit aktueller Hintergrundfarbe löschen,
/// Cursor an den Anfang des Bereichs.
pub fn cls(&mut self) {
self.veraendert = true;
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
for row in self.view_top..=self.view_bottom {
self.cells[row * self.cols..(row + 1) * self.cols].fill(blank);
}
self.cur_row = self.view_top;
self.cur_col = 0;
for r in self.view_top..=self.view_bottom {
self.belegt[r] = None;
}
}
/// Hat sich seit der letzten Quittung etwas geändert?
pub fn ist_veraendert(&self) -> bool {
self.veraendert
}
/// Änderung quittieren (nach dem Anzeigen).
pub fn veraenderung_quittieren(&mut self) {
self.veraendert = false;
}
/// Anzahl belegter Zeilen (1-basiert bis zur letzten beschriebenen),
/// 0 wenn nichts ausgegeben wurde.
pub fn belegte_zeilen(&self) -> usize {
self.belegt
.iter()
.rposition(|b| b.is_some())
.map_or(0, |i| i + 1)
}
/// Breite der belegten Zeile `z` (1-basiert); 0 für unberührte Zeilen.
pub fn belegte_breite(&self, z: usize) -> usize {
self.belegt.get(z - 1).copied().flatten().map_or(0, |c| c + 1)
}
/// `COLOR vg, hg` (vg 031, hg 07). Blinkende Vordergrundfarben
/// (1631) werden als „hell" simuliert (Entscheidung 2026-09-02):
/// echtes Terminal-Blinken ist nicht überall verfügbar.
pub fn set_color(&mut self, fg: u8, bg: u8) {
self.veraendert = true;
let fg = if fg >= 16 { (fg & 0x0F) | 8 } else { fg };
self.fg = fg & 0x0F;
self.bg = bg & 0x07;
}
/// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5).
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> {
if row < 1 || row > self.rows || col < 1 || col > self.cols {
return Err(());
}
self.cur_row = row - 1;
// Auf eine Fortsetzungszelle zu zeigen bedeutet den Zeichenanfang.
let mut c = col - 1;
if c > 0 && self.cells[self.cur_row * self.cols + c].fortsetzung {
c -= 1;
}
self.cur_col = c;
self.veraendert = true;
Ok(())
}
/// `CSRLIN` (1-basiert).
pub fn csrlin(&self) -> usize {
self.cur_row + 1
}
/// `POS(0)` (1-basiert).
pub fn pos(&self) -> usize {
self.cur_col + 1
}
/// `VIEW PRINT oben TO unten` (1-basiert).
pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ()> {
if top < 1 || bottom > self.rows || top > bottom {
return Err(());
}
self.view_top = top - 1;
self.view_bottom = bottom - 1;
self.view_full = top == 1 && bottom == self.rows;
Ok(())
}
pub fn cell(&self, row: usize, col: usize) -> Cell {
self.cells[(row - 1) * self.cols + (col - 1)]
}
/// Text an der Cursorposition ausgeben: Umbruch am rechten Rand,
/// Scrollen am unteren Rand des Scrollbereichs. `\n` bricht um,
/// `\r` setzt an den Zeilenanfang.
pub fn print(&mut self, text: &str) {
self.veraendert = true;
for ch in text.chars() {
match ch {
'\n' => self.newline(),
'\r' => self.cur_col = 0,
_ => self.zeichen_setzen(ch),
}
}
}
/// Ein Zeichen an der Cursorposition setzen und den Cursor um seine
/// Darstellungsbreite vorrücken. Ein breites Zeichen, das nicht mehr
/// vollständig in die Zeile passt, wandert als Ganzes in die nächste;
/// die letzte Spalte bleibt dann leer.
fn zeichen_setzen(&mut self, ch: char) {
// ponytail: Breite 0 (kombinierende Zeichen) wird wie 1 behandelt —
// sie bekommen eine eigene Zelle statt sich an die vorige zu hängen.
// Ceiling: echte Kombination erst, wenn ein Korpusprogramm sie braucht.
let breite = ch.width().unwrap_or(1).max(1);
if self.cur_col + breite > self.cols {
self.newline();
}
let attr = (self.fg, self.bg);
let start = self.cur_row * self.cols + self.cur_col;
self.haelften_freimachen(self.cur_col, breite);
self.cells[start] = Cell { ch, fg: attr.0, bg: attr.1, fortsetzung: false };
for i in 1..breite {
self.cells[start + i] =
Cell { ch: ' ', fg: attr.0, bg: attr.1, fortsetzung: true };
}
let letzte = self.cur_col + breite - 1;
let b = &mut self.belegt[self.cur_row];
*b = Some(b.map_or(letzte, |c| c.max(letzte)));
self.cur_col += breite;
if self.cur_col >= self.cols {
self.newline();
}
}
/// Vor dem Schreiben angeschnittene breite Zeichen auflösen: wird auf
/// eine Fortsetzung geschrieben, verliert die Hälfte davor ihr Zeichen;
/// überschreibt der neue Text den Anfang eines breiten Zeichens, wird
/// dessen Fortsetzung geleert. Sonst blieben Geisterzeichen stehen.
fn haelften_freimachen(&mut self, col: usize, breite: usize) {
let zeilenanfang = self.cur_row * self.cols;
let leer = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
if col > 0 && self.cells[zeilenanfang + col].fortsetzung {
self.cells[zeilenanfang + col - 1] = leer;
}
let hinter = col + breite;
if hinter < self.cols && self.cells[zeilenanfang + hinter].fortsetzung {
self.cells[zeilenanfang + hinter] = leer;
}
}
/// Zeilenumbruch inkl. Scrollen im Scrollbereich.
fn newline(&mut self) {
self.cur_col = 0;
if self.cur_row >= self.view_bottom {
self.scroll_up();
self.cur_row = self.view_bottom;
} else {
self.cur_row += 1;
}
}
/// Rückschritt: Cursor eine Spalte zurück und die Zelle leeren.
/// Am Zeilenanfang passiert nichts (Vorbild).
pub fn rueckschritt(&mut self) {
if self.cur_col == 0 {
return;
}
self.cur_col -= 1;
// War das gelöschte eine Fortsetzung, gehört die Hälfte davor dazu.
if self.cells[self.cur_row * self.cols + self.cur_col].fortsetzung && self.cur_col > 0 {
self.cells[self.cur_row * self.cols + self.cur_col] =
Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
self.cur_col -= 1;
}
self.cells[self.cur_row * self.cols + self.cur_col] =
Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
// Die gelöschte Zelle gilt nicht mehr als belegt.
let b = &mut self.belegt[self.cur_row];
*b = match (self.cur_col, *b) {
(0, _) => None,
(c, Some(alt)) => Some(alt.min(c - 1)),
(c, None) => Some(c - 1),
};
self.veraendert = true;
}
/// Scrollbereich um eine Zeile nach oben schieben; unterste Zeile leeren.
pub fn scroll_up(&mut self) {
self.veraendert = true;
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false };
for row in self.view_top..self.view_bottom {
let (a, b) = self.cells.split_at_mut((row + 1) * self.cols);
a[row * self.cols..].copy_from_slice(&b[..self.cols]);
}
self.cells[self.view_bottom * self.cols..(self.view_bottom + 1) * self.cols]
.fill(blank);
for row in self.view_top..self.view_bottom {
self.belegt[row] = self.belegt[row + 1];
}
self.belegt[self.view_bottom] = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn print_schreibt_und_bewegt_cursor() {
let mut s = TextScreen::new();
s.print("AB");
assert_eq!(s.cell(1, 1).ch, 'A');
assert_eq!(s.cell(1, 2).ch, 'B');
assert_eq!((s.csrlin(), s.pos()), (1, 3));
}
#[test]
fn unicode_zeichen_belegen_eine_zelle() {
let mut s = TextScreen::new();
s.print("Ä☃");
assert_eq!(s.cell(1, 1).ch, 'Ä');
assert_eq!(s.cell(1, 2).ch, '☃');
}
#[test]
fn breites_zeichen_belegt_zwei_zellen() {
let mut s = TextScreen::new();
s.print("中a");
assert_eq!(s.cell(1, 1).ch, '中');
assert!(s.cell(1, 2).fortsetzung);
assert_eq!(s.cell(1, 3).ch, 'a');
assert_eq!(s.pos(), 4);
}
#[test]
fn breites_zeichen_am_rechten_rand_bricht_ganz_um() {
let mut s = TextScreen::new();
s.print(&"x".repeat(79)); // Cursor auf Spalte 80
assert_eq!(s.pos(), 80);
s.print("");
// Letzte Spalte bleibt leer, das Zeichen steht in Zeile 2.
assert_eq!(s.cell(1, 80).ch, ' ');
assert_eq!(s.cell(2, 1).ch, '中');
assert!(s.cell(2, 2).fortsetzung);
}
#[test]
fn ueberschreiben_einer_haelfte_leert_die_andere() {
let mut s = TextScreen::new();
s.print("");
// Auf den Anfang schreiben → Fortsetzung muss weg.
s.locate(1, 1).unwrap();
s.print("a");
assert_eq!(s.cell(1, 1).ch, 'a');
assert!(!s.cell(1, 2).fortsetzung);
assert_eq!(s.cell(1, 2).ch, ' ');
// Auf die Fortsetzung schreiben → Anfang muss weg.
let mut s2 = TextScreen::new();
s2.print("");
s2.locate(1, 2).unwrap(); // klemmt auf den Zeichenanfang
assert_eq!(s2.pos(), 1);
s2.locate(1, 3).unwrap();
s2.print("b");
assert_eq!(s2.cell(1, 3).ch, 'b');
}
#[test]
fn locate_auf_fortsetzung_trifft_den_zeichenanfang() {
let mut s = TextScreen::new();
s.print("");
s.locate(1, 2).unwrap();
assert_eq!(s.pos(), 1);
}
#[test]
fn rueckschritt_loescht_ein_breites_zeichen_ganz() {
let mut s = TextScreen::new();
s.print("a中");
assert_eq!(s.pos(), 4);
s.rueckschritt();
assert_eq!(s.pos(), 2);
assert_eq!(s.cell(1, 2).ch, ' ');
assert!(!s.cell(1, 2).fortsetzung);
assert_eq!(s.cell(1, 3).ch, ' ');
}
#[test]
fn umbruch_am_rechten_rand() {
let mut s = TextScreen::new();
s.print(&"x".repeat(81));
assert_eq!(s.cell(1, 80).ch, 'x');
assert_eq!(s.cell(2, 1).ch, 'x');
assert_eq!((s.csrlin(), s.pos()), (2, 2));
}
#[test]
fn scrollen_am_unteren_rand() {
let mut s = TextScreen::new();
s.locate(25, 1).unwrap();
s.print("unten\n"); // erzwingt Scroll
assert_eq!(s.cell(24, 1).ch, 'u');
assert_eq!(s.cell(25, 1).ch, ' ');
assert_eq!(s.csrlin(), 25);
}
#[test]
fn view_print_begrenzt_scrollen() {
let mut s = TextScreen::new();
s.locate(1, 1).unwrap();
s.print("kopf");
s.view_print(3, 5).unwrap();
s.locate(5, 1).unwrap();
s.print("a\nb"); // scrollt nur Zeilen 35
assert_eq!(s.cell(1, 1).ch, 'k');
assert_eq!(s.cell(4, 1).ch, 'a');
assert_eq!(s.cell(5, 1).ch, 'b');
}
#[test]
fn locate_prueft_grenzen() {
let mut s = TextScreen::new();
assert!(s.locate(0, 1).is_err());
assert!(s.locate(26, 1).is_err());
assert!(s.locate(25, 80).is_ok());
}
#[test]
fn dynamische_groesse_und_resize() {
let mut s = TextScreen::with_size(120, 40);
assert_eq!((s.cols(), s.rows()), (120, 40));
s.locate(40, 1).unwrap();
s.print("!");
assert_eq!(s.cell(40, 1).ch, '!');
// Verkleinern: Inhalt oben links bleibt, Cursor wird geklemmt
s.locate(1, 1).unwrap();
s.print("K");
s.resize(100, 30);
assert_eq!((s.cols(), s.rows()), (100, 30));
assert_eq!(s.cell(1, 1).ch, 'K');
// Unter Minimum wird auf 80×25 geklemmt
s.resize(10, 5);
assert_eq!((s.cols(), s.rows()), (80, 25));
}
#[test]
fn blink_wird_als_hell_simuliert() {
let mut s = TextScreen::new();
s.set_color(17, 0); // blinkend Blau → helles Blau
assert_eq!(s.fg, 9);
s.set_color(31, 0); // blinkend Hellweiß → Hellweiß
assert_eq!(s.fg, 15);
s.set_color(7, 0);
assert_eq!(s.fg, 7);
}
#[test]
fn resize_klemmt_cursor_und_scrollbereich() {
let mut s = TextScreen::with_size(120, 40);
s.locate(1, 1).unwrap();
s.print("K");
s.view_print(30, 38).unwrap();
s.locate(38, 5).unwrap();
s.resize(80, 25);
// Inhalt oben links bleibt.
assert_eq!(s.cell(1, 1).ch, 'K');
// Cursor liegt in den neuen Grenzen.
assert!(s.csrlin() <= s.rows() && s.pos() <= s.cols());
// Der Bereich lag ganz außerhalb → voller Bildschirm.
s.locate(25, 1).unwrap();
s.print("u\n");
assert_eq!(s.cell(24, 1).ch, 'u');
}
#[test]
fn resize_klemmt_nur_den_unteren_rand_wenn_der_bereich_bleibt() {
let mut s = TextScreen::with_size(100, 40);
s.view_print(5, 38).unwrap();
s.resize(100, 30);
// Oberer Rand bleibt bei 5, unterer klemmt auf 30.
s.locate(30, 1).unwrap();
s.print("a\nb");
assert_eq!(s.cell(29, 1).ch, 'a');
assert_eq!(s.cell(30, 1).ch, 'b');
// Zeile 4 liegt außerhalb und bleibt unberührt.
assert_eq!(s.cell(4, 1).ch, ' ');
}
#[test]
fn resize_folgt_vollem_scrollbereich() {
let mut s = TextScreen::new();
s.resize(90, 40);
s.locate(40, 1).unwrap();
s.print("a\nb"); // Scroll am neuen unteren Rand
assert_eq!(s.cell(39, 1).ch, 'a');
assert_eq!(s.cell(40, 1).ch, 'b');
}
}

View File

@@ -0,0 +1,141 @@
//! Snapshot des Bildschirms als vergleichbarer Text.
//!
//! Grundlage des Kompatibilitäts-Korpus (Entscheidung 2026-09-03, D3):
//! ein Byte-Strom könnte `LOCATE`/`COLOR` nicht abbilden, ein voller
//! 80×25-Abzug wäre für ein `PRINT "hi"` fast nur Rauschen. Der Snapshot
//! ist deshalb auf die belegte Fläche getrimmt — nachgestellte Leerzeichen,
//! die `PRINT` selbst ausgibt, gehören dazu.
//!
//! Die Attributebene entsteht nur, wenn das Programm überhaupt Farben
//! gesetzt hat; sonst wäre sie eine Fläche identischer Werte.
use crate::screen::{Cell, TextScreen};
/// Trennzeile vor der Attributebene.
pub const ATTRIBUT_MARKE: &str = "--- Attribute (Vordergrund/Hintergrund je Zelle, hex) ---";
/// Standardattribut eines unberührten Bildschirms.
fn ist_standard(c: &Cell) -> bool {
c.fg == Cell::default().fg && c.bg == Cell::default().bg
}
/// Enthält die belegte Fläche eine vom Standard abweichende Farbe?
pub fn hat_farben(screen: &TextScreen) -> bool {
(1..=screen.belegte_zeilen())
.any(|r| (1..=screen.belegte_breite(r)).any(|c| !ist_standard(&screen.cell(r, c))))
}
/// Textbild der belegten Fläche, je Zeile bis zu ihrer eigenen belegten
/// Breite. Leerer String, wenn nichts ausgegeben wurde.
pub fn text(screen: &TextScreen) -> String {
let mut out = String::new();
for r in 1..=screen.belegte_zeilen() {
for c in 1..=screen.belegte_breite(r) {
// Fortsetzungszellen gehören zum Zeichen davor und werden nicht
// eigens ausgegeben — sonst stünde hinter jedem breiten Zeichen
// ein Leerzeichen und die Spalten der Datei liefen auseinander.
let z = screen.cell(r, c);
if !z.fortsetzung {
out.push(z.ch);
}
}
out.push('\n');
}
out
}
/// Attributebene: je Zelle zwei Hexziffern (Vordergrund, Hintergrund),
/// zeilenweise gleich breit wie das Textbild.
pub fn attribute(screen: &TextScreen) -> String {
let mut out = String::new();
for r in 1..=screen.belegte_zeilen() {
for c in 1..=screen.belegte_breite(r) {
let cell = screen.cell(r, c);
if cell.fortsetzung {
continue; // gehört zum Zeichen davor (siehe `text`)
}
out.push(char::from_digit(cell.fg as u32, 16).unwrap_or('?'));
out.push(char::from_digit(cell.bg as u32, 16).unwrap_or('?'));
}
out.push('\n');
}
out
}
/// Vollständiger Snapshot: Textbild, bei gesetzten Farben gefolgt von
/// Marke und Attributebene.
pub fn snapshot(screen: &TextScreen) -> String {
let mut s = text(screen);
if hat_farben(screen) {
s.push_str(ATTRIBUT_MARKE);
s.push('\n');
s.push_str(&attribute(screen));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn getrimmt_auf_die_belegte_flaeche() {
let mut s = TextScreen::new();
s.print("hi\n");
// Zwei Zeichen in Zeile 1 — kein 80×25-Abzug.
assert_eq!(snapshot(&s), "hi\n");
}
#[test]
fn nachgestellte_leerzeichen_bleiben_erhalten() {
let mut s = TextScreen::new();
s.print(" 1 2 3 "); // wie PRINT 1; 2; 3
assert_eq!(snapshot(&s), " 1 2 3 \n");
}
#[test]
fn ohne_farben_keine_attributebene() {
let mut s = TextScreen::new();
s.print("abc");
assert!(!snapshot(&s).contains(ATTRIBUT_MARKE));
}
#[test]
fn mit_farben_kommt_die_attributebene() {
let mut s = TextScreen::new();
s.print("a");
s.set_color(14, 1);
s.print("b");
let snap = snapshot(&s);
assert!(snap.starts_with("ab\n"));
assert!(snap.contains(ATTRIBUT_MARKE));
// Zelle 1 Standard (70), Zelle 2 gelb auf blau (e1)
assert!(snap.ends_with("70e1\n"), "{snap:?}");
}
#[test]
fn locate_erweitert_die_flaeche() {
let mut s = TextScreen::new();
s.locate(3, 5).unwrap();
s.print("x");
let snap = snapshot(&s);
let zeilen: Vec<&str> = snap.lines().collect();
assert_eq!(zeilen.len(), 3);
assert_eq!(zeilen[2], " x");
// Unberührte Zeilen bleiben leer statt auf die Breite aufgefüllt.
assert_eq!(zeilen[0], "");
}
#[test]
fn breites_zeichen_erscheint_einmal() {
let mut s = TextScreen::new();
s.print("中a");
// Ein Zeichen, kein nachgestelltes Leerzeichen für die Fortsetzung.
assert_eq!(snapshot(&s), "中a\n");
}
#[test]
fn leerer_bildschirm_ergibt_leeren_snapshot() {
assert_eq!(snapshot(&TextScreen::new()), "");
}
}

View File

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