Phase 1: Sprach-Frontend (Lexer, AST, Parser, Semantik) + Entscheidungen

Frontend:
- Lexer komplett: Typ-Suffixe, Literal-Typisierung (Entscheidung: > 7
  signifikante Stellen -> DOUBLE), Hex/Oktal, Zeilenfortsetzung mit _,
  Strings mit ""-Escape, case-insensitive Keywords (Bibliotheksnamen
  bleiben Bezeichner)
- AST fuer Module/Prozeduren/Anweisungen/Ausdruecke
- Parser: fehlertolerant, zeilenorientiert; Kern-Anweisungssatz inkl.
  Bloecke, ON [LOCAL] ERROR, DEF FN (einzeilig); Datei-E/A als
  Phase-3-Platzhalter
- Semantik: Symboltabellen, implizite Deklaration, DEFtype, OPTION
  EXPLICIT, Arrays, Builtin-Signaturen, Labelpruefung; Hardware-Features
  (PEEK/POKE/...) werden zur Compile-Zeit abgewiesen
- Meilenstein: Testkorpus parst und wird typgeprueft (corpus.rs); 27 Tests

Entscheidungen eingearbeitet:
- Binaries heissen tb (IDE) und tbc (Compiler)
- Dynamische Terminalgroesse statt 80x25 (Minimum 80x25, btop-artiger
  Hinweis darunter); tb-ui::screen mit resize(), Spike angepasst
- Vollstaendigkeits-Leitplanke: 100% Sprache/Stdlib minus deklarierte
  Non-Features; Original-Doku als Guiding Principle; Inventar-Aufgabe
- CURRENCY als i64-Festkomma; ISAM wird implementiert; breite Zeichen
  belegen 2 Zellen; GET/PUT-Strings als UTF-32; Blink als hell simuliert
- LICENSE: MIT

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 09:16:01 +02:00
parent 8002ac2388
commit 333e794540
21 changed files with 3799 additions and 138 deletions

View File

@@ -1,13 +1,13 @@
[package]
name = "tb-cli"
description = "Terminal Basic: Standalone-Kommandozeilen-Compiler (tbdosc)"
description = "Terminal Basic: Standalone-Kommandozeilen-Compiler (tbc)"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[[bin]]
name = "tbdosc"
name = "tbc"
path = "src/main.rs"
[dependencies]

View File

@@ -1,13 +1,13 @@
//! `tbdosc` — Standalone-Compiler von Terminal Basic.
//! `tbc` — Standalone-Compiler von Terminal Basic.
//!
//! Wandelt Quelldateien und Projekte in binäre Ergebnisse (`.tbc`-Bytecode
//! bzw. eigenständig ausführbare Programme) um. Geplante Unterbefehle
//! (siehe PLAN.md):
//! - `tbdosc build <projekt|datei.bas>` Kompilieren zu binärem Ergebnis
//! - `tbdosc run <datei.bas>` Kompilieren und sofort ausführen
//! - `tbdosc check <datei.bas>` Nur Syntax-/Semantikprüfung
//! - `tbc build <projekt|datei.bas>` Kompilieren zu binärem Ergebnis
//! - `tbc run <datei.bas>` Kompilieren und sofort ausführen
//! - `tbc check <datei.bas>` Nur Syntax-/Semantikprüfung
fn main() -> anyhow::Result<()> {
println!("tbdosc — Terminal Basic Compiler (Projektrahmen, noch ohne Funktion)");
println!("tbc — Terminal Basic Compiler (Projektrahmen, noch ohne Funktion)");
Ok(())
}

View File

