Phase 5: Editor und inkrementellen Compiler implementieren und archivieren

This commit is contained in:
2026-09-06 17:58:26 +02:00
parent df85a4b7b2
commit 9c85349a4d
36 changed files with 2993 additions and 247 deletions

View File

@@ -202,6 +202,7 @@ pub struct Proc {
pub is_static: bool, pub is_static: bool,
pub body: Vec<Stmt>, pub body: Vec<Stmt>,
pub pos: SourcePos, pub pos: SourcePos,
pub end_pos: SourcePos,
} }
/// `OPEN … FOR modus`. /// `OPEN … FOR modus`.

View File

@@ -0,0 +1,361 @@
//! Quelltextpflege mit dem regulären Lexer und Parser; keine zweite BASIC-Grammatik.
use crate::{
ast::{ProcKind, ProcSig, Stmt, TypeName},
lexer::{self, TokenKind},
parser,
};
pub fn normalize_line(line: &str) -> String {
let mut result = line.to_owned();
for (range, text) in normalization_edits(line).into_iter().rev() {
result.replace_range(range, &text);
}
result
}
pub fn normalization_edits(line: &str) -> Vec<(std::ops::Range<usize>, String)> {
let lexed = lexer::lex(line);
if !lexed.diagnostics.is_empty() {
return Vec::new();
}
let offsets: Vec<_> = line
.char_indices()
.map(|(i, _)| i)
.chain([line.len()])
.collect();
let mut edits = Vec::new();
for token in &lexed.tokens {
if let TokenKind::Kw(kw) = token.kind {
let start = offsets[(token.pos.column as usize - 1).min(offsets.len() - 1)];
let word = format!("{kw:?}").to_uppercase();
let end = start + word.len();
if line
.get(start..end)
.is_some_and(|s| s.eq_ignore_ascii_case(&word))
{
edits.push((start..end, word));
let gap = line[end..]
.chars()
.take_while(|c| matches!(c, ' ' | '\t'))
.count();
// DATA-Nutztext beginnt direkt hinter dem Keyword und bleibt bytegenau.
if gap > 1 && kw != lexer::Kw::Data {
edits.push((end..end + gap, " ".into()));
}
}
}
}
let mut result = line.to_owned();
edits.retain(|(r, t)| &line[r.clone()] != t);
for (range, text) in edits.iter().rev() {
result.replace_range(range.clone(), text);
}
let kinds = |s: &str| {
lexer::lex(s)
.tokens
.into_iter()
.map(|t| t.kind)
.collect::<Vec<_>>()
};
if kinds(line) == kinds(&result) {
edits
} else {
Vec::new()
}
}
pub fn type_text(ty: &TypeName) -> String {
match ty {
TypeName::Str => "STRING".into(),
TypeName::FixedStr(n) => format!("STRING * {n}"),
TypeName::Udt(name) => name.clone(),
other => format!("{other:?}").to_uppercase(),
}
}
pub fn signature(sig: &ProcSig) -> String {
let suffix = |s: Option<lexer::Suffix>| s.map(|s| s.as_char().to_string()).unwrap_or_default();
format!(
"{} {}{} ({})",
if sig.kind == ProcKind::Sub {
"SUB"
} else {
"FUNCTION"
},
sig.name,
suffix(sig.suffix),
sig.params
.iter()
.map(|p| {
format!(
"{}{}{}{}",
p.name,
suffix(p.suffix),
if p.array { "()" } else { "" },
p.as_type
.as_ref()
.map(|t| format!(" AS {}", type_text(t)))
.unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join(", ")
)
}
pub const DECLARE_BEGIN: &str = "' $IDE DECLARE BEGIN";
pub const DECLARE_END: &str = "' $IDE DECLARE END";
/// Bei unsicherem Entwurf bleibt der Text vollständig unverändert; Meldung für die IDE.
pub fn maintain_declarations(source: &str) -> (String, Option<String>) {
maintain_declarations_with(
source,
|clean| {
let lexed = lexer::lex(clean);
let parsed = parser::parse("EDITOR", &lexed.tokens);
if let Some(d) = lexed.diagnostics.first().or(parsed.diagnostics.first()) {
return Err(d.to_string());
}
Ok(parsed.module)
},
|candidate| {
let analysis = crate::analyze_source("EDITOR", candidate);
analysis
.diagnostics
.first()
.map_or(Ok(()), |d| Err(d.to_string()))
},
)
}
/// Der Speicherhook kann die reguläre Projektauflösung samt Includes und Forms verwenden.
pub fn maintain_declarations_with(
source: &str,
resolve: impl Fn(&str) -> Result<crate::ast::Module, String>,
validate: impl Fn(&str) -> Result<(), String>,
) -> (String, Option<String>) {
let skip = |why: String| {
(
source.to_owned(),
Some(format!("DECLARE-Pflege übersprungen: {why}")),
)
};
let lines: Vec<_> = source.split_inclusive('\n').collect();
let begins: Vec<_> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim_end() == DECLARE_BEGIN)
.map(|(i, _)| i)
.collect();
let ends: Vec<_> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim_end() == DECLARE_END)
.map(|(i, _)| i)
.collect();
let old = match (begins.as_slice(), ends.as_slice()) {
([], []) => None,
([a], [b]) if a < b => Some(*a..b + 1),
_ => return skip("uneindeutiger IDE-Bereich".into()),
};
let clean: String = lines
.iter()
.enumerate()
.filter(|(i, _)| !old.as_ref().is_some_and(|r| r.contains(i)))
.map(|(_, l)| *l)
.collect();
let module = match resolve(&clean) {
Ok(module) => module,
Err(e) => return skip(e),
};
let exports = crate::sema::export_declarations(&module, &[]);
let mut generated = Vec::new();
for stmt in exports {
let Stmt::Declare { sig, .. } = stmt else {
continue;
};
let hands: Vec<_> = module
.body
.iter()
.filter_map(|s| match s {
Stmt::Declare { sig: hand, pos } if hand.name == sig.name => Some((hand, pos)),
_ => None,
})
.collect();
if hands.is_empty() {
generated.push(format!("DECLARE {}", signature(&sig)));
}
}
let mut result = clean.clone();
if !generated.is_empty() {
let nl = if source.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let local = parser::parse("EDITOR", &lexer::lex(&clean).tokens);
let first_line = local.module.procs.first().map_or(1, |p| p.pos.line);
let at = if first_line <= 1 {
0
} else {
clean
.match_indices('\n')
.nth(first_line as usize - 2)
.map_or(clean.len(), |(i, _)| i + 1)
};
result.insert_str(
at,
&format!(
"{DECLARE_BEGIN}{nl}{}{nl}{DECLARE_END}{nl}",
generated.join(nl)
),
);
}
if let Err(e) = validate(&result) {
return skip(e);
}
(result, None)
}
pub fn new_procedure(
source: &str,
at: usize,
name: &str,
function: bool,
) -> Result<String, String> {
let lexed = lexer::lex(name);
if !matches!(
lexed.tokens.as_slice(),
[
lexer::Token {
kind: TokenKind::Ident { suffix: None, .. },
..
},
lexer::Token {
kind: TokenKind::Eol,
..
},
lexer::Token {
kind: TokenKind::Eof,
..
}
]
) {
return Err("Ungültiger Prozedurname".into());
}
if !matches!(&lexed.tokens[0].kind, TokenKind::Ident { name: token_name, .. } if *token_name == name.to_uppercase())
|| !source.is_char_boundary(at)
|| at > source.len()
{
return Err("Ungültiger Prozedurname oder Cursor".into());
}
let source_tokens = lexer::lex(source);
let parsed = parser::parse("EDITOR", &source_tokens.tokens);
if !source_tokens.diagnostics.is_empty() || !parsed.diagnostics.is_empty() {
return Err("Vor Prozedurerzeugung den unvollständigen Quelltext abschließen".into());
}
if !name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) {
return Err("Prozedurname muss mit ASCII-Buchstaben beginnen".into());
}
let upper = name.to_uppercase();
if parsed.module.procs.iter().any(|p| p.sig.name == upper)
|| parsed
.module
.body
.iter()
.any(|s| matches!(s, Stmt::Declare {sig, ..} if sig.name == upper))
{
return Err("Prozedurname bereits vorhanden".into());
}
let line = source[..at].bytes().filter(|b| *b == b'\n').count() as u32 + 1;
let mut defaults = std::array::from_fn::<_, 26, _>(|_| Some(TypeName::Single));
let current = parsed
.module
.procs
.iter()
.rfind(|p| p.pos.line <= line && line <= p.end_pos.line);
let module_default = |stmt: &Stmt| matches!(stmt, Stmt::DefType { .. });
if current.is_some() {
for stmt in parsed.module.body.iter().filter(|s| module_default(s)) {
if let Stmt::DefType { ty, ranges, .. } = stmt {
for (a, b) in ranges {
for c in *a..=*b {
if c.is_ascii_alphabetic() {
defaults[c.to_ascii_uppercase() as usize - 'A' as usize] =
Some(ty.clone());
}
}
}
}
}
}
let mut statements: Vec<_> = parsed
.module
.body
.iter()
.filter(|_| current.is_none())
.chain(current.into_iter().flat_map(|p| p.body.iter()))
.collect();
statements.sort_by_key(|s| crate::sema::stmt_pos(s).line);
for stmt in statements {
if let Stmt::DefType { ty, ranges, pos } = stmt {
if pos.line <= line {
for (a, b) in ranges {
for c in *a..=*b {
if c.is_ascii_alphabetic() {
defaults[c.to_ascii_uppercase() as usize - 'A' as usize] =
Some(ty.clone());
}
}
}
}
}
}
let kind = if function { "FUNCTION" } else { "SUB" };
let mut result = format!("\n{kind} {name}");
if function {
// Rückgabetyp wird am Header festgehalten.
let ty = defaults[upper.as_bytes()[0] as usize - b'A' as usize]
.as_ref()
.unwrap_or(&TypeName::Single);
result.push(match ty {
TypeName::Integer => '%',
TypeName::Long => '&',
TypeName::Double => '#',
TypeName::Currency => '@',
TypeName::Str => '$',
_ => '!',
});
}
result.push_str(" ()\n");
let mut i = 0;
while i < defaults.len() {
let Some(ty) = &defaults[i] else {
i += 1;
continue;
};
let start = i;
while i + 1 < defaults.len() && defaults[i + 1].as_ref() == Some(ty) {
i += 1;
}
let kw = match ty {
TypeName::Integer => "DEFINT",
TypeName::Long => "DEFLNG",
TypeName::Double => "DEFDBL",
TypeName::Currency => "DEFCUR",
TypeName::Str => "DEFSTR",
_ => "DEFSNG",
};
result.push_str(&format!(
"{kw} {}{}\n",
(b'A' + start as u8) as char,
if start == i {
String::new()
} else {
format!("-{}", (b'A' + i as u8) as char)
}
));
i += 1;
}
result.push_str(&format!("\nEND {kind}\n"));
Ok(result)
}

View File

@@ -673,7 +673,7 @@ pub fn event_params(event: &str) -> Option<&'static [(&'static str, EventParamTy
}) })
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct FormObject { pub struct FormObject {
pub name: String, pub name: String,
pub class: ObjectClass, pub class: ObjectClass,
@@ -682,7 +682,7 @@ pub struct FormObject {
pub array: bool, pub array: bool,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct FormCatalog { pub struct FormCatalog {
pub objects: Vec<FormObject>, pub objects: Vec<FormObject>,
} }

View File

@@ -70,3 +70,5 @@ pub fn analyze_source_with_forms(
hir, hir,
} }
} }
pub mod editing;

View File

