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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 1–12, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
377
crates/tb-runtime/src/finanz.rs
Normal file
377
crates/tb-runtime/src/finanz.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 …
|
||||
|
||||
557
crates/tb-runtime/src/screen.rs
Normal file
557
crates/tb-runtime/src/screen.rs
Normal 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 0–15 (klassische Palette).
|
||||
pub fg: u8,
|
||||
/// Hintergrund 0–7.
|
||||
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 0–31, hg 0–7). Blinkende Vordergrundfarben
|
||||
/// (16–31) werden als „hell" simuliert (Entscheidung 2026-09-02):
|
||||
/// echtes Terminal-Blinken ist nicht überall verfügbar.
|
||||
pub fn set_color(&mut self, fg: u8, bg: u8) {
|
||||
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 3–5
|
||||
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');
|
||||
}
|
||||
|
||||
}
|
||||
141
crates/tb-runtime/src/snapshot.rs
Normal file
141
crates/tb-runtime/src/snapshot.rs
Normal 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()), "");
|
||||
}
|
||||
}
|
||||
487
crates/tb-runtime/src/using.rs
Normal file
487
crates/tb-runtime/src/using.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user