@@ -1,4 +1,247 @@
//! AST-Definitionen: Module, Prozeduren (`SUB`/`FUNCTION`), Anweisungen, Ausdrücke,
//! benutzerdefinierte Typen (`TYPE … END TYPE`), Deklarationen.
//! AST-Definitionen: Module, Prozeduren, Anweisungen, Ausdrücke,
//! Deklarationen und benutzerdefinierte Typen.
// Platzhalter — wird in Phase 1 ausgearbeitet (siehe PLAN.md)
use crate::lexer::Suffix;
use crate::SourcePos;
/// Typangabe in `AS`-Klauseln.
#[derive(Debug, Clone, PartialEq)]
pub enum TypeName {
Integer,
Long,
Single,
Double,
Currency,
Str,
FixedStr(i64),
Udt(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnOp {
Neg,
Not,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinOp {
Pow, Mul, Div, IntDiv, Mod, Add, Sub,
Eq, Ne, Lt, Le, Gt, Ge,
And, Or, Xor, Eqv, Imp,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
IntLit(i16),
LongLit(i32),
SingleLit(f32),
DoubleLit(f64),
CurrencyLit(i64),
StrLit(String),
/// Benannter Zugriff: Variable, Arrayelement, Funktionsaufruf oder
/// Konstante — Auflösung erfolgt in der Semantik.
Name {
name: String,
suffix: Option<Suffix>,
args: Option<Vec<Expr>>,
pos: SourcePos,
},
Unary { op: UnOp, operand: Box<Expr>, pos: SourcePos },
Binary { op: BinOp, lhs: Box<Expr>, rhs: Box<Expr>, pos: SourcePos },
/// Ausgelassenes Argument (`LOCATE , 5`).
Missing,
}
impl Expr {
pub fn pos(&self) -> SourcePos {
match self {
Expr::Name { pos, .. }
| Expr::Unary { pos, .. }
| Expr::Binary { pos, .. } => *pos,
_ => SourcePos::default(),
}
}
}
/// Sprungziel: alphanumerisches Label oder Zeilennummer.
#[derive(Debug, Clone, PartialEq)]
pub enum LabelRef {
Name(String),
Line(u32),
}
#[derive(Debug, Clone, PartialEq)]
pub enum PrintItem {
Expr(Expr),
Comma,
Semicolon,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CaseSpec {
Expr(Expr),
Range(Expr, Expr),
Is(BinOp, Expr),
}
#[derive(Debug, Clone, PartialEq)]
pub struct CaseArm {
/// Leer = `CASE ELSE`.
pub specs: Vec<CaseSpec>,
pub body: Vec<Stmt>,
pub pos: SourcePos,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VarDecl {
pub name: String,
pub suffix: Option<Suffix>,
/// `None` = Skalar; sonst Dimensionen `(untergrenze TO obergrenze)`.
pub dims: Option<Vec<(Option<Expr>, Expr)>>,
pub as_type: Option<TypeName>,
pub pos: SourcePos,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitKind {
For,
Do,
Sub,
Function,
Def,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionKind {
Explicit,
Base(u8),
}
#[derive(Debug, Clone, PartialEq)]
pub enum OnErrorAction {
Goto(LabelRef),
ResumeNext,
Disable, // GOTO 0
}
#[derive(Debug, Clone, PartialEq)]
pub enum ResumeKind {
Retry, // RESUME [0]
Next,
Label(LabelRef),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Param {
pub name: String,
pub suffix: Option<Suffix>,
pub array: bool,
pub as_type: Option<TypeName>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcKind {
Sub,
Function,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProcSig {
pub kind: ProcKind,
pub name: String,
pub suffix: Option<Suffix>,
pub params: Vec<Param>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Proc {
pub sig: ProcSig,
pub is_static: bool,
pub body: Vec<Stmt>,
pub pos: SourcePos,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Stmt {
Label(String),
LineNumber(u32),
Assign { target: Expr, value: Expr, pos: SourcePos },
Print {
file: Option<Expr>,
using: Option<Expr>,
items: Vec<PrintItem>,
pos: SourcePos,
},
Input {
line: bool,
file: Option<Expr>,
keep_cursor: bool,
prompt: Option<(String, bool)>, // (Text, mit Fragezeichen)
vars: Vec<Expr>,
pos: SourcePos,
},
If {
cond: Expr,
then_body: Vec<Stmt>,
elseifs: Vec<(Expr, Vec<Stmt>)>,
else_body: Option<Vec<Stmt>>,
pos: SourcePos,
},
Select { expr: Expr, arms: Vec<CaseArm>, pos: SourcePos },
For {
var: Expr,
from: Expr,
to: Expr,
step: Option<Expr>,
body: Vec<Stmt>,
pos: SourcePos,
},
DoLoop {
pre: Option<(bool, Expr)>, // (ist UNTIL, Bedingung)
post: Option<(bool, Expr)>,
body: Vec<Stmt>,
pos: SourcePos,
},
While { cond: Expr, body: Vec<Stmt>, pos: SourcePos },
Goto { target: LabelRef, pos: SourcePos },
Gosub { target: LabelRef, pos: SourcePos },
OnGoto { expr: Expr, targets: Vec<LabelRef>, gosub: bool, pos: SourcePos },
Return { target: Option<LabelRef>, pos: SourcePos },
End,
StopStmt,
System,
Exit { kind: ExitKind, pos: SourcePos },
Dim { shared: bool, redim: bool, decls: Vec<VarDecl>, pos: SourcePos },
Erase { names: Vec<Expr>, pos: SourcePos },
ConstDecl { items: Vec<(String, Option<Suffix>, Expr)>, pos: SourcePos },
DefType { ty: TypeName, ranges: Vec<(char, char)>, pos: SourcePos },
OptionStmt { kind: OptionKind, pos: SourcePos },
TypeDecl { name: String, fields: Vec<(String, TypeName)>, pos: SourcePos },
Declare { sig: ProcSig, pos: SourcePos },
/// Expliziter oder impliziter Prozedur-/Builtin-Aufruf als Anweisung.
Call { name: String, suffix: Option<Suffix>, args: Vec<Expr>, pos: SourcePos },
OnError { local: bool, action: OnErrorAction, pos: SourcePos },
Resume { kind: ResumeKind, pos: SourcePos },
ErrorStmt { code: Expr, pos: SourcePos },
Data { items: Vec<String>, pos: SourcePos },
ReadStmt { vars: Vec<Expr>, pos: SourcePos },
Restore { target: Option<LabelRef>, pos: SourcePos },
/// Einzeilige `DEF FNname(...) = ausdruck`-Definition.
DefFn {
name: String,
suffix: Option<Suffix>,
params: Vec<Param>,
body: Expr,
pos: SourcePos,
},
/// Geparst, aber erst in Phase 3 implementiert (Datei-E/A u. ä.);
/// die Tokens der Anweisung wurden übersprungen.
NotYetImplemented { keyword: String, pos: SourcePos },
}
#[derive(Debug, Clone, PartialEq)]
pub struct Module {
pub name: String,
pub body: Vec<Stmt>,
pub procs: Vec<Proc>,
}

View File

@@ -1,13 +1,557 @@
//! Lexer: zerlegt Quelltext in Tokens.
//!
//! Besonderheiten des BASIC-Dialekts, die hier abgebildet werden müssen:
//! - Typ-Suffixe an Bezeichnern und Literalen (`%`, `&`, `!`, `#`, `$`, `@`)
//! - Zeilennummern und Labels
//! - Keywords sind case-insensitiv, der Editor normalisiert später auf Großschreibung
//! - Fortsetzung/Trennung von Anweisungen mit `:` und Kommentare mit `'` und `REM`
//! Besonderheiten des Dialekts:
//! - Typ-Suffixe an Bezeichnern und Literalen (`% & ! # $ @`)
//! - Zeilennummern und Labels (löst der Parser auf)
//! - Keywords case-insensitiv; nur echte Sprach-Keywords sind reserviert,
//! Bibliotheksnamen (`CLS`, `LEFT$` …) bleiben Bezeichner und werden in
//! der Semantik als Builtins aufgelöst
//! - `:` trennt Anweisungen, `'` und `REM` leiten Kommentare ein
//! - Zeilenfortsetzung: `_` als letztes Zeichen nach Leerraum
//! - Literal-Typisierung nach Sprachreferenz (docs/sprachreferenz.md §1)
use crate::{Diagnostic, SourcePos};
/// Typ-Suffix eines Bezeichners oder Literals.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Suffix {
Integer, // %
Long, // &
Single, // !
Double, // #
Str, // $
Currency, // @
}
impl Suffix {
pub fn from_char(c: char) -> Option<Suffix> {
match c {
'%' => Some(Suffix::Integer),
'&' => Some(Suffix::Long),
'!' => Some(Suffix::Single),
'#' => Some(Suffix::Double),
'$' => Some(Suffix::Str),
'@' => Some(Suffix::Currency),
_ => None,
}
}
pub fn as_char(self) -> char {
match self {
Suffix::Integer => '%',
Suffix::Long => '&',
Suffix::Single => '!',
Suffix::Double => '#',
Suffix::Str => '$',
Suffix::Currency => '@',
}
}
}
/// Wert eines numerischen Literals, bereits typisiert.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NumValue {
Int(i16),
Long(i32),
Single(f32),
Double(f64),
/// Festkomma ×10 000
Currency(i64),
}
/// Reservierte Sprach-Keywords (bewusst schlank: Bibliotheksfunktionen
/// und -anweisungen sind KEINE Keywords, sondern Builtins der Semantik).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kw {
And, As, Call, Case, Const, Data, Declare, Def, DefCur, DefDbl, DefInt,
DefLng, DefSng, DefStr, Dim, Do, Double, Else, ElseIf, End, Eqv, Erase,
Error, Exit, For, Function, Gosub, Goto, If, Imp, Input, Integer, Is,
Let, Line, Local, Long, Loop, Mod, Next, Not, On, Option, Or, Print,
Read, ReDim, Rem, Restore, Resume, Return, Select, Shared, Single,
Static, Step, Stop, String, Sub, System, Then, To, Type, Until, Using,
Wend, While, Xor, Currency,
// Datei-E/A-Keywords: werden geparst, aber erst in Phase 3 implementiert
Open, Close, Write, Field, Get, Put, Seek, Lset, Rset,
}
fn keyword(upper: &str) -> Option<Kw> {
use Kw::*;
Some(match upper {
"AND" => And, "AS" => As, "CALL" => Call, "CASE" => Case,
"CONST" => Const, "CURRENCY" => Currency, "DATA" => Data,
"DECLARE" => Declare, "DEF" => Def, "DEFCUR" => DefCur,
"DEFDBL" => DefDbl, "DEFINT" => DefInt, "DEFLNG" => DefLng,
"DEFSNG" => DefSng, "DEFSTR" => DefStr, "DIM" => Dim, "DO" => Do,
"DOUBLE" => Double, "ELSE" => Else, "ELSEIF" => ElseIf, "END" => End,
"EQV" => Eqv, "ERASE" => Erase, "ERROR" => Error, "EXIT" => Exit,
"FIELD" => Field, "FOR" => For, "FUNCTION" => Function,
"GET" => Get, "GOSUB" => Gosub, "GOTO" => Goto, "IF" => If,
"IMP" => Imp, "INPUT" => Input, "INTEGER" => Integer, "IS" => Is,
"LET" => Let, "LINE" => Line, "LOCAL" => Local, "LONG" => Long,
"LOOP" => Loop, "LSET" => Lset, "MOD" => Mod, "NEXT" => Next,
"NOT" => Not, "ON" => On, "OPEN" => Open, "OPTION" => Option,
"OR" => Or, "PRINT" => Print, "PUT" => Put, "READ" => Read,
"REDIM" => ReDim, "REM" => Rem, "RESTORE" => Restore,
"RESUME" => Resume, "RETURN" => Return, "RSET" => Rset,
"SEEK" => Seek, "SELECT" => Select, "SHARED" => Shared,
"SINGLE" => Single, "STATIC" => Static, "STEP" => Step,
"STOP" => Stop, "STRING" => String, "SUB" => Sub,
"SYSTEM" => System, "THEN" => Then,
"TO" => To, "TYPE" => Type, "UNTIL" => Until, "USING" => Using,
"WEND" => Wend, "WHILE" => While, "WRITE" => Write, "XOR" => Xor,
"CLOSE" => Close,
_ => return None,
})
}
#[derive(Debug, Clone, PartialEq)]
pub enum Token {
// Platzhalter — wird in Phase 1 ausgearbeitet (siehe PLAN.md)
pub enum TokenKind {
/// Bezeichner; `name` ist bereits in Großschreibung normalisiert.
Ident { name: String, suffix: Option<Suffix> },
Kw(Kw),
Num(NumValue),
Str(String),
Plus, Minus, Star, Slash, Backslash, Caret,
Eq, Ne, Lt, Le, Gt, Ge,
LParen, RParen, Comma, Semicolon, Colon, Hash,
/// Ende einer logischen Zeile.
Eol,
Eof,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Token {
pub kind: TokenKind,
pub pos: SourcePos,
}
pub struct LexOutput {
pub tokens: Vec<Token>,
pub diagnostics: Vec<Diagnostic>,
}
/// Signifikante Stellen einer Ziffernfolge (führende Nullen zählen nicht).
fn significant_digits(int_part: &str, frac_part: &str) -> usize {
let all: String = int_part.chars().chain(frac_part.chars()).collect();
let trimmed = all.trim_start_matches('0');
trimmed.len()
}
pub fn lex(source: &str) -> LexOutput {
let mut tokens: Vec<Token> = Vec::new();
let mut diagnostics: Vec<Diagnostic> = Vec::new();
let mut continuation = false;
for (line_idx, raw_line) in source.lines().enumerate() {
let line_no = (line_idx + 1) as u32;
let chars: Vec<char> = raw_line.chars().collect();
let mut i = 0usize;
let mut line_continued = false;
'line: while i < chars.len() {
// Leerraum überspringen
while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
i += 1;
}
if i >= chars.len() {
break;
}
let start = i;
let pos = SourcePos { line: line_no, column: (start + 1) as u32 };
let c = chars[i];
// Zeilenfortsetzung: `_` nach Leerraum, danach nur noch Leerraum
if c == '_'
&& (start == 0
|| chars[start - 1] == ' '
|| chars[start - 1] == '\t')
&& chars[start + 1..].iter().all(|&ch| ch == ' ' || ch == '\t')
{
line_continued = true;
break 'line;
}
match c {
'\'' => break 'line, // Kommentar bis Zeilenende
'"' => {
i += 1;
let mut s = String::new();
let mut closed = false;
while i < chars.len() {
if chars[i] == '"' {
if i + 1 < chars.len() && chars[i + 1] == '"' {
s.push('"');
i += 2;
} else {
i += 1;
closed = true;
break;
}
} else {
s.push(chars[i]);
i += 1;
}
}
if !closed {
// Das Vorbild toleriert fehlende schließende
// Anführungszeichen am Zeilenende.
}
tokens.push(Token { kind: TokenKind::Str(s), pos });
}
'&' if i + 1 < chars.len()
&& matches!(chars[i + 1], 'h' | 'H' | 'o' | 'O') =>
{
let hex = matches!(chars[i + 1], 'h' | 'H');
i += 2;
let digit_start = i;
while i < chars.len()
&& chars[i].is_ascii_alphanumeric()
{
i += 1;
}
let digits: String = chars[digit_start..i].iter().collect();
let long_suffix = i < chars.len() && chars[i] == '&';
if long_suffix {
i += 1;
}
let radix = if hex { 16 } else { 8 };
match u32::from_str_radix(&digits, radix) {
Ok(v) => {
let kind = if long_suffix {
TokenKind::Num(NumValue::Long(v as i32))
} else if v <= 0xFFFF {
TokenKind::Num(NumValue::Int(v as u16 as i16))
} else {
diagnostics.push(Diagnostic {
pos,
message: "Overflow".into(),
});
TokenKind::Num(NumValue::Long(v as i32))
};
tokens.push(Token { kind, pos });
}
Err(_) => diagnostics.push(Diagnostic {
pos,
message: "Syntax error".into(),
}),
}
}
'0'..='9' | '.' if c != '.'
|| (i + 1 < chars.len() && chars[i + 1].is_ascii_digit()) =>
{
let int_start = i;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
let int_part: String = chars[int_start..i].iter().collect();
let mut frac_part = String::new();
let mut has_point = false;
if i < chars.len() && chars[i] == '.' {
has_point = true;
i += 1;
let fs = i;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
frac_part = chars[fs..i].iter().collect();
}
// Exponent E/D
let mut exp_kind: Option<char> = None;
let mut exp_str = String::new();
if i < chars.len()
&& matches!(chars[i], 'e' | 'E' | 'd' | 'D')
{
let save = i;
let k = chars[i].to_ascii_uppercase();
let mut j = i + 1;
let mut e = String::new();
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
e.push(chars[j]);
j += 1;
}
let ds = j;
while j < chars.len() && chars[j].is_ascii_digit() {
j += 1;
}
if j > ds {
for ch in &chars[ds..j] {
e.push(*ch);
}
exp_kind = Some(k);
exp_str = e;
i = j;
} else {
i = save; // kein Exponent (z. B. Variable `e`)
}
}
// Suffix
let suffix = if i < chars.len() {
Suffix::from_char(chars[i])
} else {
None
};
if suffix.is_some() {
i += 1;
}
let text = format!(
"{}{}{}{}",
int_part,
if has_point { "." } else { "" },
frac_part,
match exp_kind {
Some(_) => format!("e{exp_str}"),
None => String::new(),
}
);
let dval: f64 = text.parse().unwrap_or(0.0);
let value = match (suffix, exp_kind) {
(Some(Suffix::Integer), _) => {
if dval > i16::MAX as f64 || dval < i16::MIN as f64 {
diagnostics.push(Diagnostic {
pos,
message: "Overflow".into(),
});
}
NumValue::Int(dval as i16)
}
(Some(Suffix::Long), _) => {
if dval > i32::MAX as f64 || dval < i32::MIN as f64 {
diagnostics.push(Diagnostic {
pos,
message: "Overflow".into(),
});
}
NumValue::Long(dval as i32)
}
(Some(Suffix::Single), _) => NumValue::Single(dval as f32),
(Some(Suffix::Double), _) => NumValue::Double(dval),
(Some(Suffix::Currency), _) => {
NumValue::Currency((dval * 10_000.0).round() as i64)
}
(Some(Suffix::Str), _) => {
diagnostics.push(Diagnostic {
pos,
message: "Syntax error".into(),
});
NumValue::Double(dval)
}
(None, Some('D')) => NumValue::Double(dval),
(None, Some(_)) => NumValue::Single(dval as f32),
(None, None) => {
if !has_point {
// Ganzzahl: INTEGER → LONG → Gleitkomma
if let Ok(v) = text.parse::<i64>() {
if let Ok(v16) = i16::try_from(v) {
NumValue::Int(v16)
} else if let Ok(v32) = i32::try_from(v) {
NumValue::Long(v32)
} else {
NumValue::Double(dval)
}
} else {
NumValue::Double(dval)
}
} else {
// Entscheidung (2026-09-02, siehe
// Sprachreferenz §1): > 7 signifikante
// Stellen → DOUBLE, sonst SINGLE.
if significant_digits(&int_part, &frac_part) > 7 {
NumValue::Double(dval)
} else {
NumValue::Single(dval as f32)
}
}
}
};
tokens.push(Token { kind: TokenKind::Num(value), pos });
}
c if c.is_alphabetic() => {
i += 1;
while i < chars.len()
&& (chars[i].is_alphanumeric()
|| chars[i] == '.'
|| chars[i] == '_')
{
i += 1;
}
let mut name: String = chars[start..i]
.iter()
.collect::<String>()
.to_uppercase();
let suffix = if i < chars.len() {
Suffix::from_char(chars[i])
} else {
None
};
if suffix.is_some() {
i += 1;
}
if suffix.is_none() {
if let Some(kw) = keyword(&name) {
if kw == Kw::Rem {
break 'line; // REM: Rest ist Kommentar
}
tokens.push(Token { kind: TokenKind::Kw(kw), pos });
continue;
}
}
// Bezeichner dürfen nicht mit '.' enden (a.b. → a.b + .)
while name.ends_with('.') {
name.pop();
i -= 1;
}
tokens.push(Token {
kind: TokenKind::Ident { name, suffix },
pos,
});
}
_ => {
i += 1;
let kind = match c {
'+' => TokenKind::Plus,
'-' => TokenKind::Minus,
'*' => TokenKind::Star,
'/' => TokenKind::Slash,
'\\' => TokenKind::Backslash,
'^' => TokenKind::Caret,
'=' => TokenKind::Eq,
'(' => TokenKind::LParen,
')' => TokenKind::RParen,
',' => TokenKind::Comma,
';' => TokenKind::Semicolon,
':' => TokenKind::Colon,
'#' => TokenKind::Hash,
'?' => TokenKind::Kw(Kw::Print), // Editor-Kurzform
'&' => TokenKind::Kw(Kw::Long), // isoliertes & (selten)
'<' => {
if i < chars.len() && chars[i] == '=' {
i += 1;
TokenKind::Le
} else if i < chars.len() && chars[i] == '>' {
i += 1;
TokenKind::Ne
} else {
TokenKind::Lt
}
}
'>' => {
if i < chars.len() && chars[i] == '=' {
i += 1;
TokenKind::Ge
} else {
TokenKind::Gt
}
}
other => {
diagnostics.push(Diagnostic {
pos,
message: format!("Syntax error ('{other}')"),
});
continue;
}
};
tokens.push(Token { kind, pos });
}
}
}
if line_continued {
continuation = true;
} else {
if !continuation || !tokens.is_empty() {
tokens.push(Token {
kind: TokenKind::Eol,
pos: SourcePos {
line: line_no,
column: (chars.len() + 1) as u32,
},
});
}
continuation = false;
}
}
tokens.push(Token {
kind: TokenKind::Eof,
pos: SourcePos { line: (source.lines().count() + 1) as u32, column: 1 },
});
LexOutput { tokens, diagnostics }
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(src: &str) -> Vec<TokenKind> {
lex(src).tokens.into_iter().map(|t| t.kind).collect()
}
#[test]
fn keywords_case_insensitiv() {
let k = kinds("print If tHeN");
assert_eq!(k[0], TokenKind::Kw(Kw::Print));
assert_eq!(k[1], TokenKind::Kw(Kw::If));
assert_eq!(k[2], TokenKind::Kw(Kw::Then));
}
#[test]
fn suffix_macht_keyword_zum_bezeichner() {
// STRING$ ist die Bibliotheksfunktion, STRING das Typ-Keyword
let k = kinds("STRING$ STRING");
assert_eq!(
k[0],
TokenKind::Ident { name: "STRING".into(), suffix: Some(Suffix::Str) }
);
assert_eq!(k[1], TokenKind::Kw(Kw::String));
}
#[test]
fn literal_typisierung() {
assert_eq!(kinds("42")[0], TokenKind::Num(NumValue::Int(42)));
assert_eq!(kinds("40000")[0], TokenKind::Num(NumValue::Long(40000)));
assert_eq!(kinds("1.5")[0], TokenKind::Num(NumValue::Single(1.5)));
assert_eq!(
kinds("3.14159265")[0],
TokenKind::Num(NumValue::Double(3.14159265))
);
assert_eq!(kinds("1E3")[0], TokenKind::Num(NumValue::Single(1000.0)));
assert_eq!(kinds("1D3")[0], TokenKind::Num(NumValue::Double(1000.0)));
assert_eq!(kinds("2.5@")[0], TokenKind::Num(NumValue::Currency(25000)));
assert_eq!(kinds("&HFF")[0], TokenKind::Num(NumValue::Int(255)));
assert_eq!(kinds("&HFFFF")[0], TokenKind::Num(NumValue::Int(-1)));
assert_eq!(kinds("&HFFFF&")[0], TokenKind::Num(NumValue::Long(0xFFFF)));
assert_eq!(kinds("&O777")[0], TokenKind::Num(NumValue::Int(511)));
}
#[test]
fn strings_mit_doppelten_anfuehrungszeichen() {
assert_eq!(kinds("\"a\"\"b\"")[0], TokenKind::Str("a\"b".into()));
}
#[test]
fn kommentare_und_rem() {
let k = kinds("PRINT 1 ' Kommentar\nREM ganze Zeile\nPRINT 2");
// PRINT 1 EOL EOL PRINT 2 EOL EOF
assert_eq!(k.len(), 8);
assert_eq!(k[2], TokenKind::Eol);
assert_eq!(k[3], TokenKind::Eol);
}
#[test]
fn zeilenfortsetzung() {
let k = kinds("PRINT 1, _\n 2");
// Kein Eol zwischen 1, und 2
assert!(matches!(k[3], TokenKind::Num(NumValue::Int(2))));
}
#[test]
fn bezeichner_mit_punkt_und_unterstrich() {
let k = kinds("kunde.name_2$");
assert_eq!(
k[0],
TokenKind::Ident {
name: "KUNDE.NAME_2".into(),
suffix: Some(Suffix::Str)
}
);
}
}

View File

@@ -2,16 +2,48 @@
//!
//! Enthält Lexer, Parser, AST-Definitionen und die semantische Analyse
//! (Symboltabellen, Typprüfung, implizite Deklarationen, `DEFINT`-Regeln usw.).
//! Ausgabe des Frontends ist ein typgeprüfter AST bzw. eine Zwischen-
//! repräsentation (IR), die von `tb-vm` in Bytecode übersetzt wird.
//! Ausgabe des Frontends ist ein typgeprüfter AST, den `tb-vm` in Bytecode
//! übersetzt.
pub mod ast;
pub mod lexer;
pub mod parser;
pub mod ast;
pub mod sema;
/// Quelltextposition für Diagnostik (1-basiert, wie im klassischen IDE-Vorbild).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Quelltextposition für Diagnostik (1-basiert, wie im IDE-Vorbild).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SourcePos {
pub line: u32,
pub column: u32,
}
/// Eine Diagnosemeldung mit Position. Die Texte folgen den (englischen)
/// Meldungen des Vorbilds, wo es eine Entsprechung gibt.
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub pos: SourcePos,
pub message: String,
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}: {}", self.pos.line, self.pos.column, self.message)
}
}
/// Ergebnis der Frontend-Pipeline für ein Modul.
pub struct Analysis {
pub module: ast::Module,
pub diagnostics: Vec<Diagnostic>,
}
/// Komplette Pipeline: Lexen → Parsen → semantische Prüfung.
pub fn analyze_source(module_name: &str, source: &str) -> Analysis {
let lexed = lexer::lex(source);
let mut diagnostics = lexed.diagnostics;
let parsed = parser::parse(module_name, &lexed.tokens);
diagnostics.extend(parsed.diagnostics);
diagnostics.extend(sema::check(&parsed.module));
diagnostics.sort_by_key(|d| (d.pos.line, d.pos.column));
Analysis { module: parsed.module, diagnostics }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
//! Phase-1-Meilenstein: der komplette Kompatibilitäts-Testkorpus
//! (tests/compat/*.bas) muss fehlerfrei parsen und typgeprüft werden.
use std::fs;
use std::path::Path;
#[test]
fn korpus_parst_und_wird_typgeprueft() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat");
let mut checked = 0;
for entry in fs::read_dir(&dir).expect("tests/compat fehlt") {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("bas") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().to_string();
let src = fs::read_to_string(&path).unwrap();
let analysis = tb_frontend::analyze_source(&name, &src);
assert!(
analysis.diagnostics.is_empty(),
"{name}: {:?}",
analysis.diagnostics
);
checked += 1;
}
assert!(checked >= 5, "zu wenige Korpusdateien gefunden: {checked}");
}

View File

@@ -7,7 +7,7 @@ license.workspace = true
authors.workspace = true
[[bin]]
name = "tbdos"
name = "tb"
path = "src/main.rs"
[dependencies]

View File

@@ -1,4 +1,4 @@
//! `tbdos` — die integrierte Entwicklungsumgebung von Terminal Basic (TUI).
//! `tb` — die integrierte Entwicklungsumgebung von Terminal Basic (TUI).
//!
//! Nachbildung der klassischen DOS-IDE: Menüleiste, Editor mit
//! Syntaxprüfung pro Zeile, Formular-Designer, Direktfenster,
@@ -6,6 +6,6 @@
//! Siehe PLAN.md, Phase 5.
fn main() -> anyhow::Result<()> {
println!("tbdos — Terminal Basic IDE (Projektrahmen, noch ohne Funktion)");
println!("tb — Terminal Basic IDE (Projektrahmen, noch ohne Funktion)");
Ok(())
}

View File

@@ -43,9 +43,10 @@ fn testbild(s: &mut TextScreen) {
fn status(s: &mut TextScreen, text: &str) {
let (r, c) = (s.csrlin(), s.pos());
let (row, width) = (s.rows(), s.cols());
s.set_color(0, 7);
s.locate(25, 1).unwrap();
s.print(&format!("{text:<80.80}"));
s.locate(row, 1).unwrap();
s.print(&format!("{text:<width$.width$}"));
s.set_color(7, 0);
s.locate(r, c).unwrap();
}
@@ -54,7 +55,8 @@ fn main() -> anyhow::Result<()> {
let mut terminal = ratatui::init();
execute!(stdout(), EnableMouseCapture)?;
let mut screen = TextScreen::new();
let (tw, th) = crossterm::terminal::size()?;
let mut screen = TextScreen::with_size(tw as usize, th as usize);
testbild(&mut screen);
status(&mut screen, "Bereit. Tasten/Maus testen, Esc beendet.");
@@ -79,7 +81,10 @@ fn main() -> anyhow::Result<()> {
&format!("Maus: {:?} bei Spalte {}, Zeile {}", m.kind, m.column + 1, m.row + 1),
);
}
Event::Resize(w, h) => status(&mut screen, &format!("Resize: {w}x{h}")),
Event::Resize(w, h) => {
screen.resize(w as usize, h as usize);
status(&mut screen, &format!("Resize: {w}x{h}"));
}
_ => {}
}
}

View File

@@ -1,8 +1,12 @@
//! Textbildschirm-Emulation: 80×25-Zellenpuffer (Unicode-Zeichen + Farb-
//! attribut), Cursor, Scrollen — gerendert als Ratatui-Widget.
//! Textbildschirm-Emulation: Zellenpuffer (Unicode-Zeichen + Farbattribut),
//! Cursor, Scrollen — gerendert als Ratatui-Widget.
//!
//! 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.
//!
//! Der Puffer ist die Grundlage für `PRINT`, `LOCATE`, `COLOR`, `CLS` usw.
//! Entscheidung (siehe PLAN.md): durchgängig Unicode, keine CP437-Emulation.
//! Das Zellenmodell ist strikt 1 Zeichen = 1 Zelle; Zeichen mit
//! Darstellungsbreite ≠ 1 sind eine offene Frage (PLAN.md).
@@ -11,8 +15,9 @@ use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::Widget;
pub const COLS: usize = 80;
pub const ROWS: usize = 25;
/// 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)]
@@ -37,12 +42,14 @@ pub fn basic_color(n: u8) -> Color {
Color::Indexed(MAP[(n & 0x0F) as usize])
}
/// Der emulierte 80×25-Textbildschirm.
/// Der emulierte Textbildschirm mit dynamischer Größe.
///
/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile 125,
/// Spalte 180), wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts.
/// 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,
@@ -54,6 +61,9 @@ pub struct TextScreen {
/// 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,
}
impl Default for TextScreen {
@@ -63,16 +73,63 @@ impl Default for TextScreen {
}
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 {
cells: vec![Cell::default(); COLS * ROWS],
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_bottom: rows - 1,
view_full: 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) {
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 }; 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;
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 = 0;
self.view_bottom = rows - 1;
} else {
self.view_top = self.view_top.min(rows - 1);
self.view_bottom = self.view_bottom.min(rows - 1);
}
}
@@ -81,23 +138,24 @@ impl TextScreen {
pub fn cls(&mut self) {
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg };
for row in self.view_top..=self.view_bottom {
self.cells[row * COLS..(row + 1) * COLS].fill(blank);
self.cells[row * self.cols..(row + 1) * self.cols].fill(blank);
}
self.cur_row = self.view_top;
self.cur_col = 0;
}
/// `COLOR vg, hg` — Werte werden wie im Vorbild maskiert
/// (vg 031, wir ignorieren Blink → 015; hg 07).
/// `COLOR vg, hg` (vg 031, hg 07). Blinkende Vordergrundfarben
/// (1631) werden als „hell" simuliert (Entscheidung 2026-09-02):
/// echtes Terminal-Blinken ist nicht überall verfügbar.
pub fn set_color(&mut self, fg: u8, bg: u8) {
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 des Bildschirms:
/// Fehler 5 beim Aufrufer — hier wird geklemmt geprüft.
/// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5).
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> {
if row < 1 || row > ROWS || col < 1 || col > COLS {
if row < 1 || row > self.rows || col < 1 || col > self.cols {
return Err(());
}
self.cur_row = row - 1;
@@ -117,32 +175,32 @@ impl TextScreen {
/// `VIEW PRINT oben TO unten` (1-basiert).
pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ()> {
if top < 1 || bottom > ROWS || top > bottom {
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) * COLS + (col - 1)]
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; andere Steuerzeichen werden
/// (noch) als normale Zeichen behandelt.
/// `\r` setzt an den Zeilenanfang.
pub fn print(&mut self, text: &str) {
for ch in text.chars() {
match ch {
'\n' => self.newline(),
'\r' => self.cur_col = 0,
_ => {
self.cells[self.cur_row * COLS + self.cur_col] =
self.cells[self.cur_row * self.cols + self.cur_col] =
Cell { ch, fg: self.fg, bg: self.bg };
self.cur_col += 1;
if self.cur_col >= COLS {
if self.cur_col >= self.cols {
self.newline();
}
}
@@ -165,34 +223,34 @@ impl TextScreen {
pub fn scroll_up(&mut self) {
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg };
for row in self.view_top..self.view_bottom {
let (a, b) = self.cells.split_at_mut((row + 1) * COLS);
a[row * COLS..].copy_from_slice(&b[..COLS]);
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 * COLS..(self.view_bottom + 1) * COLS].fill(blank);
self.cells[self.view_bottom * self.cols..(self.view_bottom + 1) * self.cols]
.fill(blank);
}
}
/// Rendert den Bildschirm zentriert (Letterboxing) in die verfügbare Fläche.
/// Ist das Terminal kleiner als 80×25, wird ein Hinweis angezeigt.
/// Rendert den Bildschirm oben links in die verfügbare Fläche. Ist das
/// Terminal kleiner als die Mindestgröße, erscheint nur ein Hinweis
/// (btop-artig); die Anwendung ruft bei Resize `TextScreen::resize` auf,
/// damit Puffer und Terminal deckungsgleich bleiben.
impl Widget for &TextScreen {
fn render(self, area: Rect, buf: &mut Buffer) {
if (area.width as usize) < COLS || (area.height as usize) < ROWS {
if (area.width as usize) < MIN_COLS || (area.height as usize) < MIN_ROWS {
let msg = format!(
"Terminal zu klein: {}x{}benötigt {}x{}",
area.width, area.height, COLS, ROWS
"Terminal zu klein: {}x{}Minimum {}x{}",
area.width, area.height, MIN_COLS, MIN_ROWS
);
if let Some(cell) = buf.cell_mut((area.x, area.y)) {
cell.set_symbol(" ");
}
buf.set_string(area.x, area.y, msg, Style::default().fg(Color::Red));
return;
}
let x0 = area.x + (area.width - COLS as u16) / 2;
let y0 = area.y + (area.height - ROWS as u16) / 2;
for row in 0..ROWS {
for col in 0..COLS {
let c = self.cells[row * COLS + col];
if let Some(cell) = buf.cell_mut((x0 + col as u16, y0 + row as u16)) {
let cols = self.cols.min(area.width as usize);
let rows = self.rows.min(area.height as usize);
for row in 0..rows {
for col in 0..cols {
let c = self.cells[row * self.cols + col];
if let Some(cell) = buf.cell_mut((area.x + col as u16, area.y + row as u16)) {
let mut s = String::new();
s.push(c.ch);
cell.set_symbol(&s);
@@ -204,10 +262,12 @@ impl Widget for &TextScreen {
}
// Cursor als invertierte Zelle darstellen (Terminal-Cursor wird in
// der Forms-/Runtime-Schicht später gezielt gesteuert).
if self.cursor_visible {
let (cx, cy) = (x0 + self.cur_col as u16, y0 + self.cur_row as u16);
if self.cursor_visible && self.cur_row < rows && self.cur_col < cols {
let (cx, cy) = (area.x + self.cur_col as u16, area.y + self.cur_row as u16);
if let Some(cell) = buf.cell_mut((cx, cy)) {
cell.set_style(cell.style().add_modifier(ratatui::style::Modifier::REVERSED));
cell.set_style(
cell.style().add_modifier(ratatui::style::Modifier::REVERSED),
);
}
}
}
@@ -248,7 +308,7 @@ mod tests {
let mut s = TextScreen::new();
s.locate(25, 1).unwrap();
s.print("unten\n"); // erzwingt Scroll
assert_eq!(s.cell(24, 1).ch, 'u'); // Zeile 25 ist nach 24 gerutscht
assert_eq!(s.cell(24, 1).ch, 'u');
assert_eq!(s.cell(25, 1).ch, ' ');
assert_eq!(s.csrlin(), 25);
}
@@ -261,7 +321,7 @@ mod tests {
s.view_print(3, 5).unwrap();
s.locate(5, 1).unwrap();
s.print("a\nb"); // scrollt nur Zeilen 35
assert_eq!(s.cell(1, 1).ch, 'k'); // Kopfzeile unberührt
assert_eq!(s.cell(1, 1).ch, 'k');
assert_eq!(s.cell(4, 1).ch, 'a');
assert_eq!(s.cell(5, 1).ch, 'b');
}
@@ -274,6 +334,47 @@ mod tests {
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_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');
}
#[test]
fn farbabbildung() {
assert_eq!(basic_color(1), Color::Indexed(4)); // klassisch Blau