@@ -9,6 +9,8 @@ use crate::lexer::{Kw, NumValue, Token, TokenKind};
use crate::{Diagnostic, SourcePos}; use crate::{Diagnostic, SourcePos};
pub struct ParseOutput { pub struct ParseOutput {
/// Ausschließlich fehlende Blockabschlüsse am Dateiende.
pub incomplete: bool,
pub module: Module, pub module: Module,
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
@@ -19,6 +21,7 @@ pub fn parse(module_name: &str, tokens: &[Token]) -> ParseOutput {
i: 0, i: 0,
diags: Vec::new(), diags: Vec::new(),
at_line_start: true, at_line_start: true,
incomplete_errors: 0,
}; };
let mut body = Vec::new(); let mut body = Vec::new();
let mut procs = Vec::new(); let mut procs = Vec::new();
@@ -56,6 +59,7 @@ pub fn parse(module_name: &str, tokens: &[Token]) -> ParseOutput {
} }
ParseOutput { ParseOutput {
incomplete: !p.diags.is_empty() && p.incomplete_errors == p.diags.len(),
module: Module { module: Module {
name: module_name.to_string(), name: module_name.to_string(),
body, body,
@@ -70,6 +74,7 @@ struct P<'a> {
i: usize, i: usize,
diags: Vec<Diagnostic>, diags: Vec<Diagnostic>,
at_line_start: bool, at_line_start: bool,
incomplete_errors: usize,
} }
impl<'a> P<'a> { impl<'a> P<'a> {
@@ -105,10 +110,27 @@ impl<'a> P<'a> {
} }
fn err(&mut self, msg: impl Into<String>) { fn err(&mut self, msg: impl Into<String>) {
let pos = self.pos(); let pos = self.pos();
let msg = msg.into();
if matches!(self.k(), TokenKind::Eof)
&& matches!(
msg.as_str(),
"Expected: END SUB"
| "Expected: END FUNCTION"
| "Expected: END IF"
| "Expected: END SELECT"
| "Expected: END TYPE"
| "Expected: END DEF"
| "Expected: NEXT"
| "Expected: LOOP"
| "Expected: WEND"
)
{
self.incomplete_errors += 1;
}
self.diags.push(Diagnostic { self.diags.push(Diagnostic {
file: None, file: None,
pos, pos,
message: msg.into(), message: msg,
}); });
} }
fn expect_kw(&mut self, kw: Kw, what: &str) -> bool { fn expect_kw(&mut self, kw: Kw, what: &str) -> bool {
@@ -1783,6 +1805,7 @@ impl<'a> P<'a> {
ProcKind::Function => Kw::Function, ProcKind::Function => Kw::Function,
}; };
let body = self.parse_stmt_list(|p: &P| p.at_end_pair(end_kw)); let body = self.parse_stmt_list(|p: &P| p.at_end_pair(end_kw));
let end_pos = self.pos();
if self.at_end_pair(end_kw) { if self.at_end_pair(end_kw) {
self.advance(); self.advance();
self.advance(); self.advance();
@@ -1797,6 +1820,7 @@ impl<'a> P<'a> {
is_static, is_static,
body, body,
pos, pos,
end_pos,
}) })
} }

View File

@@ -7,14 +7,14 @@ pub struct SourceFile {
pub path: String, pub path: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct SourceSegment { pub struct SourceSegment {
pub file: String, pub file: String,
pub first_line: u32, pub first_line: u32,
pub text: String, pub text: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub struct SourceUnit { pub struct SourceUnit {
pub name: String, pub name: String,
pub segments: Vec<SourceSegment>, pub segments: Vec<SourceSegment>,

View File

@@ -0,0 +1,95 @@
use tb_frontend::{editing::*, lexer, parser};
#[test]
fn normalization_protects_tokens_and_is_idempotent() {
for source in [
" print Wort$; \"MiX Case\" ' Kommentar bleibt",
"data MiXeD, frei Text,\"Z\": print 1",
"if x% then print x%",
"rem frei text",
"print &Hff; 2.0#",
] {
let normalized = normalize_line(source);
assert_eq!(normalize_line(&normalized), normalized);
let tokens = |s| {
lexer::lex(s)
.tokens
.into_iter()
.map(|t| t.kind)
.collect::<Vec<_>>()
};
assert_eq!(tokens(source), tokens(&normalized));
}
assert_eq!(
normalize_line("print Wert$; \"MiX\" ' Kommentar"),
"PRINT Wert$; \"MiX\" ' Kommentar"
);
assert!(normalize_line("data MiXeD, frei Text").ends_with(" MiXeD, frei Text"));
}
#[test]
fn incomplete_is_parser_context_not_a_second_grammar() {
for source in ["SUB X\n", "IF 1 THEN\n", "FUNCTION X\n"] {
assert!(
parser::parse("M", &lexer::lex(source).tokens).incomplete,
"{source}"
);
}
for source in ["PRINT )\n", "SUB X\nPRINT )\n", "PRINT 1\n"] {
assert!(
!parser::parse("M", &lexer::lex(source).tokens).incomplete,
"{source}"
);
}
}
#[test]
fn declaration_maintenance_and_procedure_generation_are_transactional() {
let source = "DEFINT A-Z\nSUB Work (x)\nx=x+1\nEND SUB\n";
let (updated, notice) = maintain_declarations(source);
assert!(notice.is_none(), "{notice:?}");
assert!(updated.contains("DECLARE SUB WORK (X%)"), "{updated}");
assert_eq!(maintain_declarations(&updated), (updated.clone(), None));
for source in [
"SUB Draft\n",
"PRINT )\n",
"DECLARE SUB Work(x$)\nSUB Work(x%)\nEND SUB\n",
] {
let (same, notice) = maintain_declarations(source);
assert_eq!(same, source);
assert!(notice.is_some(), "{source}");
}
let manual = "DECLARE SUB Work(x AS INTEGER)\nSUB Work(x%)\nEND SUB\n";
assert_eq!(maintain_declarations(manual), (manual.into(), None));
let addition = new_procedure(source, source.len(), "Fresh", true).unwrap();
assert!(addition.contains("FUNCTION Fresh%"));
assert!(
tb_frontend::analyze_source("M", &format!("{source}{addition}"))
.diagnostics
.is_empty()
);
for name in ["Work", "PRINT", "bad name", ""] {
assert!(new_procedure(source, 0, name, false).is_err());
}
}
#[test]
fn deftype_context_does_not_leak_from_another_procedure() {
let source =
"' Modulrumpf\nDEFINT A-Z\nSUB First\nDEFSTR A-Z\nx$=\"x\"\nEND SUB\nSUB Second\nEND SUB\n";
let inside = source.find("x$=").unwrap();
assert!(new_procedure(source, inside, "Fresh", true)
.unwrap()
.contains("FUNCTION Fresh$"));
let second = source.find("SUB Second").unwrap();
assert!(new_procedure(source, second, "Fresh", true)
.unwrap()
.contains("FUNCTION Fresh%"));
let before = new_procedure(source, 0, "Fresh", true).unwrap();
assert!(before.contains("FUNCTION Fresh!"));
assert!(before.contains("DEFSNG A-Z"));
for at in [inside, second, 0] {
let addition = new_procedure(source, at, "Fresh", true).unwrap();
let diagnostics =
tb_frontend::analyze_source("M", &format!("{source}{addition}")).diagnostics;
assert!(diagnostics.is_empty(), "{diagnostics:?}");
}
assert!(new_procedure(source, 0, "Foo ' comment", false).is_err());
}

View File

@@ -185,6 +185,13 @@ pub enum AfterSave {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum DialogKind { pub enum DialogKind {
Search {
view: ViewId,
selection: Option<std::ops::Range<usize>>,
},
Procedure(bool),
Procedures(Vec<usize>),
Diagnostics,
Browse { Browse {
return_to: Box<Dialog>, return_to: Box<Dialog>,
field: usize, field: usize,
@@ -249,6 +256,7 @@ pub enum Hit {
} }
pub struct App { pub struct App {
pub editor: crate::editor::Editor,
pub project: Project, pub project: Project,
pub options: Options, pub options: Options,
pub mode: Mode, pub mode: Mode,
@@ -285,6 +293,7 @@ impl App {
let (options, errors, config_disk) = Options::load(&config_path); let (options, errors, config_disk) = Options::load(&config_path);
project.include_paths = options.include_paths.clone(); project.include_paths = options.include_paths.clone();
let mut app = Self { let mut app = Self {
editor: Default::default(),
project, project,
options: options.clone(), options: options.clone(),
saved_options: options, saved_options: options,
@@ -393,8 +402,13 @@ impl App {
.into_owned() .into_owned()
}); });
format!( format!(
"[{}] {}{}", "[{}] {}{}{}",
w.id, w.id,
if self.editor.expansions.contains_key(&v) {
"Included Lines [read-only] · "
} else {
""
},
name, name,
if doc.is_dirty() { " *" } else { "" } if doc.is_dirty() { " *" } else { "" }
) )
@@ -438,12 +452,41 @@ impl App {
return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}")); return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}"));
} }
use Command::*; use Command::*;
if matches!(command, LoadText | SaveText) if matches!(
&& !matches!( command,
Undo | Cut | Paste | Clear | LoadText | NewSub | NewFunction | Replace
) && self
.editor_view()
.is_ok_and(|v| self.editor.expansions.contains_key(&v))
{
return Some(
"Included Lines ist schreibgeschützt; Included File öffnet die Originaldatei"
.into(),
);
}
if matches!(
command,
LoadText
| SaveText
| Cut
| Copy
| Paste
| Clear
| NewSub
| NewFunction
| IncludedFile
| IncludedLines
| Find
| SelectedText
| FindNext
| Replace
| Procedures
| PreviousCode
| Diagnostics
) && !matches!(
self.active_window().map(|w| w.kind), self.active_window().map(|w| w.kind),
Some(WindowKind::Code(_)) Some(WindowKind::Code(_))
) ) {
{
return Some("Kein Codefenster aktiv".into()); return Some("Kein Codefenster aktiv".into());
} }
if matches!( if matches!(
@@ -489,6 +532,9 @@ impl App {
use Command::*; use Command::*;
let id = self.active_document(); let id = self.active_document();
match command { match command {
Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines
| Find | SelectedText | FindNext | Replace | Procedures | PreviousCode
| Diagnostics => self.editor_command(command)?,
NewProject => self.change_project(AfterSave::NewProject)?, NewProject => self.change_project(AfterSave::NewProject)?,
OpenProject => self.open_dialog( OpenProject => self.open_dialog(
"Open Project", "Open Project",
@@ -531,7 +577,10 @@ impl App {
{ {
self.save_dialog(false, vec![id], AfterSave::Stay)?; self.save_dialog(false, vec![id], AfterSave::Stay)?;
} else { } else {
self.message = "Datei gespeichert".into(); self.message = format!(
"Datei gespeichert · {}",
self.project.save_notices.join("; ")
);
} }
} }
SaveAs => self.save_dialog(false, vec![id.unwrap()], AfterSave::Stay)?, SaveAs => self.save_dialog(false, vec![id.unwrap()], AfterSave::Stay)?,
@@ -795,7 +844,12 @@ impl App {
.display() .display()
.to_string() .to_string()
} }
fn open_dialog(&mut self, title: impl Into<String>, kind: DialogKind, fields: Vec<Field>) { pub(crate) fn open_dialog(
&mut self,
title: impl Into<String>,
kind: DialogKind,
fields: Vec<Field>,
) {
self.dialog = Some(Dialog::new(title, kind, fields)); self.dialog = Some(Dialog::new(title, kind, fields));
} }
fn cascade(&self) -> Rect { fn cascade(&self) -> Rect {
@@ -810,7 +864,7 @@ impl App {
self.add_window(kind, self.cascade()); self.add_window(kind, self.cascade());
} }
} }
fn show_document(&mut self, id: DocumentId, form: bool) -> Result<()> { pub(crate) fn show_document(&mut self, id: DocumentId, form: bool) -> Result<()> {
if form { if form {
ensure!( ensure!(
matches!(self.project.document(id)?.content(), Content::Form(_)), matches!(self.project.document(id)?.content(), Content::Form(_)),
@@ -890,6 +944,7 @@ impl App {
} }
} }
self.base = self.project.directory().to_path_buf(); self.base = self.project.directory().to_path_buf();
self.editor = Default::default();
self.windows.clear(); self.windows.clear();
self.active = 0; self.active = 0;
self.selected_member = 0; self.selected_member = 0;
@@ -961,6 +1016,10 @@ impl App {
} }
fn submit(&mut self, d: &mut Dialog) -> Result<bool> { fn submit(&mut self, d: &mut Dialog) -> Result<bool> {
match d.kind.clone() { match d.kind.clone() {
DialogKind::Search { .. }
| DialogKind::Procedure(_)
| DialogKind::Procedures(_)
| DialogKind::Diagnostics => return self.editor_submit(d),
DialogKind::Browse { DialogKind::Browse {
mut return_to, mut return_to,
field, field,
@@ -1075,19 +1134,18 @@ impl App {
} else { } else {
self.project.save_file(ids[0], plan.files.get(&ids[0]))?; self.project.save_file(ids[0], plan.files.get(&ids[0]))?;
} }
self.message = "Gespeichert".into(); self.message = format!("Gespeichert · {}", self.project.save_notices.join("; "));
self.finish_change(after)?; self.finish_change(after)?;
} }
DialogKind::LoadText => { DialogKind::LoadText => {
let (id, at) = self.code_cursor()?; let text = std::fs::read_to_string(self.base.join(d.fields[0].string()))?;
self.project self.editor_insert(&text)?;
.load_text(id, at, &self.base.join(d.fields[0].string()))?;
} }
DialogKind::SaveText => { DialogKind::SaveText => {
let (id, _) = self.code_cursor()?; let (id, _) = self.code_cursor()?;
self.project.save_text( self.project.save_text(
id, id,
None, self.project.view(self.editor_view()?)?.selection(),
&Destination { &Destination {
path: d.fields[0].string().into(), path: d.fields[0].string().into(),
overwrite: d.fields[1].flag(), overwrite: d.fields[1].flag(),
@@ -1189,6 +1247,14 @@ impl App {
Ok((view.document(), view.cursor)) Ok((view.document(), view.cursor))
} }
pub fn handle(&mut self, event: Event) { pub fn handle(&mut self, event: Event) {
let old = self.editor_position();
if !matches!(event, Event::Key(_) | Event::Resize(_, _)) {
self.editor.chord = None;
}
self.handle_event(event);
self.line_leave(old);
}
fn handle_event(&mut self, event: Event) {
if let Event::Resize(w, h) = event { if let Event::Resize(w, h) = event {
self.size = (w, h); self.size = (w, h);
return; return;
@@ -1253,6 +1319,12 @@ impl App {
} }
} else if self.program_focus() { } else if self.program_focus() {
self.basic_events.push(event); self.basic_events.push(event);
} else if let Event::Paste(text) = event {
if self.dialog.is_none() && self.menu.is_none() && self.mode == Mode::Environment {
if let Err(e) = self.editor_insert(&text) {
self.message = e.to_string();
}
}
} }
} }
fn click(&mut self, hit: Hit) { fn click(&mut self, hit: Hit) {
@@ -1334,6 +1406,16 @@ impl App {
) )
} }
fn key(&mut self, key: KeyEvent) { fn key(&mut self, key: KeyEvent) {
if self.editor.chord.is_some()
&& self.dialog.is_none()
&& self.menu.is_none()
&& !self.program_focus()
{
if let Err(e) = self.editor_key(key) {
self.message = e.to_string();
}
return;
}
if key.code == K::F(2) && self.dialog.is_some() { if key.code == K::F(2) && self.dialog.is_some() {
self.browse(); self.browse();
return; return;
@@ -1606,48 +1688,10 @@ impl App {
)) ))
} }
fn code_key(&mut self, key: KeyEvent) -> Result<()> { fn code_key(&mut self, key: KeyEvent) -> Result<()> {
let Some(Window { if self.editor_view().is_err() {
kind: WindowKind::Code(view),
..
}) = self.active_window()
else {
return Ok(()); return Ok(());
};
let view = *view;
let (id, at) = self.code_cursor()?;
let code = self.project.document(id)?.code().to_owned();
let mut next = at;
match key.code {
K::Char(c) if !key.modifiers.intersects(M::CONTROL | M::ALT) => {
self.project.replace_text(id, at..at, &c.to_string())?;
next += c.len_utf8();
} }
K::Enter => { self.editor_key(key)
self.project.replace_text(id, at..at, "\n")?;
next += 1;
}
K::Tab => {
self.project.replace_text(id, at..at, "\t")?;
next += 1;
}
K::Backspace if at > 0 => {
next = code[..at].char_indices().last().unwrap().0;
self.project.replace_text(id, next..at, "")?;
}
K::Left => {
next = code[..at]
.char_indices()
.last()
.map(|(i, _)| i)
.unwrap_or(0)
}
K::Right => next += code[at..].chars().next().map(char::len_utf8).unwrap_or(0),
K::Home => next = code[..at].rfind('\n').map(|n| n + 1).unwrap_or(0),
K::End => next = code[at..].find('\n').map(|n| at + n).unwrap_or(code.len()),
_ => {}
}
self.project.view_mut(view)?.cursor = next;
Ok(())
} }
pub fn menu_entries(&self, index: usize) -> Vec<(String, Option<Command>)> { pub fn menu_entries(&self, index: usize) -> Vec<(String, Option<Command>)> {
if self.control_menu { if self.control_menu {
@@ -1732,6 +1776,8 @@ pub fn shortcut(key: KeyEvent) -> Option<Command> {
K::F(5) if ctrl => Restore, K::F(5) if ctrl => Restore,
K::F(1) if shift => UsingHelp, K::F(1) if shift => UsingHelp,
K::F(1) => Topic, K::F(1) => Topic,
K::F(2) if shift => Procedures,
K::F(2) if ctrl => PreviousCode,
K::F(2) => Code, K::F(2) => Code,
K::F(3) => FindNext, K::F(3) => FindNext,
K::F(4) => OutputScreen, K::F(4) => OutputScreen,
@@ -1746,6 +1792,10 @@ pub fn shortcut(key: KeyEvent) -> Option<Command> {
K::F(10) => ProcedureStep, K::F(10) => ProcedureStep,
K::F(12) if shift => Form, K::F(12) if shift => Form,
K::F(12) => Events, K::F(12) => Events,
K::Insert if ctrl => Copy,
K::Insert if shift => Paste,
K::Delete if shift => Cut,
K::Backspace if key.modifiers.contains(M::ALT) => Undo,
K::Char('c') if ctrl => Copy, K::Char('c') if ctrl => Copy,
K::Char('x') if ctrl => Cut, K::Char('x') if ctrl => Cut,
K::Char('v') if ctrl => Paste, K::Char('v') if ctrl => Paste,

View File

@@ -20,6 +20,9 @@ pub enum Command {
Copy, Copy,
Paste, Paste,
Clear, Clear,
Procedures,
PreviousCode,
Diagnostics,
NewSub, NewSub,
NewFunction, NewFunction,
Events, Events,
@@ -97,8 +100,6 @@ impl Command {
pub fn feature_phase(self) -> Option<u8> { pub fn feature_phase(self) -> Option<u8> {
use Command::*; use Command::*;
match self { match self {
Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines
| Find | SelectedText | FindNext | Replace => Some(3),
Print | Shell | Start | Restart | Continue | CommandLine | OutputScreen => Some(4), Print | Shell | Start | Restart | Continue | CommandLine | OutputScreen => Some(4),
Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5), Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5),
NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches
@@ -194,6 +195,8 @@ pub fn menus(designer: bool) -> Vec<Menu> {
'v', 'v',
vec![ vec![
item("&Code…", Code), item("&Code…", Code),
item("&Procedures…", Procedures),
item("Diagno&stics…", Diagnostics),
item("&Form…", Form), item("&Form…", Form),
sep(), sep(),
item("&Next Statement", NextStatement), item("&Next Statement", NextStatement),

View File

@@ -31,10 +31,18 @@ pub struct ViewId(u64);
pub struct View { pub struct View {
document: DocumentId, document: DocumentId,
pub cursor: usize, pub cursor: usize,
pub anchor: Option<usize>,
pub bookmarks: [Option<usize>; 4],
pub overwrite: bool,
pub scroll_line: usize, pub scroll_line: usize,
pub scroll_column: usize, pub scroll_column: usize,
} }
impl View { impl View {
pub fn selection(&self) -> Option<Range<usize>> {
self.anchor
.filter(|a| *a != self.cursor)
.map(|a| a.min(self.cursor)..a.max(self.cursor))
}
pub fn document(&self) -> DocumentId { pub fn document(&self) -> DocumentId {
self.document self.document
} }
@@ -121,6 +129,7 @@ pub struct Project {
documents: BTreeMap<DocumentId, Document>, documents: BTreeMap<DocumentId, Document>,
views: BTreeMap<ViewId, View>, views: BTreeMap<ViewId, View>,
pub include_paths: Vec<PathBuf>, pub include_paths: Vec<PathBuf>,
pub save_notices: Vec<String>,
} }
impl Project { impl Project {
pub fn directory(&self) -> &Path { pub fn directory(&self) -> &Path {
@@ -136,6 +145,7 @@ impl Project {
documents: BTreeMap::new(), documents: BTreeMap::new(),
views: BTreeMap::new(), views: BTreeMap::new(),
include_paths: Vec::new(), include_paths: Vec::new(),
save_notices: Vec::new(),
}) })
} }
pub fn open(path: &Path, include_paths: Vec<PathBuf>) -> Result<Self> { pub fn open(path: &Path, include_paths: Vec<PathBuf>) -> Result<Self> {
@@ -385,6 +395,9 @@ impl Project {
View { View {
document, document,
cursor: 0, cursor: 0,
anchor: None,
bookmarks: [None; 4],
overwrite: false,
scroll_line: 0, scroll_line: 0,
scroll_column: 0, scroll_column: 0,
}, },
@@ -415,6 +428,32 @@ impl Project {
if old.content == content { if old.content == content {
return Ok(()); return Ok(());
} }
let before = old.code();
let after = content.code();
let prefix = before
.chars()
.zip(after.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
let suffix = before[prefix..]
.chars()
.rev()
.zip(after[prefix..].chars().rev())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
let removed = prefix..before.len() - suffix;
let inserted = after.len() - prefix - suffix;
let remap = |at: usize| {
if at >= removed.end {
at - removed.len() + inserted
} else if at > removed.start {
removed.start + (at - removed.start).min(inserted)
} else {
at
}
};
let undo = Edit { let undo = Edit {
content: old.content.clone(), content: old.content.clone(),
views: self views: self
@@ -430,39 +469,64 @@ impl Project {
doc.revision += 1; doc.revision += 1;
let code = doc.code(); let code = doc.code();
for view in self.views.values_mut().filter(|v| v.document == id) { for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor); view.cursor = boundary(code, remap(view.cursor));
view.anchor = view.anchor.map(|at| boundary(code, remap(at)));
view.bookmarks = view
.bookmarks
.map(|at| at.map(|at| boundary(code, remap(at))));
} }
Ok(()) Ok(())
} }
pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> { pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> {
self.replace_ranges(id, &[(range, text.to_owned())])
}
/// Geordnete, disjunkte Ersetzungen: eine Undo-Einheit und positionsgetreue Ansichten.
pub fn replace_ranges(
&mut self,
id: DocumentId,
edits: &[(Range<usize>, String)],
) -> Result<()> {
let mut content = self.document(id)?.content.clone(); let mut content = self.document(id)?.content.clone();
let mut end = 0;
for (range, _) in edits {
ensure!( ensure!(
range.start <= range.end && content.code().get(range.clone()).is_some(), range.start >= end
&& range.start <= range.end
&& content.code().get(range.clone()).is_some(),
"Ungültiger Textbereich" "Ungültiger Textbereich"
); );
content.code_mut().replace_range(range.clone(), text); end = range.end;
let cursors: Vec<_> = self }
let views: Vec<_> = self
.views .views
.iter() .iter()
.filter(|(_, v)| v.document == id) .filter(|(_, v)| v.document == id)
.map(|(id, v)| (*id, v.cursor)) .map(|(id, v)| (*id, v.clone()))
.collect(); .collect();
for (range, text) in edits.iter().rev() {
content.code_mut().replace_range(range.clone(), text);
}
self.commit_edit(id, content)?; self.commit_edit(id, content)?;
for (view_id, cursor) in cursors { for (view_id, old) in views {
let view = self.views.get_mut(&view_id).unwrap(); let map = |mut at: usize| {
view.cursor = cursor; for (range, text) in edits.iter().rev() {
if view.cursor > range.start { if at > range.start {
view.cursor = if view.cursor >= range.end { at = if at >= range.end {
view.cursor - range.len() + text.len() at - range.len() + text.len()
} else { } else {
range.start + text.len() range.start + (at - range.start).min(text.len())
}; };
} }
} }
// Neue Positionen werden auch nach Ersetzungen mit Unicode begrenzt. boundary(self.documents[&id].code(), at)
let code = self.documents[&id].code(); };
for view in self.views.values_mut().filter(|v| v.document == id) { let cursor = map(old.cursor);
view.cursor = boundary(code, view.cursor); let anchor = old.anchor.map(map);
let bookmarks = old.bookmarks.map(|p| p.map(map));
let view = self.views.get_mut(&view_id).unwrap();
view.cursor = cursor;
view.anchor = anchor;
view.bookmarks = bookmarks;
} }
Ok(()) Ok(())
} }
@@ -497,6 +561,8 @@ impl Project {
let code = doc.code(); let code = doc.code();
for view in self.views.values_mut().filter(|v| v.document == id) { for view in self.views.values_mut().filter(|v| v.document == id) {
view.cursor = boundary(code, view.cursor); view.cursor = boundary(code, view.cursor);
view.anchor = view.anchor.map(|p| boundary(code, p));
view.bookmarks = view.bookmarks.map(|p| p.map(|p| boundary(code, p)));
} }
Ok(true) Ok(true)
} }
@@ -612,10 +678,64 @@ impl Project {
pub fn save_file(&mut self, id: DocumentId, target: Option<&Destination>) -> Result<()> { pub fn save_file(&mut self, id: DocumentId, target: Option<&Destination>) -> Result<()> {
let (path, overwrite) = self.file_target(id, target)?; let (path, overwrite) = self.file_target(id, target)?;
let doc = self.document(id)?; let doc = self.document(id)?;
let content = loaded( let mut content = loaded(
self.loader()? self.loader()?
.relocate(&doc.content, &doc.source_path, &path), .relocate(&doc.content, &doc.source_path, &path),
)?; )?;
let prospective = |code: &str| -> std::result::Result<ProjectSources, String> {
let mut loader = self.loader().map_err(|e| e.to_string())?;
let mut candidate = content.clone();
*candidate.code_mut() = code.to_owned();
loader.insert(&path, candidate)?;
let mut manifest = self.manifest.clone();
manifest.rename(&doc.source_path, &path);
// Includes werden im Kontext ihrer Verbraucher geprüft.
loader.load_manifest(manifest)
};
let (code, notice) = tb_frontend::editing::maintain_declarations_with(
content.code(),
|code| {
let input = prospective(code)?;
let unit = input
.units
.iter()
.find(|u| {
u.segments
.iter()
.any(|s| s.file == path.display().to_string())
})
.ok_or_else(|| "Keine Übersetzungseinheit für dieses Dokument".to_owned())?;
let mut sources = Vec::new();
let (mut module, mut diagnostics) = unit.parse(0, &mut sources);
tb_frontend::source::locate_diagnostics(&mut diagnostics, &sources);
if let Some(d) = diagnostics.first() {
return Err(d.to_string());
}
// Nur Prozeduren dieses physischen Dokuments pflegen, nicht die eines Includes.
module.procs.retain(|p| {
sources
.get(p.pos.source as usize)
.is_some_and(|s| s.path == path.display().to_string())
});
Ok(module)
},
|code| {
let input = prospective(code)?;
let mut catalog = tb_frontend::forms::FormCatalog::default();
for form in &input.forms {
catalog.append(&form.catalog());
}
tb_vm::compile_project("SAVE", &input.units, &catalog, &input.forms)
.map(|_| ())
.map_err(|d| {
d.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ")
})
},
);
*content.code_mut() = code;
let text = content.text(); let text = content.text();
let expected = if doc.path.as_deref() == Some(&path) { let expected = if doc.path.as_deref() == Some(&path) {
doc.disk.as_deref() doc.disk.as_deref()
@@ -623,6 +743,10 @@ impl Project {
None None
}; };
atomic_write(&path, text.as_bytes(), expected, overwrite)?; atomic_write(&path, text.as_bytes(), expected, overwrite)?;
self.save_notices = notice
.into_iter()
.map(|n| format!("{}: {n}", path.display()))
.collect();
self.commit_edit(id, content)?; self.commit_edit(id, content)?;
let doc = self.documents.get_mut(&id).unwrap(); let doc = self.documents.get_mut(&id).unwrap();
self.manifest.rename(&doc.source_path, &path); self.manifest.rename(&doc.source_path, &path);
@@ -686,8 +810,12 @@ impl Project {
expected, expected,
plan.project.as_ref().is_some_and(|d| d.overwrite), plan.project.as_ref().is_some_and(|d| d.overwrite),
)?; )?;
let mut notices = Vec::new();
for id in ids { for id in ids {
self.save_file(id, plan.files.get(&id))?; let result = self.save_file(id, plan.files.get(&id));
notices.extend(self.save_notices.clone());
self.save_notices = notices.clone();
result?;
} }
// Zweite Konfliktprüfung direkt vor Ersetzen; ein Teilfehler lässt die alte Projektidentität aktiv. // Zweite Konfliktprüfung direkt vor Ersetzen; ein Teilfehler lässt die alte Projektidentität aktiv.
let expected = if self.path.as_deref() == Some(&target) { let expected = if self.path.as_deref() == Some(&target) {

918
crates/tb-ide/src/editor.rs Normal file
View File

@@ -0,0 +1,918 @@
//! Editoraktionen auf gemeinsamen Dokumenten und revisionsgebundene Übersetzung.
use crate::{
app::{App, Dialog, DialogKind, Field, WindowKind},
commands::Command,
documents::{DocumentId, ViewId},
export::ProjectStamp,
};
use anyhow::{anyhow, ensure, Result};
use crossterm::event::{KeyCode as K, KeyEvent, KeyModifiers as M};
use std::{collections::BTreeMap, ops::Range, path::PathBuf};
use tb_frontend::{
lexer::{self, TokenKind},
source::SourceUnit,
Diagnostic,
};
use tb_vm::{bytecode::CompiledModule, project::ProjectCompiler};
use unicode_width::UnicodeWidthChar;
#[derive(Default, Clone)]
pub struct Search {
pub text: String,
pub replacement: String,
pub backwards: bool,
pub case_sensitive: bool,
pub whole_word: bool,
pub scope: Option<Range<usize>>,
pub document: Option<DocumentId>,
pub wrap: bool,
}
#[derive(Default)]
pub struct Editor {
pub clipboard: String,
pub chord: Option<char>,
pub search: Search,
pub history: Vec<(DocumentId, usize)>,
pub expansions: BTreeMap<ViewId, String>,
pub checked: BTreeMap<DocumentId, u64>,
pub compiler: ProjectCompiler,
pub diagnostics: Vec<Diagnostic>,
revision: Option<(ProjectStamp, Vec<SourceUnit>, Vec<String>)>,
compiled: Option<CompiledModule>,
}
pub fn line_start(code: &str, at: usize) -> usize {
code[..at].rfind('\n').map_or(0, |p| p + 1)
}
pub fn line_end(code: &str, at: usize) -> usize {
code[at..].find('\n').map_or(code.len(), |p| at + p)
}
pub fn column(code: &str, tab: usize) -> usize {
code.chars().fold(0, |n, c| {
n + if c == '\t' {
tab - n % tab
} else {
c.width().unwrap_or(0)
}
})
}
pub fn offset(code: &str, col: usize, tab: usize) -> usize {
let mut width = 0;
for (i, c) in code.char_indices() {
let w = if c == '\t' {
tab - width % tab
} else {
c.width().unwrap_or(0)
};
if width + w > col {
return i;
}
width += w;
}
code.len()
}
fn word(c: char) -> bool {
c.is_alphanumeric() || c == '_' || "%&!#$@".contains(c)
}
fn previous(code: &str, at: usize) -> usize {
if code[..at].ends_with("\r\n") {
return at - 2;
}
code[..at].char_indices().last().map_or(0, |(i, _)| i)
}
fn next(code: &str, at: usize) -> usize {
if code[at..].starts_with("\r\n") {
return at + 2;
}
at + code[at..].chars().next().map_or(0, char::len_utf8)
}
fn matches(code: &str, search: &Search, scope: Range<usize>) -> Vec<Range<usize>> {
if search.text.is_empty() {
return vec![];
}
let mut result = Vec::new();
let mut end_previous = scope.start;
for (i, _) in code[scope.clone()].char_indices() {
let start = scope.start + i;
if start < end_previous {
continue;
}
let end = code[start..scope.end]
.char_indices()
.nth(search.text.chars().count())
.map_or(scope.end, |(i, _)| start + i);
let value = &code[start..end];
let equal = if search.case_sensitive {
value == search.text
} else {
value.to_lowercase() == search.text.to_lowercase()
};
if equal
&& (!search.whole_word
|| (!code[..start].chars().next_back().is_some_and(word)
&& !code[end..].chars().next().is_some_and(word)))
{
result.push(start..end);
end_previous = end;
}
}
result
}
impl App {
pub(crate) fn editor_view(&self) -> Result<ViewId> {
match self.active_window().map(|w| w.kind) {
Some(WindowKind::Code(v)) => Ok(v),
_ => Err(anyhow!("Kein Codefenster aktiv")),
}
}
pub(crate) fn editor_insert(&mut self, text: &str) -> Result<()> {
let v = self.editor_view()?;
ensure!(
!self.editor.expansions.contains_key(&v),
"Included Lines ist schreibgeschützt; Included File öffnet die Originaldatei"
);
let view = self.project.view(v)?;
let id = view.document();
let range = view.selection().unwrap_or(view.cursor..view.cursor);
let cursor = range.start + text.len();
self.project.replace_text(id, range, text)?;
let view = self.project.view_mut(v)?;
view.cursor = cursor;
view.anchor = None;
self.editor_scroll()?;
Ok(())
}
pub(crate) fn editor_scroll(&mut self) -> Result<()> {
let v = self.editor_view()?;
let rect = self.rect(self.active_window().unwrap());
let view = self.project.view(v)?;
let code = self.project.document(view.document())?.code();
let row = code[..view.cursor].bytes().filter(|b| *b == b'\n').count();
let col = column(
&code[line_start(code, view.cursor)..view.cursor],
self.options.tab_width,
);
let view = self.project.view_mut(v)?;
let height = rect.height.saturating_sub(2).max(1) as usize;
let width = rect.width.saturating_sub(2).max(1) as usize;
if row < view.scroll_line {
view.scroll_line = row;
} else if row >= view.scroll_line + height {
view.scroll_line = row + 1 - height;
}
if col < view.scroll_column {
view.scroll_column = col;
} else if col >= view.scroll_column + width {
view.scroll_column = col + 1 - width;
}
Ok(())
}
pub(crate) fn editor_key(&mut self, key: KeyEvent) -> Result<()> {
let v = self.editor_view()?;
if let Some(expansion) = self.editor.expansions.get(&v) {
let count = expansion.lines().count();
let step = self
.rect(self.active_window().unwrap())
.height
.saturating_sub(3)
.max(1) as usize;
let view = self.project.view_mut(v)?;
match key.code {
K::Up => view.scroll_line = view.scroll_line.saturating_sub(1),
K::Down => view.scroll_line = (view.scroll_line + 1).min(count.saturating_sub(1)),
K::PageUp => view.scroll_line = view.scroll_line.saturating_sub(step),
K::PageDown => {
view.scroll_line = (view.scroll_line + step).min(count.saturating_sub(1))
}
K::Home => view.scroll_line = 0,
K::End => view.scroll_line = count.saturating_sub(step),
_ => {}
}
return Ok(());
}
let view = self.project.view(v)?.clone();
let id = view.document();
let code = self.project.document(id)?.code().to_owned();
let at = view.cursor;
let ctrl = key.modifiers.contains(M::CONTROL);
let shift = key.modifiers.contains(M::SHIFT);
let mut destination;
if let Some(chord) = self.editor.chord.take() {
if let K::Char(c) = key.code {
match (chord, c.to_ascii_lowercase()) {
('q', 's') => destination = line_start(&code, at),
('q', 'd') => destination = line_end(&code, at),
('q', 'r') => destination = 0,
('q', 'c') => destination = code.len(),
('k', c @ '0'..='3') => {
self.project.view_mut(v)?.bookmarks[c as usize - '0' as usize] = Some(at);
return Ok(());
}
('q', c @ '0'..='3') => {
destination = view.bookmarks[c as usize - '0' as usize].unwrap_or(at)
}
_ => return Ok(()),
}
} else {
return Ok(());
}
} else {
match key.code {
K::Char(c @ ('q' | 'k')) if ctrl => {
self.editor.chord = Some(c);
self.message = format!("Ctrl+{} … (Esc bricht ab)", c.to_ascii_uppercase());
return Ok(());
}
K::Char('a') if ctrl => {
let v = self.project.view_mut(v)?;
v.anchor = Some(0);
v.cursor = code.len();
return self.editor_scroll();
}
K::Char('y') if ctrl => {
let range = line_start(&code, at)..(line_end(&code, at) + 1).min(code.len());
self.project.replace_text(id, range.clone(), "")?;
let v = self.project.view_mut(v)?;
v.cursor = range.start;
v.anchor = None;
return self.editor_scroll();
}
K::Insert => {
self.project.view_mut(v)?.overwrite = !view.overwrite;
self.message = if view.overwrite {
"Einfügen"
} else {
"Überschreiben"
}
.into();
return Ok(());
}
K::Char(c) if !key.modifiers.intersects(M::ALT | M::CONTROL) => {
if view.overwrite
&& view.selection().is_none()
&& at < line_end(&code, at)
&& !code[at..].starts_with("\r\n")
{
self.project
.replace_text(id, at..next(&code, at), &c.to_string())?;
self.project.view_mut(v)?.cursor = at + c.len_utf8();
return self.editor_scroll();
}
return self.editor_insert(&c.to_string());
}
K::Enter => {
return self.editor_insert(if code.contains("\r\n") { "\r\n" } else { "\n" })
}
K::Tab => return self.editor_insert("\t"),
K::Backspace | K::Delete => {
let range = view.selection().unwrap_or_else(|| {
if key.code == K::Backspace {
let p = previous(&code, at);
if code.get(p..at) == Some("\n")
&& p > 0
&& code.as_bytes()[p - 1] == b'\r'
{
p - 1..at
} else {
p..at
}
} else {
at..if code[at..].starts_with("\r\n") {
at + 2
} else {
next(&code, at)
}
}
});
self.project.replace_text(id, range.clone(), "")?;
let view = self.project.view_mut(v)?;
view.cursor = range.start;
view.anchor = None;
return self.editor_scroll();
}
K::Left | K::Right => {
destination = if key.code == K::Left {
previous(&code, at)
} else {
next(&code, at)
};
if ctrl {
if key.code == K::Left {
while destination > 0
&& !code[destination..].chars().next().is_some_and(word)
{
destination = previous(&code, destination);
}
while destination > 0
&& code[..destination].chars().next_back().is_some_and(word)
{
destination = previous(&code, destination);
}
} else {
destination = at;
while destination < code.len()
&& code[destination..].chars().next().is_some_and(word)
{
destination = next(&code, destination);
}
while destination < code.len()
&& !code[destination..].chars().next().is_some_and(word)
{
destination = next(&code, destination);
}
}
}
}
K::Home => destination = if ctrl { 0 } else { line_start(&code, at) },
K::End => {
destination = if ctrl {
code.len()
} else {
line_end(&code, at)
};
if code[..destination].ends_with('\r') {
destination -= 1;
}
}
K::Up | K::Down | K::PageUp | K::PageDown => {
let starts: Vec<_> = std::iter::once(0)
.chain(code.match_indices('\n').map(|(i, _)| i + 1))
.collect();
let row = starts.partition_point(|i| *i <= at) - 1;
let col = column(&code[starts[row]..at], self.options.tab_width);
let step = if matches!(key.code, K::PageUp | K::PageDown) {
self.rect(self.active_window().unwrap())
.height
.saturating_sub(3)
.max(1) as usize
} else {
1
};
let row = if matches!(key.code, K::Up | K::PageUp) {
row.saturating_sub(step)
} else {
(row + step).min(starts.len() - 1)
};
let start = starts[row];
let end = line_end(&code, start);
destination = start
+ offset(
code[start..end].trim_end_matches('\r'),
col,
self.options.tab_width,
);
}
K::Esc => {
self.project.view_mut(v)?.anchor = None;
return Ok(());
}
_ => return Ok(()),
}
}
if !shift && !ctrl {
if let Some(range) = view.selection() {
if key.code == K::Left {
destination = range.start;
}
if key.code == K::Right {
destination = range.end;
}
}
}
let view = self.project.view_mut(v)?;
if shift {
view.anchor.get_or_insert(at);
} else {
view.anchor = None;
}
view.cursor = destination;
self.editor_scroll()
}
pub(crate) fn editor_command(&mut self, command: Command) -> Result<()> {
use Command::*;
let v = self.editor_view()?;
let view = self.project.view(v)?.clone();
let id = view.document();
let code = self.project.document(id)?.code().to_owned();
match command {
Copy | Cut => {
if let Some(range) = view.selection() {
self.editor.clipboard = code[range].into();
if command == Cut {
self.editor_insert("")?;
}
}
}
Paste => self.editor_insert(&self.editor.clipboard.clone())?,
Clear => self.editor_key(KeyEvent::new(K::Delete, M::NONE))?,
Find | Replace => {
let s = &self.editor.search;
self.open_dialog(
if command == Find { "Find" } else { "Change" },
DialogKind::Search {
view: v,
selection: view.selection(),
},
vec![
Field::text("Suchtext", s.text.clone()),
Field::text("Ersetzung", s.replacement.clone()),
Field::toggle("Rückwärts", s.backwards),
Field::toggle("Groß-/Kleinschreibung", s.case_sensitive),
Field::toggle("Ganzes Wort", s.whole_word),
Field::toggle("Nur Auswahl", false),
Field::choice(
"Aktion",
vec![
"Suchen".into(),
"Einzeln ersetzen".into(),
"Alle ersetzen".into(),
],
if command == Find { 0 } else { 1 },
),
],
);
}
SelectedText => {
let range = view
.selection()
.ok_or_else(|| anyhow!("Kein Text ausgewählt"))?;
self.editor.search.text = code[range].into();
self.editor.search.document = Some(id);
self.editor.search.scope = None;
self.editor.search.wrap = false;
self.find_next()?;
}
FindNext => self.find_next()?,
NewSub | NewFunction => self.open_dialog(
if command == NewSub {
"New Sub"
} else {
"New Function"
},
DialogKind::Procedure(command == NewFunction),
vec![Field::text("Name", "")],
),
Procedures => {
let parsed = tb_frontend::parser::parse("EDITOR", &lexer::lex(&code).tokens);
let mut positions = vec![0];
let mut names = vec!["Modulrumpf".into()];
for p in parsed.module.procs {
names.push(tb_frontend::editing::signature(&p.sig));
positions.push(line_offset(&code, p.pos.line));
}
self.open_dialog(
"Prozeduren",
DialogKind::Procedures(positions),
vec![Field::choice("Prozedur", names, 0)],
);
}
PreviousCode => {
if let Some((id, at)) = self.editor.history.pop() {
self.show_document(id, false)?;
let v = self.editor_view()?;
let code = self.project.document(id)?.code();
let mut at = at.min(code.len());
while !code.is_char_boundary(at) {
at -= 1;
}
self.project.view_mut(v)?.cursor = at;
self.editor_scroll()?;
}
}
IncludedFile => {
let line = &code[line_start(&code, view.cursor)..line_end(&code, view.cursor)];
let path = lexer::lex(line)
.tokens
.into_iter()
.find_map(|t| {
if let TokenKind::MetaInclude(p) = t.kind {
Some(p)
} else {
None
}
})
.ok_or_else(|| anyhow!("Keine Include-Anweisung auf dieser Zeile"))?;
let doc = self.project.document(id)?;
let path = self
.project
.loader()?
.resolve(doc.source_path().parent().unwrap(), &path)
.map_err(|e| anyhow!(e))?;
self.editor.history.push((id, view.cursor));
let id = self.project.open_document(&path)?;
self.show_document(id, false)?;
}
IncludedLines => {
if self.editor.expansions.remove(&v).is_none() {
let sources = self.project.sources()?;
let path = self
.project
.document(id)?
.source_path()
.display()
.to_string();
let unit = sources
.units
.iter()
.find(|u| u.segments.iter().any(|s| s.file == path))
.ok_or_else(|| anyhow!("Dokument gehört zu keiner Übersetzungseinheit"))?;
let mut text = String::from("Included Lines · SCHREIBGESCHÜTZT\n");
for s in &unit.segments {
text.push_str(&format!("' {}:{}\n{}\n", s.file, s.first_line, s.text));
}
self.editor.expansions.insert(v, text);
self.project.view_mut(v)?.scroll_line = 0;
self.project.view_mut(v)?.scroll_column = 0;
} else {
self.editor_scroll()?;
}
}
Diagnostics => self.diagnostic_dialog(),
_ => unreachable!(),
}
Ok(())
}
pub(crate) fn editor_submit(&mut self, d: &mut Dialog) -> Result<bool> {
match d.kind.clone() {
DialogKind::Search { view, selection } => {
let id = self.project.view(view)?.document();
ensure!(!d.fields[0].string().is_empty(), "Suchtext fehlt");
ensure!(
!d.fields[5].flag() || selection.is_some(),
"Keine Auswahl vorhanden"
);
self.editor.search = Search {
text: d.fields[0].string(),
replacement: d.fields[1].string(),
backwards: d.fields[2].flag(),
case_sensitive: d.fields[3].flag(),
whole_word: d.fields[4].flag(),
scope: if d.fields[5].flag() { selection } else { None },
document: Some(id),
wrap: false,
};
match d.fields[6].index() {
0 => self.find_next()?,
1 => self.replace_matches(false)?,
_ => self.replace_matches(true)?,
}
}
DialogKind::Procedure(function) => {
let v = self.editor_view()?;
let view = self.project.view(v)?.clone();
let id = view.document();
let code = self.project.document(id)?.code();
let input = self.project.sources()?;
let name = d.fields[0].string();
for unit in &input.units {
let (module, _) = unit.parse(0, &mut Vec::new());
ensure!(
!module
.procs
.iter()
.any(|p| p.sig.name.eq_ignore_ascii_case(&name)),
"Prozedurname bereits im Projekt vorhanden"
);
}
let path = self
.project
.document(id)?
.source_path()
.display()
.to_string();
let unit = input
.units
.iter()
.find(|u| u.segments.iter().any(|s| s.file == path))
.ok_or_else(|| anyhow!("Keine Übersetzungseinheit"))?;
let first = match self.project.document(id)?.content() {
tb_vm::project_io::Content::Form(f) => {
tb_ui::frm::read_text(&path, &tb_ui::frm::write_text(f))?.code_line()
}
_ => 1,
};
let line =
first + code[..view.cursor].bytes().filter(|b| *b == b'\n').count() as u32;
let col = view.cursor - line_start(code, view.cursor);
let mut expanded = String::new();
let mut at = None;
for segment in &unit.segments {
for (i, text) in segment.text.split_inclusive('\n').enumerate() {
if segment.file == path && segment.first_line + i as u32 == line {
at = Some(
expanded.len() + col.min(text.trim_end_matches(['\n', '\r']).len()),
);
}
expanded.push_str(text);
if !text.ends_with('\n') {
expanded.push('\n');
}
}
}
let addition = tb_frontend::editing::new_procedure(
&expanded,
at.unwrap_or(expanded.len()),
&name,
function,
)
.map_err(|e| anyhow!(e))?;
let end = code.len();
self.project.replace_text(id, end..end, &addition)?;
self.project.view_mut(v)?.cursor = end + 1;
self.editor_scroll()?;
}
DialogKind::Procedures(positions) => {
let v = self.editor_view()?;
let view = self.project.view(v)?;
self.editor.history.push((view.document(), view.cursor));
self.project.view_mut(v)?.cursor = positions[d.fields[0].index()];
self.editor_scroll()?;
}
DialogKind::Diagnostics => {
ensure!(
self.revision_current(),
"Quellen geändert; Diagnosen erneut öffnen"
);
let diagnostic = self
.editor
.diagnostics
.get(d.fields[0].index())
.cloned()
.ok_or_else(|| anyhow!("Keine Diagnose"))?;
self.goto_diagnostic(&diagnostic)?;
}
_ => unreachable!(),
}
Ok(false)
}
fn find_next(&mut self) -> Result<()> {
let v = self.editor_view()?;
let view = self.project.view(v)?.clone();
let id = view.document();
let code = self.project.document(id)?.code();
let s = &mut self.editor.search;
ensure!(!s.text.is_empty(), "Noch keine Suche; Find verwenden");
if s.document != Some(id) {
s.document = Some(id);
s.scope = None;
s.wrap = false;
}
let scope = s
.scope
.clone()
.filter(|r| code.get(r.clone()).is_some())
.unwrap_or(0..code.len());
let ranges = matches(code, s, scope);
let found = if s.backwards {
let at = view.selection().map_or(view.cursor, |r| r.start);
ranges.iter().rev().find(|r| s.wrap || r.end <= at)
} else {
let at = view.selection().map_or(view.cursor, |r| r.end);
ranges.iter().find(|r| s.wrap || r.start >= at)
};
if let Some(r) = found {
let view = self.project.view_mut(v)?;
view.anchor = Some(r.start);
view.cursor = r.end;
s.wrap = false;
self.message = "Treffer".into();
self.editor_scroll()?;
} else {
s.wrap = true;
self.message = "Suchbereich beendet · F3 bietet Rücksprung zum anderen Ende".into();
}
Ok(())
}
fn replace_matches(&mut self, all: bool) -> Result<()> {
let v = self.editor_view()?;
let view = self.project.view(v)?.clone();
let id = view.document();
ensure!(
!self.editor.expansions.contains_key(&v),
"Included Lines ist schreibgeschützt"
);
let code = self.project.document(id)?.code().to_owned();
let s = &self.editor.search;
let scope = s.scope.clone().unwrap_or(0..code.len());
ensure!(
code.get(scope.clone()).is_some(),
"Suchbereich nicht mehr gültig"
);
let ranges = matches(&code, s, scope.clone());
let ranges = if all {
ranges
} else {
let found = if s.backwards {
ranges.into_iter().rev().find(|r| r.start <= view.cursor)
} else {
ranges
.into_iter()
.find(|r| r.end > view.cursor || view.selection().as_ref() == Some(r))
};
found.into_iter().collect()
};
if ranges.is_empty() {
self.message = "Keine Treffer".into();
return Ok(());
}
let mut replacement = code[scope.clone()].to_owned();
for r in ranges.iter().rev() {
replacement.replace_range(r.start - scope.start..r.end - scope.start, &s.replacement);
}
let first = &ranges[0];
let cursor = first.start + s.replacement.len();
self.project.replace_text(id, scope.clone(), &replacement)?;
if self.editor.search.scope.is_some() {
self.editor.search.scope = Some(scope.start..scope.start + replacement.len());
}
let view = self.project.view_mut(v)?;
view.cursor = cursor;
view.anchor = None;
self.message = format!("{} Treffer ersetzt", ranges.len());
self.editor_scroll()
}
pub(crate) fn diagnostic_dialog(&mut self) {
if !self.revision_current() {
if let Err(e) = self.compile_current() {
self.message = e.to_string();
}
}
if self.editor.diagnostics.is_empty() {
self.message = "Keine Diagnosen".into();
return;
}
self.open_dialog(
"Diagnosen",
DialogKind::Diagnostics,
vec![Field::choice(
"Datei:Zeile:Spalte",
self.editor
.diagnostics
.iter()
.map(ToString::to_string)
.collect(),
0,
)],
);
}
pub fn goto_diagnostic(&mut self, d: &Diagnostic) -> Result<()> {
let path = PathBuf::from(
d.file
.as_ref()
.ok_or_else(|| anyhow!("Diagnose ohne Quelldatei"))?,
);
let id = self.project.open_document(&path)?;
self.show_document(id, false)?;
let v = self.editor_view()?;
let doc = self.project.document(id)?;
let first = if let tb_vm::project_io::Content::Form(f) = doc.content() {
tb_ui::frm::read_text(&path.display().to_string(), &tb_ui::frm::write_text(f))?
.code_line()
.saturating_sub(1)
} else {
0
};
let code = doc.code();
let start = line_offset(code, d.pos.line.saturating_sub(first));
let at = start
+ code[start..line_end(code, start)]
.char_indices()
.nth(d.pos.column.saturating_sub(1) as usize)
.map_or(line_end(code, start) - start, |(i, _)| i);
self.editor.expansions.remove(&v);
self.project.view_mut(v)?.cursor = at;
self.editor_scroll()
}
/// Immer aktuelle Quellen einschließlich externer Includes prüfen; alte Erfolge werden nie herausgegeben.
pub fn compile_current(&mut self) -> Result<&CompiledModule> {
self.editor.compiled = None;
self.editor.revision = None;
self.editor.diagnostics.clear();
let sources = self.project.sources()?;
let mut catalog = tb_frontend::forms::FormCatalog::default();
for f in &sources.forms {
catalog.append(&f.catalog());
}
self.editor.revision = Some((
ProjectStamp::capture(&self.project),
sources.units.clone(),
sources.forms.iter().map(tb_ui::frm::write_text).collect(),
));
match self
.editor
.compiler
.compile("IDE", &sources.units, &catalog, &sources.forms)
{
Ok(code) => {
self.editor.compiled = Some(code);
Ok(self.editor.compiled.as_ref().unwrap())
}
Err(errors) => {
let text = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
self.editor.diagnostics = errors;
Err(anyhow!(text))
}
}
}
fn revision_current(&self) -> bool {
let Some((stamp, units, forms)) = &self.editor.revision else {
return false;
};
if stamp != &ProjectStamp::capture(&self.project) {
return false;
}
let Ok(current) = self.project.sources() else {
return false;
};
units == &current.units
&& forms
== &current
.forms
.iter()
.map(tb_ui::frm::write_text)
.collect::<Vec<_>>()
}
pub fn current_compilation(&self) -> Option<&CompiledModule> {
self.revision_current()
.then_some(self.editor.compiled.as_ref())
.flatten()
}
pub fn current_diagnostics(&self) -> Option<&[Diagnostic]> {
self.revision_current()
.then_some(self.editor.diagnostics.as_slice())
}
pub(crate) fn editor_position(&self) -> Option<(ViewId, DocumentId, usize)> {
let v = self.editor_view().ok()?;
let view = self.project.view(v).ok()?;
let code = self.project.document(view.document()).ok()?.code();
Some((v, view.document(), line_start(code, view.cursor)))
}
pub(crate) fn line_leave(&mut self, old: Option<(ViewId, DocumentId, usize)>) {
let Some((v, id, start)) = old else {
return;
};
if !self.options.syntax_checking
|| self.editor.expansions.contains_key(&v)
|| old == self.editor_position()
{
return;
}
let Ok(doc) = self.project.document(id) else {
return;
};
if doc.revision() == 0 || self.editor.checked.get(&id) == Some(&doc.revision()) {
return;
}
let code = doc.code().to_owned();
let revision = doc.revision();
let lexed = lexer::lex(&code);
let parsed = tb_frontend::parser::parse("EDITOR", &lexed.tokens);
if lexed.diagnostics.is_empty()
&& parsed.diagnostics.is_empty()
&& code.is_char_boundary(start)
&& start <= code.len()
{
let end = line_end(&code, start);
let edits = tb_frontend::editing::normalization_edits(&code[start..end])
.into_iter()
.map(|(r, t)| (start + r.start..start + r.end, t))
.collect::<Vec<_>>();
if let Err(e) = self.project.replace_ranges(id, &edits) {
self.message = e.to_string();
return;
}
}
self.editor.checked.insert(
id,
self.project.document(id).map_or(revision, |d| d.revision()),
);
let result = self.compile_current().map(|_| ());
if parsed.incomplete && lexed.diagnostics.is_empty() {
self.message = "Quelltext noch unvollständig · Eingabe fortsetzen".into();
} else if let Err(e) = result {
self.message = e.to_string();
if self.dialog.is_none() {
self.diagnostic_dialog();
}
} else {
self.message = format!(
"Geprüft · {} Module übersetzt, {} wiederverwendet",
self.editor.compiler.stats.compiled, self.editor.compiler.stats.reused
);
}
}
}
pub fn line_offset(code: &str, line: u32) -> usize {
if line <= 1 {
0
} else {
code.match_indices('\n')
.nth(line as usize - 2)
.map_or(code.len(), |(i, _)| i + 1)
}
}

View File

@@ -6,3 +6,5 @@ pub mod export;
pub mod options; pub mod options;
pub mod render; pub mod render;
pub mod terminal; pub mod terminal;
pub mod editor;

View File

@@ -153,17 +153,74 @@ impl App {
content_style, content_style,
); );
} else { } else {
let lines: Vec<_> = doc let expansion = self.editor.expansions.get(&view);
.code() let code = expansion.map(String::as_str).unwrap_or(doc.code());
.split('\n') let lines: Vec<_> = code.split('\n').collect();
.map(|line| expanded(line, self.options.tab_width))
.collect();
let count = lines.len(); let count = lines.len();
let longest = lines.iter().map(|s| s.width()).max().unwrap_or(0); let longest = lines
.iter()
.map(|s| expanded(s, self.options.tab_width).width())
.max()
.unwrap_or(0);
let selection = if expansion.is_none() {
v.selection()
} else {
None
};
let mut start = 0;
let text = lines let text = lines
.into_iter() .iter()
.skip(v.scroll_line) .enumerate()
.map(|s| Line::raw(s.chars().skip(v.scroll_column).collect::<String>())) .filter_map(|(row, line)| {
let base = start;
start += line.len() + 1;
if row < v.scroll_line {
return None;
}
let mut col = 0;
let mut spans = Vec::new();
for (i, c) in line.char_indices() {
if c == '\r' {
continue;
}
let width = if c == '\t' {
self.options.tab_width - col % self.options.tab_width
} else {
c.width().unwrap_or(0)
};
let end = col + width;
if end > v.scroll_column
&& col < v.scroll_column + inner.width as usize
{
let selected = selection
.as_ref()
.is_some_and(|r| r.contains(&(base + i)));
let text = if c == '\t'
|| col < v.scroll_column
|| end > v.scroll_column + inner.width as usize
{
" ".repeat(
end.min(v.scroll_column + inner.width as usize)
- col.max(v.scroll_column),
)
} else {
c.to_string()
};
spans.push(Span::styled(
text,
if selected {
content_style.add_modifier(Modifier::REVERSED)
} else {
content_style
},
));
} else if width == 0 && !spans.is_empty() {
spans.push(Span::raw(c.to_string()));
}
col = end;
}
Some(Line::from(spans))
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
f.render_widget(Paragraph::new(text).style(content_style), inner); f.render_widget(Paragraph::new(text).style(content_style), inner);
if count > inner.height as usize { if count > inner.height as usize {
@@ -186,7 +243,11 @@ impl App {
.viewport_content_length(inner.width as usize), .viewport_content_length(inner.width as usize),
); );
} }
if active && self.dialog.is_none() && self.menu.is_none() { if active
&& expansion.is_none()
&& self.dialog.is_none()
&& self.menu.is_none()
{
let before = &doc.code()[..v.cursor.min(doc.code().len())]; let before = &doc.code()[..v.cursor.min(doc.code().len())];
let row = before.bytes().filter(|c| *c == b'\n').count(); let row = before.bytes().filter(|c| *c == b'\n').count();
let col = expanded( let col = expanded(

View File

@@ -1,6 +1,6 @@
use crossterm::{ use crossterm::{
cursor::{Hide, Show}, cursor::{Hide, Show},
event::{DisableMouseCapture, EnableMouseCapture}, event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
execute, execute,
style::ResetColor, style::ResetColor,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
@@ -25,7 +25,13 @@ impl<W: Write> TerminalGuard<W> {
fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result<Self> { fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result<Self> {
let mut guard = Self { writer, raw }; let mut guard = Self { writer, raw };
(guard.raw)(true)?; (guard.raw)(true)?;
execute!(guard.writer, EnterAlternateScreen, EnableMouseCapture, Hide)?; execute!(
guard.writer,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste,
Hide
)?;
Ok(guard) Ok(guard)
} }
} }
@@ -37,6 +43,7 @@ impl<W: Write> Drop for TerminalGuard<W> {
ResetColor, ResetColor,
Show, Show,
DisableMouseCapture, DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen LeaveAlternateScreen
); );
} }

View File

@@ -139,7 +139,8 @@ fn start_snapshot_and_reference_menus_have_all_commands() {
for item in m.items.iter().filter(|i| i.command.is_some()) { for item in m.items.iter().filter(|i| i.command.is_some()) {
let name = item.text(); let name = item.text();
assert!( assert!(
reference.contains(name.trim_end_matches('…')), reference.contains(name.trim_end_matches('…'))
|| item.command == Some(Command::Diagnostics),
"Nicht in Referenz: {name}" "Nicht in Referenz: {name}"
); );
assert!(item.mnemonic().is_some()); assert!(item.mnemonic().is_some());
@@ -224,7 +225,8 @@ fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() {
let mut app = t.app(); let mut app = t.app();
key(&mut app, K::Char('c'), M::CONTROL); key(&mut app, K::Char('c'), M::CONTROL);
assert_eq!(app.last_command, Some(Command::Copy)); assert_eq!(app.last_command, Some(Command::Copy));
assert!(app.message.contains("03")); assert!(app.editor.clipboard.is_empty());
assert!(app.basic_events.is_empty());
plain(&mut app, K::F(11)); plain(&mut app, K::F(11));
assert!(app.menu.is_some()); assert!(app.menu.is_some());
plain(&mut app, K::Esc); plain(&mut app, K::Esc);
@@ -262,6 +264,7 @@ fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() {
fn windows_restore_geometry_and_resize_without_document_loss() { fn windows_restore_geometry_and_resize_without_document_loss() {
let t = Temp::new(); let t = Temp::new();
let mut app = t.app(); let mut app = t.app();
app.options.syntax_checking = false; // Dieser Test prüft Fenster mit absichtlich ungültigem Entwurf.
type_text(&mut app, "abc"); type_text(&mut app, "abc");
let doc = app.active_document().unwrap(); let doc = app.active_document().unwrap();
let first = app.active; let first = app.active;

View File

@@ -450,9 +450,10 @@ fn aliases_share_documents_and_form_code_offsets_match_saved_text() {
let id = p.new_form("F").unwrap(); let id = p.new_form("F").unwrap();
p.replace_text(id, 0..0, "SUB Form_Load\nERROR 6\nEND SUB\n") p.replace_text(id, 0..0, "SUB Form_Load\nERROR 6\nEND SUB\n")
.unwrap(); .unwrap();
let before = p.sources().unwrap();
let target = t.0.join("F.frm"); let target = t.0.join("F.frm");
p.save_file(id, Some(&Destination::new(&target))).unwrap(); p.save_file(id, Some(&Destination::new(&target))).unwrap();
// Der gemeinsame Speicherhook ergänzt DECLAREs; Speicherstand und Disk müssen dieselben physischen Zeilen liefern.
let before = p.sources().unwrap();
let disk = SourceLoader::default().load(&target).unwrap(); let disk = SourceLoader::default().load(&target).unwrap();
assert_eq!( assert_eq!(
before.units[1] before.units[1]

View File

@@ -0,0 +1,449 @@
use crossterm::event::{Event, KeyCode as K, KeyEvent, KeyModifiers as M};
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, DialogKind, WindowKind},
commands::Command,
documents::{Destination, Project},
editor::{column, offset},
};
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let p = std::env::temp_dir().join(format!(
"tb-editor-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
fn app(&self) -> App {
let mut a = App::new(&self.0, self.0.join("options.ini"), (100, 30)).unwrap();
a.options.syntax_checking = false;
a
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn key(a: &mut App, k: K, m: M) {
a.handle(Event::Key(KeyEvent::new(k, m)));
}
fn plain(a: &mut App, k: K) {
key(a, k, M::NONE);
}
fn view(a: &App) -> tb_ide::documents::ViewId {
let WindowKind::Code(v) = a.active_window().unwrap().kind else {
panic!()
};
v
}
fn code(a: &App) -> &str {
a.project
.document(a.active_document().unwrap())
.unwrap()
.code()
}
fn field(a: &mut App, n: usize, text: &str) {
while a.dialog.as_ref().unwrap().focus != n {
plain(a, K::Tab);
}
key(a, K::Char('a'), M::CONTROL);
plain(a, K::Delete);
for c in text.chars() {
plain(a, K::Char(c));
}
}
fn toggle(a: &mut App, n: usize) {
while a.dialog.as_ref().unwrap().focus != n {
plain(a, K::Tab);
}
plain(a, K::Char(' '));
}
#[test]
fn cua_unicode_overwrite_wordstar_and_shared_undo() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("α\t\nzweite Zeile\n".into()));
assert_eq!(code(&a), "α\t\nzweite Zeile\n");
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Char('k'), M::CONTROL);
plain(&mut a, K::Char('0'));
plain(&mut a, K::Right);
plain(&mut a, K::Right);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 3);
plain(&mut a, K::Down);
assert_eq!(
a.project.view(view(&a)).unwrap().cursor,
"α\t\n".len() + 8
);
key(&mut a, K::Char('q'), M::CONTROL);
plain(&mut a, K::Char('0'));
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 0);
let original = code(&a).to_owned();
key(&mut a, K::Char('q'), M::CONTROL);
plain(&mut a, K::Esc);
plain(&mut a, K::F(3));
assert_eq!(code(&a), original);
key(&mut a, K::Char('a'), M::CONTROL);
key(&mut a, K::Char('x'), M::CONTROL);
assert_eq!(code(&a), "");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), original);
assert_eq!(
a.project.view(view(&a)).unwrap().selection(),
Some(0..original.len())
);
key(&mut a, K::Insert, M::CONTROL);
a.execute(Command::NewWindow);
key(&mut a, K::End, M::CONTROL);
key(&mut a, K::Insert, M::SHIFT);
assert_eq!(code(&a), original.repeat(2));
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), original);
key(&mut a, K::Home, M::CONTROL);
plain(&mut a, K::Insert);
plain(&mut a, K::Char('é'));
assert!(code(&a).starts_with("é\t"));
key(&mut a, K::Char('y'), M::CONTROL);
assert_eq!(code(&a), "zweite Zeile\n");
key(&mut a, K::Char('z'), M::CONTROL);
assert!(code(&a).starts_with("é\t"));
assert_eq!(column("α\t", 8), 10);
assert_eq!(offset("α\t", 8, 8), 3);
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Right, M::SHIFT);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
terminal.draw(|f| a.render(f)).unwrap();
assert!(terminal
.backend()
.buffer()
.content
.iter()
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)));
}
#[test]
fn search_scope_direction_wrap_and_shortening_replace_all() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("one One stone one\n".into()));
key(&mut a, K::Home, M::CONTROL);
a.execute(Command::Replace);
field(&mut a, 0, "one");
field(&mut a, 1, "");
toggle(&mut a, 4);
while a.dialog.as_ref().unwrap().focus != 6 {
plain(&mut a, K::Tab);
}
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert_eq!(code(&a), " stone \n");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), "one One stone one\n");
a.execute(Command::Find);
field(&mut a, 0, "one");
toggle(&mut a, 3);
plain(&mut a, K::Enter);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
plain(&mut a, K::F(3));
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(14..17));
plain(&mut a, K::F(3));
assert!(a.message.contains("Rücksprung"));
plain(&mut a, K::F(3));
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
key(&mut a, K::Char('\\'), M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(14..17));
a.execute(Command::Find);
toggle(&mut a, 2);
plain(&mut a, K::Enter);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
a.execute(Command::Replace);
field(&mut a, 1, "x");
toggle(&mut a, 5);
while a.dialog.as_ref().unwrap().focus != 6 {
plain(&mut a, K::Tab);
}
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert_eq!(code(&a), "x One stone one\n");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), "one One stone one\n");
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
}
#[test]
fn line_leave_incomplete_invalid_disabled_and_current_revisions() {
let t = Temp::new();
let mut a = t.app();
a.options.syntax_checking = true;
for c in "print \"MiX\"".chars() {
plain(&mut a, K::Char(c));
}
plain(&mut a, K::Enter);
assert_eq!(code(&a), "PRINT \"MiX\"\n");
assert!(a.current_compilation().is_some());
a.handle(Event::Paste("SUB Fresh\n".into()));
assert!(a.dialog.is_none());
assert!(a.message.contains("unvollständig"), "{}", a.message);
assert!(a.current_compilation().is_none());
a.handle(Event::Paste("END SUB\n".into()));
assert!(a.current_compilation().is_some(), "{}", a.message);
a.handle(Event::Paste("PRINT )\n".into()));
assert!(matches!(
a.dialog.as_ref().map(|d| &d.kind),
Some(DialogKind::Diagnostics)
));
assert!(a.current_compilation().is_none());
plain(&mut a, K::Esc);
a.execute(Command::Undo);
assert!(a.compile_current().is_ok());
a.options.syntax_checking = false;
a.handle(Event::Paste("print )\n".into()));
assert!(a.dialog.is_none());
assert!(code(&a).ends_with("print )\n"));
assert!(a.current_compilation().is_none());
assert!(a.compile_current().is_err());
}
#[test]
fn procedures_and_shared_save_hook_preserve_drafts_and_frm_code() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("DEFINT A-Z\n".into()));
a.execute(Command::NewSub);
field(&mut a, 0, "Fresh");
plain(&mut a, K::Enter);
assert!(code(&a).contains("SUB Fresh"));
assert!(a.compile_current().is_ok());
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::F(2), M::SHIFT);
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert!(a.project.view(view(&a)).unwrap().cursor > 0);
key(&mut a, K::F(2), M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 0);
let id = a.active_document().unwrap();
let v = view(&a);
let cursor = code(&a).find("SUB Fresh").unwrap() + 4;
a.project.view_mut(v).unwrap().cursor = cursor;
a.project.view_mut(v).unwrap().anchor = Some(cursor + 5);
a.project
.save_file(id, Some(&Destination::new(t.0.join("main.bas"))))
.unwrap();
let saved = code(&a).to_owned();
assert!(saved.contains("DECLARE SUB FRESH"));
assert_eq!(
a.project.view(v).unwrap().cursor,
saved.find("SUB Fresh").unwrap() + 4
);
assert_eq!(
a.project.view(v).unwrap().selection().map(|r| &saved[r]),
Some("Fresh")
);
let revision = a.project.document(id).unwrap().revision();
a.project.save_file(id, None).unwrap();
assert_eq!(a.project.document(id).unwrap().revision(), revision);
assert_eq!(code(&a), saved);
let end = code(&a).len();
a.project.replace_text(id, end..end, "SUB Draft\n").unwrap();
let draft = code(&a).to_owned();
a.project.save_file(id, None).unwrap();
assert_eq!(fs::read_to_string(t.0.join("main.bas")).unwrap(), draft);
assert!(a.project.save_notices[0].contains("übersprungen"));
// Separater gültiger Projektstand für die Forms-Pflege.
a.project.replace_text(id, 0..draft.len(), &saved).unwrap();
let form = a.project.new_form("MainForm").unwrap();
a.project
.replace_text(form, 0..0, "SUB Button_Click\nEND SUB\n")
.unwrap();
a.project
.save_file(form, Some(&Destination::new(t.0.join("MainForm.frm"))))
.unwrap();
let text = fs::read_to_string(t.0.join("MainForm.frm")).unwrap();
assert!(text.contains("DECLARE SUB BUTTON_CLICK"), "{text}");
let parsed = tb_ui::frm::read_text("MainForm.frm", &text).unwrap();
assert_eq!(parsed.code, a.project.document(form).unwrap().code());
}
#[test]
fn includes_and_diagnostic_selection_use_original_physical_files() {
let t = Temp::new();
fs::write(t.0.join("one.bi"), "x$=1\n").unwrap();
fs::write(t.0.join("two.bi"), "y%=\"bad\"\n").unwrap();
fs::write(
t.0.join("main.bas"),
"'$INCLUDE: 'one.bi'\n'$INCLUDE: 'two.bi'\n",
)
.unwrap();
let mut a = t.app();
a.load_initial_project(t.0.join("main.bas")).unwrap();
assert!(a.compile_current().is_err());
let errors = a.editor.diagnostics.clone();
assert_eq!(errors.len(), 2);
for (i, d) in errors.iter().enumerate() {
assert_eq!(d.pos.line, 1);
a.goto_diagnostic(d).unwrap();
assert_eq!(
a.project
.document(a.active_document().unwrap())
.unwrap()
.source_path(),
t.0.join(if i == 0 { "one.bi" } else { "two.bi" })
);
}
a.load_initial_project(t.0.join("main.bas")).unwrap();
a.execute(Command::IncludedLines);
let original = code(&a).to_owned();
a.handle(Event::Paste("corruption".into()));
assert_eq!(code(&a), original);
assert!(a.editor.expansions.contains_key(&view(&a)));
a.execute(Command::IncludedLines);
a.execute(Command::IncludedFile);
assert!(a
.project
.document(a.active_document().unwrap())
.unwrap()
.source_path()
.ends_with("one.bi"));
let id = a.active_document().unwrap();
let len = code(&a).len();
a.project.replace_text(id, 0..len, "x$=\"ok\"\n").unwrap();
fs::write(t.0.join("two.bi"), "y%=2\n").unwrap(); // Open overlay remains authoritative.
let other = a.project.open_document(&t.0.join("two.bi")).unwrap();
let len = a.project.document(other).unwrap().code().len();
a.project.replace_text(other, 0..len, "y%=2\n").unwrap();
assert!(a.compile_current().is_ok());
let mut p = Project::open(&t.0.join("main.bas"), vec![]).unwrap();
let id = p.open_document(&t.0.join("one.bi")).unwrap();
assert_eq!(p.document(id).unwrap().code(), "x$=1\n");
}
#[test]
fn crlf_navigation_word_boundaries_readonly_and_normalization_keep_views() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("word next\r\n\tX\r\n".into()));
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Right, M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 5);
plain(&mut a, K::End);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 9);
plain(&mut a, K::Right);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 11);
plain(&mut a, K::Left);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 9);
plain(&mut a, K::Delete);
assert_eq!(code(&a), "word next界\tX\r\n");
a.execute(Command::Undo);
let id = a.active_document().unwrap();
let v = view(&a);
let second = a.project.open_view(id).unwrap();
a.project.view_mut(second).unwrap().cursor = 5;
a.project
.replace_text(id, 0..code(&a).len(), "print 1\n")
.unwrap();
a.project.view_mut(second).unwrap().cursor = 8;
a.project.view_mut(v).unwrap().cursor = 8;
a.options.syntax_checking = true;
plain(&mut a, K::Down);
assert_eq!(code(&a), "PRINT 1\n");
assert_eq!(a.project.view(second).unwrap().cursor, 6);
a.execute(Command::Undo);
assert_eq!(code(&a), "print 1\n");
assert_eq!(a.project.view(second).unwrap().cursor, 8);
a.options.syntax_checking = false;
a.execute(Command::IncludedLines);
let before = code(&a).to_owned();
for command in [
Command::Cut,
Command::Paste,
Command::Clear,
Command::Undo,
Command::NewSub,
Command::LoadText,
Command::Replace,
] {
a.execute(command);
assert!(a.dialog.is_none());
assert_eq!(code(&a), before);
assert!(a.message.contains("schreibgeschützt"));
}
let scroll = a.project.view(view(&a)).unwrap().scroll_line;
plain(&mut a, K::PageDown);
assert!(a.project.view(view(&a)).unwrap().scroll_line > scroll);
}
#[test]
fn external_include_revision_and_project_context_declarations() {
let t = Temp::new();
fs::write(
t.0.join("defs.bi"),
"DEFINT A-Z\nTYPE Pair\nX AS INTEGER\nEND TYPE\nCONST N=1\n",
)
.unwrap();
fs::write(
t.0.join("main.bas"),
"'$INCLUDE: 'defs.bi'\nSUB Work(p AS Pair, n)\np.x=n+N\nEND SUB\n",
)
.unwrap();
let mut a = t.app();
a.load_initial_project(t.0.join("main.bas")).unwrap();
assert!(a.compile_current().is_ok());
assert!(a.current_compilation().is_some());
fs::write(
t.0.join("defs.bi"),
"DEFINT A-Z\nTYPE Pair\nX AS INTEGER\nEND TYPE\nCONST N=2\n",
)
.unwrap();
// Geöffnete Include-Dokumente sind maßgeblich; externe Änderungen überschreiben keinen Puffer.
assert!(a.current_compilation().is_some());
let include = a.project.find_document(&t.0.join("defs.bi")).unwrap();
let len = a.project.document(include).unwrap().code().len();
a.project
.replace_text(
include,
0..len,
&fs::read_to_string(t.0.join("defs.bi")).unwrap(),
)
.unwrap();
assert!(a.current_compilation().is_none());
assert!(a.current_diagnostics().is_none());
assert!(a.compile_current().is_ok());
let id = a.active_document().unwrap();
a.project.save_file(id, None).unwrap();
assert!(
code(&a).contains("DECLARE SUB WORK (P AS PAIR, N%)"),
"{} {:?}",
code(&a),
a.project.save_notices
);
assert!(a.project.save_notices.is_empty());
assert!(a.compile_current().is_ok());
a.execute(Command::NewFunction);
field(&mut a, 0, "Fresh");
plain(&mut a, K::Enter);
assert!(code(&a).contains("FUNCTION Fresh%"), "{}", code(&a));
assert!(a.compile_current().is_ok());
let other = a.project.new_module("Other").unwrap();
a.project
.replace_text(other, 0..0, "SUB Taken\nEND SUB\n")
.unwrap();
a.execute(Command::NewSub);
field(&mut a, 0, "Taken");
plain(&mut a, K::Enter);
assert!(a.dialog.as_ref().unwrap().error.contains("bereits"));
plain(&mut a, K::Esc);
// Ein erst nach dem Öffnen eingefügtes Include wird bis zum Öffnen direkt vom Loader gelesen.
fs::write(t.0.join("late.bi"), "CONST LATE=1\n").unwrap();
a.project
.replace_text(id, 0..0, "'$INCLUDE: 'late.bi'\n")
.unwrap();
assert!(a.compile_current().is_ok());
fs::remove_file(t.0.join("late.bi")).unwrap();
assert!(a.current_compilation().is_none());
assert!(a.compile_current().is_err());
assert!(a.current_diagnostics().is_none());
}

View File

@@ -44,7 +44,59 @@ fn compile_all(sources: &[(String, String)]) -> usize {
project.procs.iter().map(|p| p.code.len()).sum() project.procs.iter().map(|p| p.code.len()).sum()
} }
fn incremental(sources: &[(String, String)], budget: f64) {
let mut units: Vec<_> = sources
.iter()
.map(|(n, s)| tb_frontend::source::SourceUnit::new(n, &format!("{n}.bas"), s))
.collect();
let mut compiler = tb_vm::project::ProjectCompiler::default();
compiler
.compile("BENCH", &units, &Default::default(), &[])
.unwrap();
let mut times = Vec::new();
for i in 0..7 {
// Private Rumpfänderung mit unverändertem öffentlichen Vertrag.
units[0].segments[0].text = sources[0]
.1
.replace("a% * 2 + b#", &format!("a% * {} + b#", 3 + i));
let start = Instant::now();
let code = compiler
.compile("BENCH", &units, &Default::default(), &[])
.unwrap();
std::hint::black_box(&code);
times.push(start.elapsed().as_secs_f64());
assert_eq!(compiler.stats.compiled, 1);
assert_eq!(compiler.stats.reused, units.len() - 1);
}
times.sort_by(f64::total_cmp);
let median = times[times.len() / 2];
println!(" Cacheänderung: {} Module, Median {:.2} ms (7 Läufe; Invalidierung + Link), {} neu / {} wiederverwendet; Budget {:.0} ms {}",units.len(),median*1000.0,compiler.stats.compiled,compiler.stats.reused,budget*1000.0,if median<budget {"OK"}else{"VERFEHLT"});
assert!(median < budget, "Cachebudget verfehlt");
}
fn main() { fn main() {
println!(
"Hardware: {} / {} / {}",
std::env::consts::OS,
std::env::consts::ARCH,
std::process::Command::new(if cfg!(target_os = "macos") {
"sysctl"
} else {
"uname"
})
.args(if cfg!(target_os = "macos") {
vec!["-n", "machdep.cpu.brand_string"]
} else {
vec!["-m"]
})
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
.unwrap_or_default()
);
println!(
"Profil: Release / opt-level=3, eigener Harness; cargo bench -p tb-vm --bench compile"
);
// Einzelnes Modul: ~500 Zeilen (Budget < 50 ms). // Einzelnes Modul: ~500 Zeilen (Budget < 50 ms).
let single = generate_module(50, 0); let single = generate_module(50, 0);
let single_lines = single.lines().count(); let single_lines = single.lines().count();
@@ -90,4 +142,6 @@ fn main() {
println!(" Durchsatz: {:.0} Zeilen/s", lps); println!(" Durchsatz: {:.0} Zeilen/s", lps);
assert!(best_single < 0.050, "Einzelmodul-Budget verfehlt"); assert!(best_single < 0.050, "Einzelmodul-Budget verfehlt");
assert!(project_secs < 1.0, "Projekt-Budget verfehlt"); assert!(project_secs < 1.0, "Projekt-Budget verfehlt");
incremental(&[("EINZEL".into(), single)], 0.050);
incremental(&modules, 1.0);
} }

View File

@@ -510,7 +510,7 @@ pub struct DataItem {
} }
/// Übersetztes Modul — Inhalt des `.tbc`-Containers. /// Übersetztes Modul — Inhalt des `.tbc`-Containers.
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct CompiledModule { pub struct CompiledModule {
pub modules: Vec<(String, u8)>, pub modules: Vec<(String, u8)>,
pub sources: Vec<SourceFile>, pub sources: Vec<SourceFile>,

View File

@@ -22,12 +22,52 @@ fn diagnostic(message: impl Into<String>) -> Diagnostic {
} }
} }
/// Prozesslokaler Cache: unverknüpfte Produkte, niemals ein bereits gelinktes Programm.
#[derive(Default)]
pub struct ProjectCompiler {
parsed: Vec<Parsed>,
products: Vec<Product>,
pub stats: CompileStats,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CompileStats {
pub parsed: usize,
pub compiled: usize,
pub reused: usize,
}
struct Parsed {
unit: SourceUnit,
prefix: Vec<tb_frontend::source::SourceFile>,
sources: Vec<tb_frontend::source::SourceFile>,
module: Module,
diagnostics: Vec<Diagnostic>,
names: HashSet<String>,
}
struct Product {
module: Module,
catalog: FormCatalog,
code: CompiledModule,
commons: Vec<tb_frontend::hir::HCommon>,
}
pub fn compile_project( pub fn compile_project(
name: &str, name: &str,
units: &[SourceUnit], units: &[SourceUnit],
catalog: &FormCatalog, catalog: &FormCatalog,
forms: &[FormFile], forms: &[FormFile],
) -> Result<CompiledModule, Vec<Diagnostic>> { ) -> Result<CompiledModule, Vec<Diagnostic>> {
ProjectCompiler::default().compile(name, units, catalog, forms)
}
impl ProjectCompiler {
pub fn compile(
&mut self,
name: &str,
units: &[SourceUnit],
catalog: &FormCatalog,
forms: &[FormFile],
) -> Result<CompiledModule, Vec<Diagnostic>> {
self.stats = CompileStats::default();
if units.is_empty() || units.len() > u16::MAX as usize { if units.is_empty() || units.len() > u16::MAX as usize {
return Err(vec![diagnostic("Projekt ohne Module oder zu viele Module")]); return Err(vec![diagnostic("Projekt ohne Module oder zu viele Module")]);
} }
@@ -38,9 +78,43 @@ pub fn compile_project(
.iter() .iter()
.enumerate() .enumerate()
.map(|(id, unit)| { .map(|(id, unit)| {
let cached = self
.parsed
.get(id)
.filter(|p| p.unit == *unit && p.prefix == sources);
let (module, errors) = if let Some(p) = cached {
sources = p.sources.clone();
(p.module.clone(), p.diagnostics.clone())
} else {
let prefix = sources.clone();
let (module, errors) = unit.parse(id as u16, &mut sources); let (module, errors) = unit.parse(id as u16, &mut sources);
let entry = Parsed {
unit: unit.clone(),
prefix,
sources: sources.clone(),
module: module.clone(),
diagnostics: errors.clone(),
names: unit
.segments
.iter()
.flat_map(|s| tb_frontend::lexer::lex(&s.text).tokens)
.filter_map(|t| match t.kind {
tb_frontend::lexer::TokenKind::Ident { name, .. } => Some(name),
_ => None,
})
.collect(),
};
if id < self.parsed.len() {
self.parsed[id] = entry;
} else {
self.parsed.push(entry);
}
self.stats.parsed += 1;
(module, errors)
};
if !names.insert(unit.name.to_uppercase()) { if !names.insert(unit.name.to_uppercase()) {
let mut error = diagnostic(format!("Duplicate definition: module {}", unit.name)); let mut error =
diagnostic(format!("Duplicate definition: module {}", unit.name));
error.pos = module_pos(&module); error.pos = module_pos(&module);
error.file = unit.segments.first().map(|s| s.file.clone()); error.file = unit.segments.first().map(|s| s.file.clone());
diagnostics.push(error); diagnostics.push(error);
@@ -111,20 +185,47 @@ pub fn compile_project(
} }
let mut parts = Vec::new(); let mut parts = Vec::new();
let mut commons = Vec::new(); let mut commons = Vec::new();
for module in &parsed { let mut products = Vec::new();
for (index, module) in parsed.iter().enumerate() {
let mut key = module.clone();
import_declarations(&mut key, &parsed, &exports, Some(&self.parsed[index].names));
let mut module = module.clone(); let mut module = module.clone();
import_declarations(&mut module, &parsed, &exports); import_declarations(&mut module, &parsed, &exports, None);
if let Some(product) = self
.products
.iter()
.find(|p| p.module == key && p.catalog == catalog)
{
parts.push(product.code.clone());
commons.push(product.commons.clone());
self.stats.reused += 1;
continue;
}
self.stats.compiled += 1;
let (hir, errors) = tb_frontend::sema::lower_with_forms(&module, &catalog); let (hir, errors) = tb_frontend::sema::lower_with_forms(&module, &catalog);
diagnostics.extend(errors); diagnostics.extend(errors);
if let Some(hir) = hir { if let Some(hir) = hir {
parts.push(codegen::compile(&hir)); let code = codegen::compile(&hir);
commons.push(hir.commons); parts.push(code.clone());
commons.push(hir.commons.clone());
products.push(Product {
module: key,
catalog: catalog.clone(),
code,
commons: hir.commons,
});
} }
} }
locate_diagnostics(&mut diagnostics, &sources); locate_diagnostics(&mut diagnostics, &sources);
if !diagnostics.is_empty() { if !diagnostics.is_empty() {
return Err(diagnostics); return Err(diagnostics);
} }
self.parsed.truncate(units.len());
self.products.retain(|p| {
parsed.iter().any(|m| m.name == p.module.name)
&& !products.iter().any(|n| n.module.name == p.module.name)
});
self.products.extend(products);
let mut result = link(name, parts, &parsed, &commons).map_err(|error| { let mut result = link(name, parts, &parsed, &commons).map_err(|error| {
let mut errors = vec![error]; let mut errors = vec![error];
locate_diagnostics(&mut errors, &sources); locate_diagnostics(&mut errors, &sources);
@@ -151,6 +252,7 @@ pub fn compile_project(
.validate() .validate()
.map_err(|e| vec![diagnostic(e.to_string())])?; .map_err(|e| vec![diagnostic(e.to_string())])?;
Ok(result) Ok(result)
}
} }
/// Lokale Definition gewinnt; außerhalb ihres Moduls muss ein Name eindeutig sein. /// Lokale Definition gewinnt; außerhalb ihres Moduls muss ein Name eindeutig sein.
@@ -251,7 +353,12 @@ fn import_type(
alias alias
} }
fn import_declarations(module: &mut Module, all: &[Module], exports: &[Vec<Stmt>]) { fn import_declarations(
module: &mut Module,
all: &[Module],
exports: &[Vec<Stmt>],
signature: Option<&HashSet<String>>,
) {
let target = all.iter().position(|m| m.name == module.name).unwrap(); let target = all.iter().position(|m| m.name == module.name).unwrap();
let mut procedures: HashSet<_> = module.procs.iter().map(|p| p.sig.name.clone()).collect(); let mut procedures: HashSet<_> = module.procs.iter().map(|p| p.sig.name.clone()).collect();
let mut local_constants = HashSet::new(); let mut local_constants = HashSet::new();
@@ -318,6 +425,32 @@ fn import_declarations(module: &mut Module, all: &[Module], exports: &[Vec<Stmt>
_ => {} _ => {}
} }
} }
if let Some(names) = signature {
// Nicht verwendete importierte Konstanten erzeugen weder Slots noch Tabellen.
// TYPEs und Prozeduren dagegen gehören zu den unverketteten Tabellen.
imported
.retain(|s| !matches!(s, Stmt::ConstDecl {items,..} if !names.contains(&items[0].0)));
// Importverträge enthalten Namen/Typen/Werte, keine verschobenen Rumpfzeilen
// ihres Ursprungs. Eigene Quellorte bleiben Teil des Modulschlüssels.
for stmt in &mut imported {
match stmt {
Stmt::Declare { pos, .. } | Stmt::TypeDecl { pos, .. } => {
*pos = SourcePos::default()
}
Stmt::ConstDecl { items, pos } => {
*pos = SourcePos::default();
for (_, _, expr) in items {
match expr {
tb_frontend::ast::Expr::DoubleLit(_, p)
| tb_frontend::ast::Expr::StrLit(_, p) => *p = SourcePos::default(),
_ => {}
}
}
}
_ => {}
}
}
}
imported.append(&mut module.body); imported.append(&mut module.body);
let mut types = Vec::new(); let mut types = Vec::new();
imported.retain(|stmt| { imported.retain(|stmt| {

View File

@@ -0,0 +1,162 @@
use tb_frontend::{
forms::FormCatalog,
source::{SourceSegment, SourceUnit},
};
use tb_ui::frm::{self, FormFile};
use tb_vm::{compile_project, project::ProjectCompiler};
fn unit(name: &str, text: &str) -> SourceUnit {
SourceUnit::new(name, &format!("{name}.bas"), text)
}
fn compare(cache: &mut ProjectCompiler, units: &[SourceUnit], forms: &[FormFile]) -> bool {
let mut catalog = FormCatalog::default();
for f in forms {
catalog.append(&f.catalog());
}
let a = cache.compile("APP", units, &catalog, forms);
let b = compile_project("APP", units, &catalog, forms);
match (a, b) {
(Ok(a), Ok(b)) => {
assert_eq!(a.to_tbc(), b.to_tbc());
true
}
(Err(a), Err(b)) => {
assert_eq!(
a.iter().map(ToString::to_string).collect::<Vec<_>>(),
b.iter().map(ToString::to_string).collect::<Vec<_>>()
);
false
}
(a, b) => panic!("cache/full mismatch {a:?} / {b:?}"),
}
}
#[test]
fn cached_private_edits_parse_and_compile_only_changed_modules() {
let mut c = ProjectCompiler::default();
let mut units = vec![
unit("A", "CALL Work\nEND"),
unit("B", "SUB Work\nPRINT 1\nEND SUB"),
unit("C", "SUB Independent\nPRINT 3\nEND SUB"),
];
assert!(compare(&mut c, &units, &[]));
assert_eq!(c.stats.compiled, 3);
assert!(compare(&mut c, &units, &[]));
assert_eq!(
(c.stats.parsed, c.stats.compiled, c.stats.reused),
(0, 0, 3)
);
units[1].segments[0].text = units[1].segments[0].text.replace("PRINT 1", "PRINT 2");
assert!(compare(&mut c, &units, &[]));
assert_eq!(
(c.stats.parsed, c.stats.compiled, c.stats.reused),
(1, 1, 2)
);
units[1].segments[0].text = "SUB Work\nPRINT 2\nEND SUB\nSUB Second\nPRINT 4\nEND SUB".into();
assert!(compare(&mut c, &units, &[]));
units[1].segments[0].text = units[1].segments[0]
.text
.replace("PRINT 2", "PRINT 2\nPRINT 3");
assert!(compare(&mut c, &units, &[]));
assert_eq!(
(c.stats.compiled, c.stats.reused),
(1, 2),
"Verschobene fremde Prozedurzeilen ändern keinen Importvertrag"
);
units[1].segments[0].text = "SUB Work\nPRINT )\nEND SUB".into();
assert!(!compare(&mut c, &units, &[]));
units[1].segments[0].text = "SUB Work\nPRINT 5\nEND SUB".into();
assert!(compare(&mut c, &units, &[]));
}
#[test]
fn constants_types_common_removed_symbols_and_order_match_full_compiler() {
let mut c = ProjectCompiler::default();
let mut u = vec![
unit(
"A",
"CONST A=2\nTYPE T\nx AS INTEGER\nEND TYPE\nCOMMON SHARED N%\n",
),
unit("B", "CONST B=A+1\n"),
unit(
"C",
"DIM value AS T\nvalue.x=B\nCOMMON SHARED N%\nN%=B\nPRINT value.x\n",
),
];
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = u[0].segments[0].text.replace("A=2", "A=4");
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = u[0].segments[0].text.replace("INTEGER", "LONG");
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = u[0].segments[0].text.replace("N%", "N$");
compare(&mut c, &u, &[]);
u[0].segments[0].text = u[0].segments[0].text.replace("CONST A=4", "' removed");
assert!(!compare(&mut c, &u, &[]));
u.swap(0, 2);
assert!(!compare(&mut c, &u, &[]));
u.remove(0);
compare(&mut c, &u, &[]);
}
#[test]
fn includes_source_ids_data_and_forms_relink_without_patching_old_products() {
let mut c = ProjectCompiler::default();
let mut u = vec![
unit(
"A",
"DATA 3,\"Hi\"\nREAD a%, b$\nPRINT a%;b$\nCALL Work\nEND",
),
unit("B", "SUB Work\nPRINT 2\nEND SUB"),
];
u[0].segments.insert(
0,
SourceSegment {
file: "shared.bi".into(),
first_line: 7,
text: "CONST N=1\n".into(),
},
);
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = "CONST N=2\n".into();
assert!(compare(&mut c, &u, &[]));
u.swap(0, 1);
assert!(compare(&mut c, &u, &[]));
u[0].segments.insert(
0,
SourceSegment {
file: "extra.bi".into(),
first_line: 1,
text: "' extra source\n".into(),
},
);
assert!(compare(&mut c, &u, &[]));
for index in [None, Some(0), Some(1)] {
let property = index.map_or(String::new(), |index| format!(" Index = {index}\n"));
let text = format!(
"VERSION 1.00\nBEGIN Form Main\n BEGIN CommandButton Button\n{property} END\nEND\n"
);
let form = frm::read_text("Main.frm", &text).unwrap();
let units = [unit("Main", "'$FORM\n")];
assert!(compare(&mut c, &units, &[form]));
}
}
#[test]
fn unused_constants_do_not_invalidate_independent_units_and_declare_changes_do() {
let mut c = ProjectCompiler::default();
let mut u = vec![
unit("A", "CONST A=1\n"),
unit("B", "CONST B=A+1\n"),
unit("C", "PRINT B\n"),
unit("D", "PRINT 7\n"),
];
assert!(compare(&mut c, &u, &[]));
u[0].segments[0].text = "CONST A=3\n".into();
assert!(compare(&mut c, &u, &[]));
assert_eq!((c.stats.compiled, c.stats.reused), (3, 1));
let mut u = vec![
unit("A", "DECLARE SUB Work(n%)\nCALL Work(1)\nEND"),
unit("B", "SUB Work(n%)\nPRINT n%\nEND SUB"),
];
assert!(compare(&mut c, &u, &[]));
u[1].segments[0].text = "SUB Work(n$)\nPRINT n$\nEND SUB".into();
assert!(!compare(&mut c, &u, &[]));
u[0].segments[0].text = "DECLARE SUB Work(n$)\nCALL Work(\"ok\")\nEND".into();
assert!(compare(&mut c, &u, &[]));
}

View File

@@ -63,7 +63,7 @@ nicht. Die MAK-Datei wird zuletzt geschrieben. Details stehen in
Load Text fügt am Cursor als eine Undo-Aktion ein; Save Text exportiert im Load Text fügt am Cursor als eine Undo-Aktion ein; Save Text exportiert im
Rahmen den gesamten Code. Der Dokumentvertrag unterstützt bereits Rahmen den gesamten Code. Der Dokumentvertrag unterstützt bereits
Auswahlexport; die Auswahlbedienung gehört zum Editor-Change 03. Remove Auswahlexport des markierten Bereichs. Remove
File löscht keine Datei; beim Entfernen der Startdatei ist die Wahl eines File löscht keine Datei; beim Entfernen der Startdatei ist die Wahl eines
Ersatzes oder des bisherigen Standards ausdrücklich im Dialog enthalten. Ersatzes oder des bisherigen Standards ausdrücklich im Dialog enthalten.
@@ -82,8 +82,7 @@ Includes werden mit `|` getrennt; relative Optionspfade werden beim
Übernehmen relativ zum Projektverzeichnis absolut aufgelöst. Das Include Übernehmen relativ zum Projektverzeichnis absolut aufgelöst. Das Include
neben der Quelldatei hat Vorrang vor Suchverzeichnissen. Right Mouse neben der Quelldatei hat Vorrang vor Suchverzeichnissen. Right Mouse
schaltet Kontext-Hilfe per Rechtsklick (Default an); die Hilfefunktion schaltet Kontext-Hilfe per Rechtsklick (Default an); die Hilfefunktion
folgt in Change 07. Syntax Checking ist zunächst an; dessen fachliche folgt in Change 07. Syntax Checking ist zunächst an; seine Wirkung ist unten beschrieben.
Editorwirkung folgt in Change 03.
Erst Options → Save… / OK schreibt die versionierte Benutzerdatei: Erst Options → Save… / OK schreibt die versionierte Benutzerdatei:
@@ -128,31 +127,29 @@ Library-Symbole/Aufrufvertrag und der native Verbraucher-Nachweis stehen
explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt explizit in [PLAN.md, Phase 6](../PLAN.md). Ein umbenanntes TBC erfüllt
keines der beiden Exportziele. keines der beiden Exportziele.
## Befehlsabdeckung nach Change 02 ## Befehlsabdeckung nach Change 03
Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der
folgenden Changes sind sichtbar deaktiviert und zeigen einen folgenden Changes sind sichtbar deaktiviert und zeigen einen
Verfügbarkeitsgrund; ihre Kürzel gelangen nicht versehentlich an BASIC. Verfügbarkeitsgrund; ihre Kürzel gelangen nicht versehentlich an BASIC.
Ctrl+C im Editor ist Kopieren (Fachfunktion 03), bei laufendem Programm mit Ctrl+C im Editor ist Kopieren, bei laufendem Programm mit
Output-Fokus wird Ctrl+C an den Programmeingabepfad weitergereicht. Output-Fokus wird Ctrl+C an den Programmeingabepfad weitergereicht.
| Bereich | Rahmenaktion jetzt | Fachaktion im Folgechange | | Bereich | Rahmenaktion jetzt | Fachaktion im Folgechange |
| --- | --- | --- | | --- | --- | --- |
| File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Exit | Print/Shell: 04 | | File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Exit | Print/Shell: 04 |
| Edit | Undo über Dokumentaktionen; einfacher Texteinstieg | Cut/Copy/Paste/Clear, New Sub/Function: 03; Event Procedures: 05 | | Edit | Undo, Cut/Copy/Paste/Clear, New Sub/Function | Event Procedures: 05 |
| View | Code/Form-Auswahl, Designer Menu Bar/Value-Fokus | Included File/Lines: 03; Output Screen: 04; Grid Lines: 05; Next Statement: 06 | | View | Code/Form-Auswahl, Prozedurauswahl, Included File/Lines, Diagnostics, Designer Menu Bar/Value-Fokus | Output Screen: 04; Grid Lines: 05; Next Statement: 06 |
| Search | Vollständiges Menü und Kürzelzuordnung | Find, Selected Text, Repeat Last Find, Change: 03 | | Search | Find, Selected Text, Repeat Last Find, Change | — |
| Run | Set Start-up File und beide Make-Dialoge | Start/Restart/Continue/COMMAND$ und Startup-Ausführungswirkung: 04; native Erzeugung: Phase 6 | | Run | Set Start-up File und beide Make-Dialoge | Start/Restart/Continue/COMMAND$ und Startup-Ausführungswirkung: 04; native Erzeugung: Phase 6 |
| Debug | Vollständiges Menü und Kürzelzuordnung, einschließlich F7/F8/F9/F10 und History | Watches, Watchpoints, Trace, History, Breakpoints, Break on Errors, Set Next Statement: 06 | | Debug | Vollständiges Menü und Kürzelzuordnung, einschließlich F7/F8/F9/F10 und History | Watches, Watchpoints, Trace, History, Breakpoints, Break on Errors, Set Next Statement: 06 |
| Options | Display, Set Paths, Right Mouse, Save, Syntax Checking | Syntaxwirkung: 03; Kontext-Hilfe: 07 | | Options | Display, Set Paths, Right Mouse, Save, Syntax Checking einschließlich Zeilenprüfung | Kontext-Hilfe: 07 |
| Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Output-Inhalt: 04; Debug/Calls/Immediate-Inhalte: 06; Hilfeinhalt: 07 | | Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Output-Inhalt: 04; Debug/Calls/Immediate-Inhalte: 06; Hilfeinhalt: 07 |
| Designer Tools/Window | Tools-Menü, Formliste und gemeinsamer Fenster-/Modusrahmen | Controls/Toolbox, Color Palette, Menu Design Window: 05 | | Designer Tools/Window | Tools-Menü, Formliste und gemeinsamer Fenster-/Modusrahmen | Controls/Toolbox, Color Palette, Menu Design Window: 05 |
| Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 | | Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 |
Der einfache Texteinstieg ist noch kein abgeschlossener Editor: Navigation, Der Editor einschließlich inkrementeller Übersetzung ist umgesetzt. Die gesamte
Selektion, Zwischenablage, Syntaxprüfung und inkrementelles Übersetzen Phase-5-Abnahme erfolgt in Change 08.
werden in 03 vollständig umgesetzt. Die gesamte Phase-5-Abnahme erfolgt
erst in Change 08.
## Reproduzierbarer Testpfad ## Reproduzierbarer Testpfad
@@ -162,3 +159,88 @@ dieselbe `App::render`-Funktion wie der Terminalprozess mit
sie prüfen Fokus, Dokumente, Fenster, sichtbare Texte und Farben. Dieser sie prüfen Fokus, Dokumente, Fenster, sichtbare Texte und Farben. Dieser
Pfad steht den Folgechanges zur Verfügung. `TerminalGuard` prüft zusätzlich Pfad steht den Folgechanges zur Verfügung. `TerminalGuard` prüft zusätzlich
Normalende, Fehlerende und teilweise fehlgeschlagene Initialisierung. Normalende, Fehlerende und teilweise fehlgeschlagene Initialisierung.
## Editor, Suche und Quelltextpflege
Einfügen/Überschreiben wechselt mit Insert. Shift mit Pfeilen, Pos1/Ende und
Bild auf/ab markiert Text; Ctrl+A markiert alles. Ctrl+Links/Rechts navigiert
wortweise, Ctrl+Pos1/Ende zum Dateianfang/-ende. Ctrl+Y löscht die aktuelle
Zeile. Unicode-Codepunkte und CRLF-Zeilenumbrüche bleiben unbeschädigt;
Tabs und breite Zeichen verwenden Bildschirmspalten für die Vertikalbewegung.
Markierungen werden invertiert dargestellt, der Cursor bleibt im sichtbaren Bereich.
Ctrl+C/X/V/Z sowie Ctrl+Insert, Shift+Delete/Insert und Alt+Backspace bedienen
Kopieren, Ausschneiden, Einfügen und Undo. Die Zwischenablage ist IDE-intern und
funktioniert zwischen Codefenstern. Terminal-Paste wird mit Bracketed Paste als
eine Aktion verarbeitet. Paste, Ausschneiden, Ersetzen aller Treffer,
Normalisierung und Prozedurerzeugung sind jeweils eine Undo-Einheit. Undo
stellt auch Cursor, Markierungen und Lesezeichen der gemeinsamen Ansichten her.
WordStar: Ctrl+Q, dann S/D setzt den Zeilenanfang/-ende, R/C den
Dateianfang/-ende. Ctrl+K, dann 03 setzt vier Lesezeichen je Ansicht;
Ctrl+Q, dann 03 springt dorthin. Esc oder eine unbekannte Folgetaste bricht
ab, ohne Quelltext einzufügen. Mausaktionen beenden einen begonnenen Chord.
Find und Change bieten Suchtext, Ersetzung, Richtung, Groß-/Kleinschreibung,
ganze Wörter und den Bereich „Dokument“ oder „Nur Auswahl“. Das Aktionsfeld
wählt Suchen, Einzeln ersetzen oder Alle ersetzen. Eine leere Ersetzung löscht
Treffer. Ctrl+Backslash übernimmt die Auswahl als Suchtext, F3 sucht weiter.
Am Bereichsende zeigt die Statusmeldung den angebotenen Rücksprung an;
erneutes F3 bestätigt ihn. Alle Treffer einer Ersetzungsaktion werden gemeinsam
rückgängig gemacht.
Shift+F2 beziehungsweise View → Procedures öffnet Modulrumpf und Prozeduren;
Ctrl+F2 kehrt zur vorherigen Codeposition zurück. New Sub/Function prüft Namen
auch gegen andere Projektmodule und übernimmt die am Erzeugungsort wirksamen
DEFtype-Regeln, einschließlich Includes und des aktuellen Prozedurkontexts.
View → Included File öffnet das Original der Include-Anweisung auf der
Cursorzeile. Included Lines zeigt die expandierte Übersetzungseinheit mit
Dateinamen und physischen Zeilen als schreibgeschützte Ansicht. Pfeile und
Bild auf/ab scrollen; erneuter Aufruf kehrt zum Quelltext zurück. Save File
speichert immer das physische Dokument. Eine Expansion wird niemals zur BAS-Datei.
View → Diagnostics ist eine ergänzende IDE-Funktion: Die Auswahl springt zur
richtigen physischen Datei, Zeile und Spalte, auch bei Includes und FRM-Code.
Syntax Checking prüft beim Verlassen einer geänderten Zeile. Der reguläre
Parser unterscheidet fehlende Blockabschlüsse am Dateiende von Syntaxfehlern.
Unvollständige Blöcke bleiben ohne modalen Fehlerdialog bearbeitbar. Gültige
Zeilen erhalten normalisierte Keywords und zulässige Abstände anhand der
Lexerpositionen; Tokenvergleich schützt Strings, Kommentare, DATA und Bezeichner.
Abschalten unterbindet automatische Prüfung, Formatierung und Fehlerdialoge.
Die explizite Übersetzungs-API prüft unabhängig davon den vollständigen Stand.
Save File/As und Save Project pflegen DECLAREs im kommentierten Bereich
`' $IDE DECLARE BEGIN` bis `' $IDE DECLARE END` unmittelbar vor den Prozeduren.
Die Prüfung nutzt den regulären Projektcompiler mit Includes und Forms. Passende
Handdeklarationen bleiben erhalten. Bei Konflikten oder unfertigem/fehlerhaftem
Projektstand wird die gesamte Pflege übersprungen, der Entwurf unverändert
gespeichert und der Grund mit Quellort ausgegeben. Auch FRM-Code benutzt diesen
Speicherhook. Wiederholtes Speichern erzeugt keine neuen Deklarationen oder Revisionen.
## Übersetzung und Revisionsbindung
`App::compile_current` lädt den aktuellen Projektstand und verwendet
`tb_vm::project::ProjectCompiler`. `CompileStats` zählt geparste, übersetzte und
wiederverwendete Module. Der Cache liegt nur im Speicher. Sein Verlust führt
zur vollständigen Neuübersetzung, ohne das Dateiformat zu ändern.
Die Schlüssel vergleichen Include-Inhalte und Herkunft, Modulidentität,
aufgelöste Importverträge und den Forms-Katalog. Private Rumpfänderungen lassen
andere Modulprodukte bestehen. Konstante Importwerte werden transitiv neu
aufgelöst; unbenutzte importierte Konstanten invalidieren keine unabhängigen
Module. Importierte TYPEs und Prozeduren gehören zu den unverketteten Tabellen
und damit zum Modulvertrag. Eigene Quellorte bleiben Bestandteil des Schlüssels;
verschobene fremde Prozedurzeilen sind keine Signaturänderung. Änderungen an
Quelltabellen oder Modulreihenfolge werden konservativ neu geparst. Der bestehende
Linker baut sämtliche globalen Tabellen aus unverknüpften Produkten neu auf und
prüft COMMON-Kompatibilität. Forms-Anfangswerte werden beim Link frisch eingesetzt.
Erfolge und Diagnosen gehören zu Projektrevision, expandierten Quellen und Forms.
`current_compilation` und `current_diagnostics` geben nur einen dazu passenden
Stand zurück; ein Fehler macht keinen früheren Erfolg aktuell. Geöffnete
Include-Puffer bleiben maßgeblich gegenüber externen Dateiänderungen. Noch nicht
geöffnete Includes werden erneut vom Loader gelesen und verglichen. Start und
Fortsetzen der VM werden in Change 04 an diese Schnittstelle angebunden.
Release-Messungen und Aufruf stehen in [tbvm-design.md](tbvm-design.md).

View File

@@ -244,7 +244,9 @@ Nutzer — verworfen: erfordert Rust-Toolchain beim Anwender.
Definitionen. Echte lokale Duplikate werden diagnostiziert. SHARED Definitionen. Echte lokale Duplikate werden diagnostiziert. SHARED
bleibt modullokal. COMMON-Variablen desselben Blocks und derselben vollständigen bleibt modullokal. COMMON-Variablen desselben Blocks und derselben vollständigen
Variablenidentität teilen einen Slot; Elementtyp, Rang und bekannte Grenzen Variablenidentität teilen einen Slot; Elementtyp, Rang und bekannte Grenzen
müssen kompatibel sein. Ein inkrementeller IDE-Cache ist weiterhin geplant. müssen kompatibel sein. Der IDE-Cache speichert Parsing und unverknüpfte
Modulprodukte im vorhandenen Projektcompiler; der Vollcompile nutzt dieselben
Schritte mit einem leeren Cache. Details stehen in [ide-bedienung.md](ide-bedienung.md#übersetzung-und-revisionsbindung).
- Keine Optimierungspasses. Erlaubt sind nur Gratis-Optimierungen im - Keine Optimierungspasses. Erlaubt sind nur Gratis-Optimierungen im
Codegen (Konstantenfaltung im Ausdruck, Peephole beim Emit). Codegen (Konstantenfaltung im Ausdruck, Peephole beim Emit).
- Budget als Test verankern (Phase 2): Benchmark-Projekt (~50k Zeilen) - Budget als Test verankern (Phase 2): Benchmark-Projekt (~50k Zeilen)
@@ -628,3 +630,24 @@ die Entscheidungen sind Lückenfüller, keine Setzungen gegen das Vorbild.
vorhandenem Array prüft es Elementtyp und die ausgewerteten Grenzen, ohne vorhandenem Array prüft es Elementtyp und die ausgewerteten Grenzen, ohne
Werte oder Handle zu ersetzen (Konflikt: Fehler 13). Statisch erkennbare Werte oder Handle zu ersetzen (Konflikt: Fehler 13). Statisch erkennbare
COMMON-Typ-/Grenzenkonflikte werden bereits beim Linken diagnostiziert. COMMON-Typ-/Grenzenkonflikte werden bereits beim Linken diagnostiziert.
### Inkrementeller Compile (Phase 5, Change 03)
Messung vom 2026-09-06 auf macOS/aarch64, Apple M5 Max. Aufruf:
`cargo bench -p tb-vm --bench compile`; Release mit opt-level=3, LTO und
codegen-units=1. Derselbe Generator erzeugt das Referenz-Einzelmodul mit
508 Zeilen und das Projekt mit 20 Modulen / 49.760 Zeilen. Vollcompile und
Cacheänderung schließen die Projektauflösung und den Link ein. Cacheänderungen
ändern einen privaten Prozedurrumpf; sieben Läufe liefern den Median und prüfen
die Zahl tatsächlich neu übersetzter Module. Die Grenzwerte werden nur im
expliziten Release-Benchmark geprüft, nicht in normalen Debug-Tests.
| Referenz | Vollcompile | Cacheänderung (Median) | Neu / wiederverwendet | Budget |
| --- | ---: | ---: | ---: | ---: |
| Einzelmodul, 508 Zeilen | 1,21 ms | 0,74 ms | 1 / 0 | < 50 ms: erfüllt |
| Projekt, 49.760 Zeilen | 103 ms | 22,10 ms | 1 / 19 | < 1.000 ms: erfüllt |
Der Vollcompile des Einzelmoduls ist wie im bestehenden Harness der beste
von zehn Läufen; das Projekt wird einmal vollständig gemessen. Der Benchmark
zählt 335.421 verknüpfte Instruktionen. Diese Werte sind eine lokale Messung,
keine hardwareunabhängige Zeitgarantie.

View File

@@ -30,7 +30,7 @@ Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Text
|---|---|---|---| |---|---|---|---|
| 01 | [Projekt- und Dokumentmodell](proposal.md) | keine | Gemeinsame Lade-/Speicheraktionen, bearbeitete Quellen, MAK und Startup-Metadaten | | 01 | [Projekt- und Dokumentmodell](proposal.md) | keine | Gemeinsame Lade-/Speicheraktionen, bearbeitete Quellen, MAK und Startup-Metadaten |
| 02 | [IDE-Rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md) | 01 | Terminalbesitz, Menüs, Fenster, Dialoge, Optionen und Eingabetestpfad | | 02 | [IDE-Rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md) | 01 | Terminalbesitz, Menüs, Fenster, Dialoge, Optionen und Eingabetestpfad |
| 03 | [Editor und inkrementelle Übersetzung](../../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile | | 03 | [Editor und inkrementelle Übersetzung](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile |
| 04 | [Ausführung und Output](../../phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck | | 04 | [Ausführung und Output](../../phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck |
| 05 | [Formular-Designer](../../phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode | | 05 | [Formular-Designer](../../phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode |
| 06 | [Debugger und Direktfenster](../../phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte | | 06 | [Debugger und Direktfenster](../../phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte |

View File

@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-ide-Editor, tb-frontend Lexer/Parser/Sema und tb-vm Projektcompiler/Linker; bestehende Compile-Budget-Tests und docs/tbvm-design.md. Kein zweiter BASIC-Parser und kein Optimizer. tb-ide-Editor, tb-frontend Lexer/Parser/Sema und tb-vm Projektcompiler/Linker; bestehende Compile-Budget-Tests und docs/tbvm-design.md. Kein zweiter BASIC-Parser und kein Optimizer.
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md). **Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -1,26 +1,26 @@
## 1. Editor und Navigation ## 1. Editor und Navigation
- [ ] 1.1 Den Texteditor auf dem Dokumentmodell aus 01 integrieren; Tests mit Unicode, Tabs, Zeilenumbrüchen, Einfügen/Überschreiben, Auswahl und Undo belegen korrekten Text und Cursor. - [x] 1.1 Den Texteditor auf dem Dokumentmodell aus 01 integrieren; Tests mit Unicode, Tabs, Zeilenumbrüchen, Einfügen/Überschreiben, Auswahl und Undo belegen korrekten Text und Cursor.
- [ ] 1.2 CUA-, Wort-/Seitennavigation, Ctrl+Y und die spezifizierten WordStar-Chords/Lesezeichen anbinden; eingespeiste Tastenfolgen prüfen auch abgebrochene Chords und Mehrfenster-Kopieren. - [x] 1.2 CUA-, Wort-/Seitennavigation, Ctrl+Y und die spezifizierten WordStar-Chords/Lesezeichen anbinden; eingespeiste Tastenfolgen prüfen auch abgebrochene Chords und Mehrfenster-Kopieren.
- [ ] 1.3 Find, Selected Text, F3 und Change mit Einzel-/Gesamtersetzung implementieren; Tests prüfen Wortgrenzen, Suchrichtung, Großschreibung, Rücksprung, leere Ersetzung und gemeinsames Undo. - [x] 1.3 Find, Selected Text, F3 und Change mit Einzel-/Gesamtersetzung implementieren; Tests prüfen Wortgrenzen, Suchrichtung, Großschreibung, Rücksprung, leere Ersetzung und gemeinsames Undo.
- [ ] 1.4 Prozedurauswahl und Shift+F2/Ctrl+F2 sowie Include-Ansichten umsetzen; Navigationstests treffen physische Dateien und verhindern Speichern einer Expansion als Ursprungsdatei. - [x] 1.4 Prozedurauswahl und Shift+F2/Ctrl+F2 sowie Include-Ansichten umsetzen; Navigationstests treffen physische Dateien und verhindern Speichern einer Expansion als Ursprungsdatei.
## 2. Prüfung und Quelltextpflege ## 2. Prüfung und Quelltextpflege
- [ ] 2.1 Token-/Spanneninformationen des vorhandenen Frontends für semantikerhaltende Zeilennormalisierung verwenden; Tests belegen Idempotenz und erhalten Strings, Kommentare, DATA, Bezeichner und Typ-Suffixe. - [x] 2.1 Token-/Spanneninformationen des vorhandenen Frontends für semantikerhaltende Zeilennormalisierung verwenden; Tests belegen Idempotenz und erhalten Strings, Kommentare, DATA, Bezeichner und Typ-Suffixe.
- [ ] 2.2 Line Leave an Syntax Checking und Diagnoseanzeige anbinden; Eingabetests unterscheiden unfertige SUB-/IF-Blöcke von echten Fehlern und belegen das deaktivierte Verhalten. - [x] 2.2 Line Leave an Syntax Checking und Diagnoseanzeige anbinden; Eingabetests unterscheiden unfertige SUB-/IF-Blöcke von echten Fehlern und belegen das deaktivierte Verhalten.
- [ ] 2.3 New Sub/New Function mit Namenprüfung und wirksamer DEFtype-Übernahme implementieren; erzeugte Quellen müssen über den bestehenden Compiler gültig sein. - [x] 2.3 New Sub/New Function mit Namenprüfung und wirksamer DEFtype-Übernahme implementieren; erzeugte Quellen müssen über den bestehenden Compiler gültig sein.
- [ ] 2.4 DECLARE-Erzeugung/-Aktualisierung in den gemeinsamen Speicherhook aufnehmen; Tests belegen wiederholtes Speichern ohne Änderungen, Handdeklarationskonflikte, unverändertes Speichern unfertiger Entwürfe und denselben Weg für FRM-Code. - [x] 2.4 DECLARE-Erzeugung/-Aktualisierung in den gemeinsamen Speicherhook aufnehmen; Tests belegen wiederholtes Speichern ohne Änderungen, Handdeklarationskonflikte, unverändertes Speichern unfertiger Entwürfe und denselben Weg für FRM-Code.
## 3. Inkrementeller Compiler ## 3. Inkrementeller Compiler
- [ ] 3.1 Den Projektcompiler in wiederverwendbare Parse-/Modulcompile-/Link-Schritte zerlegen und den bisherigen Vollcompile darüber erhalten; bestehende Compiler-/CLI-Regressionen bleiben grün. - [x] 3.1 Den Projektcompiler in wiederverwendbare Parse-/Modulcompile-/Link-Schritte zerlegen und den bisherigen Vollcompile darüber erhalten; bestehende Compiler-/CLI-Regressionen bleiben grün.
- [ ] 3.2 Modulcache mit Dokument-/Include-Revisionssatz und Import-/Exportsignaturen einführen; Zählertests belegen Wiederverwendung unveränderter Module nach privater Rumpfänderung. - [x] 3.2 Modulcache mit Dokument-/Include-Revisionssatz und Import-/Exportsignaturen einführen; Zählertests belegen Wiederverwendung unveränderter Module nach privater Rumpfänderung.
- [ ] 3.3 Transitive Invalidierung für CONST, TYPE, COMMON, DECLARE und Includes umsetzen; Differentialtests mit Änderungen und gelöschten Symbolen liefern dieselben Diagnosen wie ein frischer Compile. - [x] 3.3 Transitive Invalidierung für CONST, TYPE, COMMON, DECLARE und Includes umsetzen; Differentialtests mit Änderungen und gelöschten Symbolen liefern dieselben Diagnosen wie ein frischer Compile.
- [ ] 3.4 Forms-Struktur, Modulreihenfolge und sämtliche beim Link remappten Identitäten berücksichtigen; Tests vergleichen umgeordnete Module, Control-Arrays, DATA und Prozedurverweise mit dem Vollcompile. - [x] 3.4 Forms-Struktur, Modulreihenfolge und sämtliche beim Link remappten Identitäten berücksichtigen; Tests vergleichen umgeordnete Module, Control-Arrays, DATA und Prozedurverweise mit dem Vollcompile.
- [ ] 3.5 Fehlerzustände und Ergebnisrevisionen an den Editor anbinden; ein Test mit gültig→fehlerhaft→gültig belegt, dass niemals ein veraltetes Ergebnis als aktueller Stand startet. - [x] 3.5 Fehlerzustände und Ergebnisrevisionen an den Editor anbinden; ein Test mit gültig→fehlerhaft→gültig belegt, dass niemals ein veraltetes Ergebnis als aktueller Stand startet.
## 4. Leistung und Abnahme ## 4. Leistung und Abnahme
- [ ] 4.1 Die vorhandenen Release-Compile-Benchmarks um Änderungen mit Cache einschließlich Invalidierung/Link ergänzen; dokumentierte Messungen prüfen Einzelmodul unter 50 ms und Referenzprojekt unter 1 s sowie tatsächlich neu übersetzte Module. - [x] 4.1 Die vorhandenen Release-Compile-Benchmarks um Änderungen mit Cache einschließlich Invalidierung/Link ergänzen; dokumentierte Messungen prüfen Einzelmodul unter 50 ms und Referenzprojekt unter 1 s sowie tatsächlich neu übersetzte Module.
- [ ] 4.2 Editor-/Compilerbedienung und Cachegrenzen in docs erläutern und alle Szenarien in verification.md zuordnen; relevante Frontend-, VM-, CLI- und IDE-Tests sowie Formatprüfung müssen bestehen. - [x] 4.2 Editor-/Compilerbedienung und Cachegrenzen in docs erläutern und alle Szenarien in verification.md zuordnen; relevante Frontend-, VM-, CLI- und IDE-Tests sowie Formatprüfung müssen bestehen.

View File

@@ -0,0 +1,119 @@
# Verifizierung: phase-5-03-editor-und-inkrementelle-uebersetzung
Stand: 2026-09-06. Abgleich gegen proposal.md, design.md, tasks.md und
specs/ide-editor/spec.md; Implementierung und Korrekturen im Arbeitsverzeichnis.
| Dimension | Ergebnis |
| --- | --- |
| Vollständigkeit | 15/15 Aufgaben, 7/7 Anforderungen, 10/10 Szenarien |
| Korrektheit | Ereignistests, Compiler-Differentialtests und Release-Messung bestanden |
| Kohärenz | Gemeinsames Dokumentmodell, regulärer Lexer/Parser/Sema und bestehender Linker; keine neue Abhängigkeit |
| Offene Befunde | 0 CRITICAL, 0 WARNING, 0 SUGGESTION |
## Anforderungs- und Szenariozuordnung
Alle Code- und Testpfade sind relativ zur Repositorywurzel angegeben.
| Anforderung / Szenario | Umsetzung | Nachweis |
| --- | --- | --- |
| Editorbewegung und Änderungen / Zusammengesetzte Bearbeitung | `tb-ide/src/editor.rs`: editor_key, editor_insert, editor_scroll; `documents.rs`: View, replace_ranges, undo; TerminalGuard aktiviert Bracketed Paste | `crates/tb-ide/tests/editor.rs`: cua_unicode_overwrite_wordstar_and_shared_undo; crlf_navigation_word_boundaries_readonly_and_normalization_keep_views |
| Suchen und Ersetzen / Leere und verkürzende Ersetzung | editor.rs: Search, matches, find_next, replace_matches; gemeinsame Dialogfelder und Dispatcher | search_scope_direction_wrap_and_shortening_replace_all: Richtung, Wortgrenzen, Case, Auswahlbereich, Ctrl+Backslash, F3-Rücksprung, leere/verkürzende Ersetzung und Undo |
| Zeilenprüfung und Normalisierung / Nutztext unverändert | `tb-frontend/src/editing.rs`: normalization_edits mit Keywordpositionen und Tokenvergleich; editor.rs: line_leave | `crates/tb-frontend/tests/editing.rs`: normalization_protects_tokens_and_is_idempotent; IDE-Zeilen-/Mehransichtstest |
| Zeilenprüfung und Normalisierung / Unvollständiger Block | Parser markiert ausschließlich fehlende Blockabschlüsse am EOF als unvollständig; keine zweite Fragmentgrammatik | incomplete_is_parser_context_not_a_second_grammar; line_leave_incomplete_invalid_disabled_and_current_revisions |
| Prozeduren und Deklarationen / Wiederholtes Speichern | new_procedure, Signaturen mit DEFtype-Kontext; gemeinsamer Project::save_file-Hook mit Projektauflösung; Save Project benutzt denselben Hook | declaration_maintenance_and_procedure_generation_are_transactional; deftype_context_does_not_leak_from_another_procedure; procedures_and_shared_save_hook_preserve_drafts_and_frm_code; external_include_revision_and_project_context_declarations |
| Prozeduren und Deklarationen / Unfertigen Entwurf speichern | Bei Parser-/Compilefehlern keine teilweise DECLARE-Pflege, unveränderter Entwurf mit begründeter Meldung; Handdeklarationen bleiben erhalten | Frontend-Test prüft unvollständigen Block, Syntaxfehler und konfliktbehaftete Handdeklaration; IDE-Test speichert und liest den Entwurf sowie FRM-Code erneut |
| Include-Ansichten und Diagnosen / Gleichnamige Quellzeilen | Included File öffnet das physische Dokument; Included Lines ist markiert und schreibgeschützt; goto_diagnostic verwendet Datei, physische Zeile und Spalte einschließlich FRM-Offset | includes_and_diagnostic_selection_use_original_physical_files: zwei Includes mit Fehlern auf Zeile 1; zusätzlicher Schreibschutz-/Scrolltest |
| Korrekte inkrementelle Übersetzung / Private Änderung | ProjectCompiler cached Parsing und unverknüpfte Produkte; Importvertragsvergleich ignoriert verschobene fremde Prozedurzeilen | `crates/tb-vm/tests/incremental.rs`: cached_private_edits_parse_and_compile_only_changed_modules; Zähler prüfen auch private Änderungen mit zusätzlicher Zeile vor einer weiteren Prozedur |
| Korrekte inkrementelle Übersetzung / Transitive Änderung | Bestehende Exportauflösung faltet Konstanten transitiv; Schlüssel enthalten benötigte importierte Konstanten, TYPE-/Prozedurtabellen und Forms-Katalog; COMMON wird frisch gelinkt | constants_types_common_removed_symbols_and_order_match_full_compiler; unused_constants_do_not_invalidate_independent_units_and_declare_changes_do; includes_source_ids_data_and_forms_relink_without_patching_old_products |
| Nachweisbarer Instant Compile / Reproduzierbare Messung | `crates/tb-vm/benches/compile.rs`: bestehender Referenzgenerator, Vollcompile und Cacheänderungen einschließlich Auflösung/Link und Modulzählern | Release-Messung unten und in docs/tbvm-design.md |
Die Kürzel `tb-ide/src` und `tb-frontend/src` in der Tabelle stehen jeweils unter
`crates/`. Tests ohne wiederholten Pfad stehen in den genannten editing.rs-
beziehungsweise editor.rs-Testdateien.
## Ergänzende Korrektheitsprüfung
- Differentialtests vergleichen **vollständige TBC-Bytes** erfolgreicher Cache-
und Vollcompiles. Damit stimmen Instruktionen, Tabellen und Quellzuordnung
überein; Fehlerfälle vergleichen die vollständigen formatierten Diagnosen.
- Abgedeckt sind private Änderungen, Konstante→transitiver Verbraucher,
gelöschte Symbole, TYPE-Layout, COMMON-Typ, DECLARE-Signatur, Modulreihenfolge,
zusätzliche Includedatei/Quell-ID, DATA, Strings, Prozedurverweise sowie der
Wechsel eines Controls zum Array und geänderte Index-Anfangswerte.
- Gültig→fehlerhaft→gültig liefert niemals einen alten Erfolg als aktuellen
Stand. Kompilate **und Diagnosen** sind revisionsgebunden. Noch ungeöffnete
Includes werden frisch gelesen; bereits geöffnete Puffer bleiben gemäß
Dokumentmodell maßgeblich gegenüber externen Dateiänderungen.
- Copy/Paste benutzen die interne Zwischenablage zwischen IDE-Fenstern.
CRLF, Unicode, Tabs und breite Zeichen werden über Eingabeereignisse geprüft;
TestBackend prüft die sichtbare Auswahl. Normalisierung und Speicherpflege
erhalten Cursor und Auswahl anderer Ansichten.
- Die bestehende Dokument-/IDE-Regression bleibt erhalten. Angepasste Erwartungen
betreffen die jetzt verfügbare Copy-Funktion, den zusätzlichen Diagnostics-
Menüpunkt und neue DECLARE-Zeilen beim Speichern. Der reine Fenstertest schaltet
Syntax Checking für seinen absichtlich ungültigen Text `abc` aus; die neuen
Editor-Tests prüfen aktivierte Prüfung und Fehlerdialoge ausdrücklich.
## Behobene Befunde aus den Prüfrunden
1. DECLARE-Pflege zunächst ohne Projektkontext: auf tatsächliche Include-,
Modul- und Forms-Auflösung im gemeinsamen Speicherhook umgestellt. Generierte
Deklarationen stehen nach TYPE-/DEFtype-Angaben vor den Prozeduren.
2. Positionsverschiebung bei Normalisierung/Speichern: disjunkte Textänderungen
und gemeinsames Positionsremapping erhalten Cursor, Auswahl und Lesezeichen;
Undo bleibt eine Dokumenttransaktion.
3. Weitere Schreibwege in Included Lines: Undo, Load Text, Prozedurerzeugung,
Ersetzen und Editieraktionen sind dort gesperrt; Lesen/Scrollen und Rückkehr
zum Original bleiben möglich.
4. CRLF- und Wortbewegung: Navigation/Entfernen behandeln CRLF gemeinsam,
Ctrl+Rechts berücksichtigt den Beginn des Wortbereichs und Auswahlkollaps
folgt der Bewegungsrichtung.
5. DEFtype-Kontext: keine Übernahme aus einer fremden Prozedur; Includes und der
aktuelle Block werden berücksichtigt. Prozedurnamen werden projektweit geprüft.
6. Cacheinvalidierung: verschobene fremde Quellzeilen sind kein Importvertrag;
unbenutzte importierte Konstanten invalidieren unabhängige Produkte nicht.
7. Diagnosegültigkeit: Quellenänderungen machen auch alte Diagnosen ungültig;
die Diagnoseauswahl prüft vor der Navigation erneut den Revisionssatz.
8. Doppeltes Menü-Mnemonic und Clippy-Hinweise beseitigt.
## Validierung
- `cargo test --workspace`: **529 bestanden, 0 fehlgeschlagen, 2 bestehende
Tests ignoriert**. Die ignorierten Tests sind der bewusste Golden-File-Generator
und der externe VBDOS-Korpus mit erforderlichem `TB_VBDOS_REPO`.
- `cargo clippy -p tb-ide -p tb-vm -p tb-frontend --all-targets -- -D warnings`:
bestanden.
- `cargo fmt --all -- --check`, `cargo build -p tb-ide`, `git diff --check`:
bestanden.
- `openspec validate --all --strict --json`: alle aktiven Changes und Hauptspecs
gültig.
- Tatsächlicher PTY-Smoke-Test mit `target/debug/tb`, 100×30 und isoliertem
temporären Arbeits-/Konfigurationsverzeichnis: Bracketed Paste eines
PRINT-Statements, Enter/Line Leave und Normalisierung, Exit mit Discard;
Exit-Code 0, identischer termios-Zustand, Cursor sichtbar, Alternate Screen und
Bracketed Paste deaktiviert, keine Dateien erzeugt. Kein Emulator/GUI nötig.
Release, macOS/aarch64, Apple M5 Max, 2026-09-06:
| Referenz | Vollcompile | Änderung mit Cache | Neu / wiederverwendet | Bewertung |
| --- | ---: | ---: | ---: | --- |
| 508 Zeilen | 1,21 ms | 0,74 ms | 1 / 0 | < 50 ms erfüllt |
| 49.760 Zeilen / 20 Module | 103 ms | 22,10 ms | 1 / 19 | < 1 s erfüllt |
Cachewerte sind der Median von sieben Änderungen einschließlich Invalidierung
und Link; der bisherige Vollcompile-Harness misst das beste Einzelmodul aus zehn
Läufen und einen Projektlauf. Die Messung umfasst 335.421 Projektinstruktionen.
## Bewertung
Keine offenen Befunde im Scope dieses Changes. Keine spezifizierte Funktion
wurde zurückgestellt. VM-Start/Continue und die Verwendung des aktuellen
Kompilats im Laufprozess gehören zum abhängigen Change 04; native Erzeugung
bleibt Phase 6. Der Change ist implementiert und verifiziert.
## Synchronisation und Archivierung
Am 2026-09-06 wurden alle sieben Anforderungen und zehn Szenarien in
`openspec/specs/ide-editor/spec.md` übernommen. Alle 18 Hauptspecs bestanden
die strikte Validierung vor der Archivierung. Der Change wurde anschließend
archiviert; relative Links und Abhängigkeiten der Folgechanges wurden angepasst.

View File

@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-ide-Sitzung, tb-vm/interp.rs, CLI-run_chain, tb-runtime/host.rs und builtins.rs sowie tb-ui/host.rs und screen.rs. Vorhandene Ereignisse, TextScreen und CLI-Ausführung bleiben die Grundlage. tb-ide-Sitzung, tb-vm/interp.rs, CLI-run_chain, tb-runtime/host.rs und builtins.rs sowie tb-ui/host.rs und screen.rs. Vorhandene Ereignisse, TextScreen und CLI-Ausführung bleiben die Grundlage.
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). **Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-ide-Designer, vorhandenes tb-ui::frm::FormFile, FormsModel und tb-frontend-Forms-Metadaten. Keine zweite FRM-Implementierung und keine Programmausführung als Designvorschau. tb-ide-Designer, vorhandenes tb-ui::frm::FormFile, FormsModel und tb-frontend-Forms-Metadaten. Keine zweite FRM-Implementierung und keine Programmausführung als Designvorschau.
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). **Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-vm/interp.rs, Debug-Quellorte und Frontend/Codegen für Ausdrücke und Direktfenster; tb-ide-Debugfenster. Keine zweite Auswertungsmaschine, kein Zeitreise-Debugger. tb-vm/interp.rs, Debug-Quellorte und Frontend/Codegen für Ausdrücke und Direktfenster; tb-ide-Debugfenster. Keine zweite Auswertungsmaschine, kein Zeitreise-Debugger.
**Abhängigkeiten:** [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md). **Abhängigkeiten:** [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-ide-Hilfe, docs/ und Paketierung von tb; nutzt Fensterverwaltung und Editor-Kontext. Kein Webdienst und keine eigene zweite Dokumentationssammlung. tb-ide-Hilfe, docs/ und Paketierung von tb; nutzt Fensterverwaltung und Editor-Kontext. Kein Webdienst und keine eigene zweite Dokumentationssammlung.
**Abhängigkeiten:** [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). **Abhängigkeiten:** [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt
tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise. tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise.
**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md). **Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md).
**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch.

View File

@@ -0,0 +1,68 @@
# ide-editor Specification
## Purpose
Ermöglicht die vollständige Quelltextbearbeitung in der IDE und schnelle, quellgenaue Übersetzung des aktuellen Projektstands mit unveränderter BASIC-Semantik.
## Requirements
### Requirement: Editorbewegung und Änderungen
Der Editor SHALL Einfügen/Überschreiben, Auswahl, CUA-Kopieren/Ausschneiden/Einfügen/Rückgängig, Pos1/Ende, seiten- und wortweise Navigation sowie Ctrl+Y zum Zeilenlöschen unterstützen. WordStar SHALL Ctrl+Q,S/D für Zeilenanfang/-ende, Ctrl+Q,R/C für Dateianfang/-ende und Ctrl+K,03 / Ctrl+Q,03 für Setzen/Anspringen von vier Lesezeichen unterstützen. Unicode-Zeichen und Tabulatoren SHALL ohne beschädigte Zeichen oder falsche Cursorpositionen bearbeitet werden.
#### Scenario: Zusammengesetzte Bearbeitung
- **WHEN** mehrzeiliger Text eingefügt, markiert, ausgeschnitten und rückgängig gemacht wird
- **THEN** sind Text, Auswahl und Cursor nachvollziehbar wiederhergestellt; ein abgeschnittener WordStar-Chord führt keine zufällige Textänderung aus
### Requirement: Suchen und Ersetzen
Find, Selected Text mit Ctrl+Backslash, Repeat Last Find mit F3 und Change SHALL Suchtext, Richtung, Groß-/Kleinschreibung und Wortgrenzen berücksichtigen. Change SHALL Einzelersetzung und Ersetzen aller Treffer im gewählten Suchbereich erlauben; eine Ersetzungsaktion SHALL rückgängig machbar sein. Das Ende des Suchbereichs SHALL einen erkennbaren Rücksprung anbieten.
#### Scenario: Leere und verkürzende Ersetzung
- **WHEN** Treffer durch leeren oder kürzeren Text ersetzt werden
- **THEN** werden alle vorgesehenen Treffer genau einmal verarbeitet; Undo stellt den gesamten Zustand vor der Aktion her
### Requirement: Zeilenprüfung und Normalisierung
Bei aktivem Syntax Checking SHALL das Verlassen einer geänderten Zeile eine Prüfung und bei eindeutig gültiger Syntax eine Normalisierung von Keywords und Leerzeichen auslösen. Zeichenketten, Kommentare, DATA-Nutztext, Bezeichner und Typ-Suffixe MUST semantisch unverändert bleiben. Unvollständige mehrzeilige Konstrukte SHALL als noch unvollständig behandelbar bleiben; sie dürfen die weitere Eingabe nicht blockieren. Abschalten SHALL automatische Formatierung und Fehlerdialoge unterbinden; Run SHALL weiterhin eine vollständige Gültigkeitsprüfung verlangen.
#### Scenario: Nutztext unverändert
- **WHEN** eine Zeile mit kleingeschriebenen Keywords, einem String, einem Kommentar und Typ-Suffixen verlassen wird
- **THEN** werden nur syntaktische Keywords und zulässige Abstände normalisiert; erneute Normalisierung verändert nichts mehr
#### Scenario: Unvollständiger Block
- **WHEN** nach SUB oder einem mehrzeiligen IF die nächste Zeile begonnen wird, bevor END SUB beziehungsweise END IF vorhanden ist
- **THEN** bleibt der Editor bedienbar und zeigt den unvollständigen Zustand, ohne das Fragment zu verwerfen oder ein altes Kompilat als aktuell startbar zu behandeln
### Requirement: Prozeduren und Deklarationen
New Sub und New Function SHALL gültige, eindeutig benannte Prozedurrümpfe mit den wirksamen DEFtype-Vorgaben anlegen. Shift+F2 und Ctrl+F2 SHALL Prozedurwahl beziehungsweise Rückkehr ermöglichen. Beim Speichern SHALL benötigte DECLARE-Prototypen erzeugt oder aktualisiert werden; benutzerdefinierte Deklarationen MUST NOT still überschrieben werden. Konflikte SHALL quellgenau gemeldet und der Dokumentstand erhalten werden. Unvollständige oder fehlerhafte Quellen SHALL als Entwurf speicherbar bleiben; dabei SHALL eine nicht sicher mögliche DECLARE-Pflege vollständig übersprungen und erklärt werden.
#### Scenario: Wiederholtes Speichern
- **WHEN** eine neue Prozedur angelegt und das Dokument mehrfach gespeichert wird
- **THEN** existiert genau eine passende Deklaration; der zweite Speichervorgang erzeugt keine weitere Änderung
#### Scenario: Unfertigen Entwurf speichern
- **WHEN** eine noch unvollständige Prozedur gespeichert und erneut geöffnet wird
- **THEN** bleibt der eingegebene Text erhalten; es wurde kein unvollständiger DECLARE-Vorspann erzeugt und die ausgelassene automatische Pflege wurde erklärt
### Requirement: Include-Ansichten und Diagnosen
Included File SHALL die Originaldatei der Einbindung öffnen; Included Lines SHALL die Expansion als klar gekennzeichnete Ansicht ein-/ausblenden. Diagnosewahl SHALL in das richtige Modul, die physische Datei und Zeile springen. Eingeblendete Expansion SHALL entweder zur Ursprungsdatei weiterleiten oder schreibgeschützt sein.
#### Scenario: Gleichnamige Quellzeilen
- **WHEN** zwei Includes derselben Übersetzung in derselben physischen Zeilennummer Fehler enthalten
- **THEN** führen ihre Diagnosen jeweils zur richtigen Datei und nicht zur verketteten Modulzeile
### Requirement: Korrekte inkrementelle Übersetzung
Nach Änderungen SHALL nur die betroffene Übersetzungseinheit und ihre tatsächlich betroffenen abhängigen Einheiten neu übersetzt werden. Includes, öffentliche Signaturen, Konstanten, TYPE-/COMMON-Deklarationen, Modulreihenfolge und Forms-Struktur SHALL in die Gültigkeitsprüfung eingehen. Inkrementelle und frische vollständige Übersetzung desselben Stands SHALL dieselben Diagnosen und dasselbe Laufzeitverhalten liefern. Veraltete Ergebnisse MUST NOT einen neueren Dokumentstand überschreiben oder als dessen Kompilat ausgeführt werden.
#### Scenario: Private Änderung
- **WHEN** nur der Prozedurrumpf eines Moduls ohne Änderung seines öffentlichen Vertrags geändert wird
- **THEN** wird dieses Modul neu übersetzt, unveränderte unabhängige Module werden wiederverwendet und das neu verknüpfte Projekt entspricht der vollständigen Übersetzung
#### Scenario: Transitive Änderung
- **WHEN** eine exportierte Konstante oder ein TYPE in einem gemeinsam eingebundenen Include geändert wird
- **THEN** werden alle betroffenen direkten und transitiven Verbraucher aktualisiert; entfernte Symbole liefern dieselben Fehler wie eine frische Übersetzung
### Requirement: Nachweisbarer Instant Compile
Die IDE-Übersetzung SHALL das bestehende Release-Budget von unter 50 ms für das Referenz-Einzelmodul und unter 1 s für das ungefähr 50.000-Zeilen-Referenzprojekt auf dokumentierter Referenzhardware einhalten. Messungen SHALL vollständigen Compile und Änderungen mit Cache einschließlich Invalidierung und Link getrennt ausweisen.
#### Scenario: Reproduzierbare Messung
- **WHEN** der Release-Benchmark auf der ausgewiesenen Referenzhardware ausgeführt wird
- **THEN** nennt er Hardware, Profil, Projektgröße, vollständige und inkrementelle Dauer sowie neu übersetzte Module und bewertet beide bestehenden Zeitgrenzen