//! Abgleich `docs/inventar.md` ↔ Implementierungsstand. //! //! Das Inventar ist die Sollseite der Leitplanke „Vollständigkeit ist das //! Soll"; dieser Test hält es gegen den Code, damit der Status nicht //! verrottet. Prüfregeln siehe `pruefe_status`. //! //! ponytail: Die Tabellen `builtin_fn`/`builtin_stmt`/`banned_feature` sind //! private `match`-Ausdrücke ohne Namensliste. Statt sie öffentlich zu machen //! oder die Namen ein zweites Mal zu pflegen, liest der Test die Stringliterale //! aus dem Quelltext. Ceiling: Wird `sema.rs` umstrukturiert, schlägt //! `abschnitt` fehl und nennt den fehlenden Anker — dann hier nachziehen oder //! die Tabellen als `pub const`-Listen exportieren. use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; const IMPLEMENTIERT: &str = "implementiert"; const OFFEN: &str = "offen"; const NON_FEATURE: &str = "Non-Feature"; fn wurzel() -> PathBuf { // crates/tb-frontend/ → Projektwurzel PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } // ---- Inventar lesen -------------------------------------------------------- #[derive(Debug, Clone)] struct Eintrag { name: String, art: String, status: String, fundstelle: String, zeile: usize, } fn inventar() -> Vec { let pfad = wurzel().join("docs/inventar.md"); let text = std::fs::read_to_string(&pfad) .unwrap_or_else(|e| panic!("docs/inventar.md nicht lesbar: {e}")); let mut out = Vec::new(); for (i, zeile) in text.lines().enumerate() { let zeile = zeile.trim(); if !zeile.starts_with("| `") { continue; } let spalten: Vec<&str> = zeile .trim_matches('|') .split('|') .map(|s| s.trim()) .collect(); assert_eq!( spalten.len(), 6, "docs/inventar.md Zeile {}: {} Spalten statt 6 — {zeile}", i + 1, spalten.len() ); out.push(Eintrag { name: spalten[0].trim_matches('`').to_string(), art: spalten[1].to_string(), status: spalten[3].to_string(), fundstelle: spalten[4].to_string(), zeile: i + 1, }); } assert!(!out.is_empty(), "docs/inventar.md enthält keine Einträge"); out } // ---- Implementierungsstand aus sema.rs ------------------------------------- fn sema_quelle() -> String { let pfad = wurzel().join("crates/tb-frontend/src/sema.rs"); std::fs::read_to_string(&pfad).unwrap_or_else(|e| panic!("sema.rs nicht lesbar: {e}")) } /// Quelltextabschnitt zwischen zwei Ankern (exklusive Endanker). fn abschnitt<'a>(quelle: &'a str, von: &str, bis: &str) -> &'a str { let a = quelle .find(von) .unwrap_or_else(|| panic!("Anker nicht gefunden in sema.rs: {von}")); let rest = &quelle[a..]; let b = rest .find(bis) .unwrap_or_else(|| panic!("Endanker nicht gefunden in sema.rs: {bis}")); &rest[..b] } /// Alle Stringliterale eines Abschnitts. fn literale(abschnitt: &str) -> BTreeSet { let mut out = BTreeSet::new(); let bytes: Vec = abschnitt.chars().collect(); let mut i = 0; while i < bytes.len() { if bytes[i] == '"' { let mut s = String::new(); i += 1; while i < bytes.len() && bytes[i] != '"' { s.push(bytes[i]); i += 1; } if !s.is_empty() { out.insert(s); } } i += 1; } out } struct Stand { signaturen: BTreeSet, verboten: BTreeSet, nicht_verfuegbar: BTreeSet, /// Non-Features, die an ihrer Syntax statt an ihrem Namen erkannt werden. syntaktisch: BTreeSet, } fn stand() -> Stand { let q = sema_quelle(); let fn_tab = abschnitt(&q, "fn builtin_fn(", "/// Deklarierte Non-Features"); let ban = abschnitt( &q, "fn banned_feature(", "/// Non-Features, die nicht über ihren Namen", ); let stmt_tab = abschnitt(&q, "fn builtin_stmt(", "// ---- Einstiegspunkte"); let unsup_fn = abschnitt( &q, "// --- Spätere Phasen: dokumentiert", "(HExpr::Unsupported(name)", ); let unsup_stmt = abschnitt( &q, "// Bildschirm-/Datei-/System-Anweisungen späterer Phasen.", r#"_ => "Anweisung""#, ); let mut signaturen = literale(fn_tab); signaturen.extend(literale(stmt_tab)); let mut nicht_verfuegbar = literale(unsup_fn); nicht_verfuegbar.extend(literale(unsup_stmt)); // Platzhalter der Sammelzweige sind keine Elementnamen. nicht_verfuegbar.remove("Funktion"); nicht_verfuegbar.remove("Anweisung"); let syn = abschnitt( &q, "pub const SYNTAKTISCH_ABGEWIESEN", "/// Ist `s` ein serieller Gerätename", ); Stand { signaturen, verboten: literale(ban), nicht_verfuegbar, syntaktisch: literale(syn).into_iter().map(|s| s.to_uppercase()).collect(), } } // ---- Regeln ---------------------------------------------------------------- #[test] fn statusvokabular_ist_beschraenkt() { for e in inventar() { assert!( matches!( e.art.as_str(), "Anweisung" | "Funktion" | "Metabefehl" | "Routine" ), "Zeile {}: `{}` trägt unzulässige Art `{}`", e.zeile, e.name, e.art ); assert!( matches!(e.status.as_str(), IMPLEMENTIERT | OFFEN | NON_FEATURE), "Zeile {}: `{}` trägt unzulässigen Status `{}` \ (erlaubt: {IMPLEMENTIERT}/{OFFEN}/{NON_FEATURE})", e.zeile, e.name, e.status ); } } #[test] fn non_features_nennen_eine_fundstelle_in_der_sprachreferenz() { for e in inventar() { if e.status == NON_FEATURE { assert!( e.fundstelle.contains("sprachreferenz.md"), "Zeile {}: `{}` ist Non-Feature, nennt aber keine Fundstelle \ in docs/sprachreferenz.md (gefunden: `{}`)", e.zeile, e.name, e.fundstelle ); } } } #[test] fn offene_elemente_haben_keine_fundstelle() { for e in inventar() { if e.status == OFFEN { assert_eq!( e.fundstelle, "-", "Zeile {}: `{}` ist offen, trägt aber die Fundstelle `{}`", e.zeile, e.name, e.fundstelle ); } } } /// Kernregel: Inventarstatus und Code dürfen nicht auseinanderlaufen. /// /// Geprüft wird nur, was mechanisch belegbar ist — die Bibliothekselemente aus /// `builtin_fn`/`builtin_stmt`, die Non-Feature-Liste und die /// „nicht verfügbar"-Marker. Anweisungen, die der Parser direkt kennt /// (`PRINT`, `IF`, …), und Metabefehle tauchen in keiner dieser Tabellen auf; /// für sie gilt nur die schwächere Regel `parserelemente_sind_keine_non_features`. #[test] fn inventar_stimmt_mit_code_ueberein() { let inv = inventar(); let st = stand(); let mut fehler: Vec = Vec::new(); // Ein Name kann zweimal vorkommen (Funktion und Anweisung, z. B. `SCREEN`). // Schlüssel ist der Name in Großschreibung — der Dialekt ist // case-insensitiv, der Code führt die Namen groß. // // ponytail: Einträge mit klammerndem Zusatz (`GET (Grafik)`) sind // syntaktische Varianten eines Tokens, das auch eine unterstützte Form hat. // Sie sind über den Namen nicht prüfbar und bleiben hier außen vor; ihre // Ablehnung liegt im Parser. Ceiling: sobald der Parser eine Liste seiner // abgewiesenen Formen exportiert, hier gegenprüfen. let mut je_name: BTreeMap> = BTreeMap::new(); for e in &inv { // Syntaktisch erkannte Formen tragen einen klammernden Zusatz oder // stehen namentlich in SYNTAKTISCH_ABGEWIESEN — sie werden unten // gegen diese Liste geprüft, nicht gegen `banned_feature`. if st.syntaktisch.contains(&e.name.to_uppercase()) { assert_eq!( e.status, NON_FEATURE, "Zeile {}: `{}` steht in SYNTAKTISCH_ABGEWIESEN, das Inventar sagt aber `{}`", e.zeile, e.name, e.status ); continue; } // Ein klammernder Zusatz benennt die Form (`KEY (Ereignis)`); für den // Codeabgleich zählt das Grundtoken davor. let schluessel = e .name .split('(') .next() .unwrap_or(&e.name) .trim() .to_uppercase(); je_name.entry(schluessel).or_default().push(e); } for (name, eintraege) in &je_name { let stati: BTreeSet<&str> = eintraege.iter().map(|e| e.status.as_str()).collect(); let _ = &name; let zeilen: Vec = eintraege.iter().map(|e| e.zeile.to_string()).collect(); let wo = format!("Zeile(n) {}", zeilen.join(", ")); let hat_signatur = st.signaturen.contains(name.as_str()); // `ON COM(1) GOSUB …` scheitert am Gerätenamen: für die ON-Formen // zählt das zweite Token. Andere Mehrwortnamen (`LINE INPUT`, // `VIEW PRINT`) sind eigenständige unterstützte Anweisungen und // erben nichts vom Grundtoken. let ist_verboten = match name.strip_prefix("ON ") { Some(geraet) => st.verboten.contains(geraet), None => st.verboten.contains(name.as_str()), }; let ist_marker = st.nicht_verfuegbar.contains(name.as_str()); if ist_verboten && !stati.contains(NON_FEATURE) { fehler.push(format!( "`{name}` ({wo}): Code weist es als Non-Feature ab, Inventar sagt {stati:?}" )); } if !ist_verboten && stati.contains(NON_FEATURE) { fehler.push(format!( "`{name}` ({wo}): Inventar sagt Non-Feature, Code weist es nicht ab \ (fehlt in banned_feature)" )); } if ist_marker && !stati.contains(OFFEN) { fehler.push(format!( "`{name}` ({wo}): senkt auf den „nicht verfügbar\"-Marker ab, \ Inventar sagt {stati:?}" )); } if stati == BTreeSet::from([IMPLEMENTIERT]) && ist_marker { fehler.push(format!( "`{name}` ({wo}): Inventar sagt implementiert, senkt aber weiterhin \ auf den „nicht verfügbar\"-Marker ab" )); } // Nur wenn ALLE Formen dieses Namens offen sind — sonst erklärt die // implementierte Form (etwa `DATE$` als Funktion) die Signatur. if stati == BTreeSet::from([OFFEN]) && hat_signatur && !ist_marker && !ist_verboten { fehler.push(format!( "`{name}` ({wo}): Inventar sagt offen, aber der Code hat eine Signatur \ ohne „nicht verfügbar\"-Marker — entweder implementiert oder Marker fehlt" )); } } // Gegenrichtung: kennt der Code Elemente, die das Inventar nicht führt? // Ein Element darf im Code unter mehreren Schreibweisen stehen: die // Original-Hilfe schreibt die Finanzfunktionen mit Typsuffix (`FV#`), der // Code nimmt beide an. Für den Abgleich zählt das Inventar als getroffen, // wenn eine der Schreibweisen passt. let bekannt = |n: &str| { let up = n.to_uppercase(); je_name.contains_key(&up) || ['#', '!', '&', '%', '@', '$'] .iter() .any(|s| je_name.contains_key(&format!("{up}{s}"))) }; for name in st.signaturen.iter().chain(st.verboten.iter()) { if !bekannt(name) { fehler.push(format!( "`{name}`: im Code bekannt, fehlt aber in docs/inventar.md" )); } } assert!( fehler.is_empty(), "Inventar und Code laufen auseinander ({} Abweichungen):\n {}", fehler.len(), fehler.join("\n ") ); } #[test] fn abdeckungsstand_wird_ausgewiesen() { let inv = inventar(); let zaehle = |s: &str| inv.iter().filter(|e| e.status == s).count(); let (i, o, n) = ( zaehle(IMPLEMENTIERT), zaehle(OFFEN), zaehle(NON_FEATURE), ); println!( "Abdeckung: implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}", inv.len() ); // Der Kopf der Tabelle nennt dieselben Zahlen — sonst ist die Doku stale. let text = std::fs::read_to_string(wurzel().join("docs/inventar.md")).unwrap(); let erwartet = format!( "implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}", inv.len() ); assert!( text.contains(&erwartet), "Abdeckungszeile in docs/inventar.md stimmt nicht; erwartet: {erwartet}" ); assert_eq!(i + o + n, inv.len(), "Statussumme ≠ Einträge"); } // ---- Namentliche Ablehnung ------------------------------------------------- /// Ein im Inventar geführtes Element darf nie als unbekannter Bezeichner oder /// mit einem generischen Syntaxfehler enden — die Ablehnung MUSS es nennen /// (Guiding Principle, Capability `sprachinventar`). /// /// Geprüft wird stichprobenartig je Ablehnungsweg, nicht über alle 285 /// Einträge: die vollständige Zuordnung leistet `inventar_stimmt_mit_code_ueberein`. #[test] fn dokumentierte_elemente_werden_namentlich_abgewiesen() { // (Quelltext, erwartetes Element, erwarteter Meldungsbestandteil) let faelle: &[(&str, &str, &str)] = &[ // Non-Feature über die Namensliste ("X = PEEK(100)", "PEEK", "Feature unavailable"), ("Y = POINT(1, 2)", "POINT", "Feature unavailable"), ("CALLS Foo", "CALLS", "Feature unavailable"), // Non-Feature über die Syntax (teilt das Schlüsselwort) ("LINE (1, 1)-(2, 2)", "LINE", "Feature unavailable"), ("GET (1, 1)-(2, 2), A", "GET (Grafik)", "Feature unavailable"), (r#"OPEN "COM1:9600,N,8,1" AS #1"#, "OPEN COM", "Feature unavailable"), // Ereignisgerät als Funktionsform ("ON PEN GOSUB Ziel\nZiel:\nRETURN", "ON PEN", "Feature unavailable"), ]; for (quelle, element, erwartet) in faelle { let a = tb_frontend::analyze_source("probe.bas", quelle); let texte: Vec = a.diagnostics.iter().map(|d| d.message.clone()).collect(); assert!( texte.iter().any(|m| m.contains(erwartet)), "`{element}`: erwartete Meldung `{erwartet}`, bekam {texte:?} \ (Quelle: {quelle:?})" ); assert!( !texte.iter().any(|m| m.contains("not defined")), "`{element}`: wird als unbekannter Bezeichner abgewiesen statt \ namentlich — {texte:?}" ); } } /// Gegenprobe: ein Element mit „nicht verfügbar"-Marker kompiliert zwar, endet /// aber zur Laufzeit in einem Fehler, der es benennt — nicht in einem /// generischen Syntaxfehler. #[test] fn offene_elemente_erzeugen_keinen_syntaxfehler() { for quelle in ["CLS", "LOCATE 5, 10", "COLOR 14, 1", r#"OPEN "d.txt" FOR OUTPUT AS #1"#] { let a = tb_frontend::analyze_source("probe.bas", quelle); assert!( a.diagnostics.is_empty(), "{quelle:?}: offenes Element soll übersetzen und erst zur Laufzeit \ namentlich abgewiesen werden, bekam {:?}", a.diagnostics ); } } /// Jedes Inventarelement besitzt eine Signatur — auch die, deren /// Laufzeitverhalten noch aussteht. Sie dürfen nie als unbekannter Bezeichner /// enden (Capability `sprach-frontend`, Anforderung „Jedes dokumentierte /// Element besitzt eine Signatur"). #[test] fn bislang_signaturlose_elemente_haben_signaturen() { let quelle = r#" DIM Zahlungen#(1 TO 3) DIM D#, S$, I%, L&, C@, G! D# = FV#(0.05, 10, -100, 0, 0) D# = PV#(0.05, 10, -100, 0, 0) D# = PMT#(0.05, 10, 1000, 0, 0) D# = NPER#(0.05, -100, 1000, 0, 0) D# = IPMT#(0.05, 1, 10, 1000, 0, 0) D# = PPMT#(0.05, 1, 10, 1000, 0, 0) D# = RATE#(10, -100, 1000, 0, 0, 0.1) D# = NPV#(0.05, Zahlungen#()) D# = IRR#(Zahlungen#(), 0.1) D# = MIRR#(Zahlungen#(), 0.05, 0.06) D# = SLN#(1000, 100, 10) D# = SYD#(1000, 100, 10, 1) D# = DDB#(1000, 100, 10, 1) S$ = MKI$(1) S$ = MKL$(1) S$ = MKS$(1) S$ = MKD$(1) S$ = MKC$(1) S$ = MKSMBF$(1) S$ = MKDMBF$(1) I% = CVI("ab") L& = CVL("abcd") G! = CVS("abcd") D# = CVD("abcdefgh") C@ = CVC("abcdefgh") G! = CVSMBF("abcd") D# = CVDMBF("abcdefgh") S$ = CURDIR$ S$ = DIR$("*.txt") I% = LPOS(0) L& = STACK I% = ERDEV S$ = ERDEV$ CHDRIVE "C" STACK 2048 SetFormatCC 49 "#; let a = tb_frontend::analyze_source("signaturen.bas", quelle); let unbekannt: Vec<&tb_frontend::Diagnostic> = a .diagnostics .iter() .filter(|d| d.message.contains("not defined")) .collect(); assert!( unbekannt.is_empty(), "Elemente werden als unbekannter Bezeichner abgewiesen: {unbekannt:?}" ); assert!( a.diagnostics.is_empty(), "unerwartete Diagnosen: {:?}", a.diagnostics ); } /// Die Signatur wirkt auch dort, wo das Laufzeitverhalten noch aussteht: /// eine falsche Argumentanzahl wird gemeldet und nennt das Element. #[test] fn signatur_greift_ohne_laufzeitverhalten() { let a = tb_frontend::analyze_source("argzahl.bas", "S$ = MKL$(1, 2)"); let texte: Vec = a.diagnostics.iter().map(|d| d.message.clone()).collect(); assert!( texte.iter().any(|m| m.contains("MKL$")), "Diagnose nennt `MKL$` nicht: {texte:?}" ); }