Phase 5: Editor und inkrementellen Compiler implementieren und archivieren
This commit is contained in:
@@ -202,6 +202,7 @@ pub struct Proc {
|
||||
pub is_static: bool,
|
||||
pub body: Vec<Stmt>,
|
||||
pub pos: SourcePos,
|
||||
pub end_pos: SourcePos,
|
||||
}
|
||||
|
||||
/// `OPEN … FOR modus`.
|
||||
|
||||
361
crates/tb-frontend/src/editing.rs
Normal file
361
crates/tb-frontend/src/editing.rs
Normal 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)
|
||||
}
|
||||
@@ -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 name: String,
|
||||
pub class: ObjectClass,
|
||||
@@ -682,7 +682,7 @@ pub struct FormObject {
|
||||
pub array: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct FormCatalog {
|
||||
pub objects: Vec<FormObject>,
|
||||
}
|
||||
|
||||
@@ -70,3 +70,5 @@ pub fn analyze_source_with_forms(
|
||||
hir,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod editing;
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::lexer::{Kw, NumValue, Token, TokenKind};
|
||||
use crate::{Diagnostic, SourcePos};
|
||||
|
||||
pub struct ParseOutput {
|
||||
/// Ausschließlich fehlende Blockabschlüsse am Dateiende.
|
||||
pub incomplete: bool,
|
||||
pub module: Module,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
@@ -19,6 +21,7 @@ pub fn parse(module_name: &str, tokens: &[Token]) -> ParseOutput {
|
||||
i: 0,
|
||||
diags: Vec::new(),
|
||||
at_line_start: true,
|
||||
incomplete_errors: 0,
|
||||
};
|
||||
let mut body = Vec::new();
|
||||
let mut procs = Vec::new();
|
||||
@@ -56,6 +59,7 @@ pub fn parse(module_name: &str, tokens: &[Token]) -> ParseOutput {
|
||||
}
|
||||
|
||||
ParseOutput {
|
||||
incomplete: !p.diags.is_empty() && p.incomplete_errors == p.diags.len(),
|
||||
module: Module {
|
||||
name: module_name.to_string(),
|
||||
body,
|
||||
@@ -70,6 +74,7 @@ struct P<'a> {
|
||||
i: usize,
|
||||
diags: Vec<Diagnostic>,
|
||||
at_line_start: bool,
|
||||
incomplete_errors: usize,
|
||||
}
|
||||
|
||||
impl<'a> P<'a> {
|
||||
@@ -105,10 +110,27 @@ impl<'a> P<'a> {
|
||||
}
|
||||
fn err(&mut self, msg: impl Into<String>) {
|
||||
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 {
|
||||
file: None,
|
||||
pos,
|
||||
message: msg.into(),
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
fn expect_kw(&mut self, kw: Kw, what: &str) -> bool {
|
||||
@@ -1783,6 +1805,7 @@ impl<'a> P<'a> {
|
||||
ProcKind::Function => Kw::Function,
|
||||
};
|
||||
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) {
|
||||
self.advance();
|
||||
self.advance();
|
||||
@@ -1797,6 +1820,7 @@ impl<'a> P<'a> {
|
||||
is_static,
|
||||
body,
|
||||
pos,
|
||||
end_pos,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ pub struct SourceFile {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SourceSegment {
|
||||
pub file: String,
|
||||
pub first_line: u32,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SourceUnit {
|
||||
pub name: String,
|
||||
pub segments: Vec<SourceSegment>,
|
||||
|
||||
95
crates/tb-frontend/tests/editing.rs
Normal file
95
crates/tb-frontend/tests/editing.rs
Normal 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());
|
||||
}
|
||||
@@ -185,6 +185,13 @@ pub enum AfterSave {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DialogKind {
|
||||
Search {
|
||||
view: ViewId,
|
||||
selection: Option<std::ops::Range<usize>>,
|
||||
},
|
||||
Procedure(bool),
|
||||
Procedures(Vec<usize>),
|
||||
Diagnostics,
|
||||
Browse {
|
||||
return_to: Box<Dialog>,
|
||||
field: usize,
|
||||
@@ -249,6 +256,7 @@ pub enum Hit {
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub editor: crate::editor::Editor,
|
||||
pub project: Project,
|
||||
pub options: Options,
|
||||
pub mode: Mode,
|
||||
@@ -285,6 +293,7 @@ impl App {
|
||||
let (options, errors, config_disk) = Options::load(&config_path);
|
||||
project.include_paths = options.include_paths.clone();
|
||||
let mut app = Self {
|
||||
editor: Default::default(),
|
||||
project,
|
||||
options: options.clone(),
|
||||
saved_options: options,
|
||||
@@ -393,8 +402,13 @@ impl App {
|
||||
.into_owned()
|
||||
});
|
||||
format!(
|
||||
"[{}] {}{}",
|
||||
"[{}] {}{}{}",
|
||||
w.id,
|
||||
if self.editor.expansions.contains_key(&v) {
|
||||
"Included Lines [read-only] · "
|
||||
} else {
|
||||
""
|
||||
},
|
||||
name,
|
||||
if doc.is_dirty() { " *" } else { "" }
|
||||
)
|
||||
@@ -438,12 +452,41 @@ impl App {
|
||||
return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}"));
|
||||
}
|
||||
use Command::*;
|
||||
if matches!(command, LoadText | SaveText)
|
||||
&& !matches!(
|
||||
self.active_window().map(|w| w.kind),
|
||||
Some(WindowKind::Code(_))
|
||||
)
|
||||
if 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),
|
||||
Some(WindowKind::Code(_))
|
||||
) {
|
||||
return Some("Kein Codefenster aktiv".into());
|
||||
}
|
||||
if matches!(
|
||||
@@ -489,6 +532,9 @@ impl App {
|
||||
use Command::*;
|
||||
let id = self.active_document();
|
||||
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)?,
|
||||
OpenProject => self.open_dialog(
|
||||
"Open Project",
|
||||
@@ -531,7 +577,10 @@ impl App {
|
||||
{
|
||||
self.save_dialog(false, vec![id], AfterSave::Stay)?;
|
||||
} 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)?,
|
||||
@@ -795,7 +844,12 @@ impl App {
|
||||
.display()
|
||||
.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));
|
||||
}
|
||||
fn cascade(&self) -> Rect {
|
||||
@@ -810,7 +864,7 @@ impl App {
|
||||
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 {
|
||||
ensure!(
|
||||
matches!(self.project.document(id)?.content(), Content::Form(_)),
|
||||
@@ -890,6 +944,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
self.base = self.project.directory().to_path_buf();
|
||||
self.editor = Default::default();
|
||||
self.windows.clear();
|
||||
self.active = 0;
|
||||
self.selected_member = 0;
|
||||
@@ -961,6 +1016,10 @@ impl App {
|
||||
}
|
||||
fn submit(&mut self, d: &mut Dialog) -> Result<bool> {
|
||||
match d.kind.clone() {
|
||||
DialogKind::Search { .. }
|
||||
| DialogKind::Procedure(_)
|
||||
| DialogKind::Procedures(_)
|
||||
| DialogKind::Diagnostics => return self.editor_submit(d),
|
||||
DialogKind::Browse {
|
||||
mut return_to,
|
||||
field,
|
||||
@@ -1075,19 +1134,18 @@ impl App {
|
||||
} else {
|
||||
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)?;
|
||||
}
|
||||
DialogKind::LoadText => {
|
||||
let (id, at) = self.code_cursor()?;
|
||||
self.project
|
||||
.load_text(id, at, &self.base.join(d.fields[0].string()))?;
|
||||
let text = std::fs::read_to_string(self.base.join(d.fields[0].string()))?;
|
||||
self.editor_insert(&text)?;
|
||||
}
|
||||
DialogKind::SaveText => {
|
||||
let (id, _) = self.code_cursor()?;
|
||||
self.project.save_text(
|
||||
id,
|
||||
None,
|
||||
self.project.view(self.editor_view()?)?.selection(),
|
||||
&Destination {
|
||||
path: d.fields[0].string().into(),
|
||||
overwrite: d.fields[1].flag(),
|
||||
@@ -1189,6 +1247,14 @@ impl App {
|
||||
Ok((view.document(), view.cursor))
|
||||
}
|
||||
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 {
|
||||
self.size = (w, h);
|
||||
return;
|
||||
@@ -1253,6 +1319,12 @@ impl App {
|
||||
}
|
||||
} else if self.program_focus() {
|
||||
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) {
|
||||
@@ -1334,6 +1406,16 @@ impl App {
|
||||
)
|
||||
}
|
||||
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() {
|
||||
self.browse();
|
||||
return;
|
||||
@@ -1606,48 +1688,10 @@ impl App {
|
||||
))
|
||||
}
|
||||
fn code_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||
let Some(Window {
|
||||
kind: WindowKind::Code(view),
|
||||
..
|
||||
}) = self.active_window()
|
||||
else {
|
||||
if self.editor_view().is_err() {
|
||||
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.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(())
|
||||
self.editor_key(key)
|
||||
}
|
||||
pub fn menu_entries(&self, index: usize) -> Vec<(String, Option<Command>)> {
|
||||
if self.control_menu {
|
||||
@@ -1732,6 +1776,8 @@ pub fn shortcut(key: KeyEvent) -> Option<Command> {
|
||||
K::F(5) if ctrl => Restore,
|
||||
K::F(1) if shift => UsingHelp,
|
||||
K::F(1) => Topic,
|
||||
K::F(2) if shift => Procedures,
|
||||
K::F(2) if ctrl => PreviousCode,
|
||||
K::F(2) => Code,
|
||||
K::F(3) => FindNext,
|
||||
K::F(4) => OutputScreen,
|
||||
@@ -1746,6 +1792,10 @@ pub fn shortcut(key: KeyEvent) -> Option<Command> {
|
||||
K::F(10) => ProcedureStep,
|
||||
K::F(12) if shift => Form,
|
||||
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('x') if ctrl => Cut,
|
||||
K::Char('v') if ctrl => Paste,
|
||||
|
||||
@@ -20,6 +20,9 @@ pub enum Command {
|
||||
Copy,
|
||||
Paste,
|
||||
Clear,
|
||||
Procedures,
|
||||
PreviousCode,
|
||||
Diagnostics,
|
||||
NewSub,
|
||||
NewFunction,
|
||||
Events,
|
||||
@@ -97,8 +100,6 @@ impl Command {
|
||||
pub fn feature_phase(self) -> Option<u8> {
|
||||
use Command::*;
|
||||
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),
|
||||
Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5),
|
||||
NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches
|
||||
@@ -194,6 +195,8 @@ pub fn menus(designer: bool) -> Vec<Menu> {
|
||||
'v',
|
||||
vec![
|
||||
item("&Code…", Code),
|
||||
item("&Procedures…", Procedures),
|
||||
item("Diagno&stics…", Diagnostics),
|
||||
item("&Form…", Form),
|
||||
sep(),
|
||||
item("&Next Statement", NextStatement),
|
||||
|
||||
@@ -31,10 +31,18 @@ pub struct ViewId(u64);
|
||||
pub struct View {
|
||||
document: DocumentId,
|
||||
pub cursor: usize,
|
||||
pub anchor: Option<usize>,
|
||||
pub bookmarks: [Option<usize>; 4],
|
||||
pub overwrite: bool,
|
||||
pub scroll_line: usize,
|
||||
pub scroll_column: usize,
|
||||
}
|
||||
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 {
|
||||
self.document
|
||||
}
|
||||
@@ -121,6 +129,7 @@ pub struct Project {
|
||||
documents: BTreeMap<DocumentId, Document>,
|
||||
views: BTreeMap<ViewId, View>,
|
||||
pub include_paths: Vec<PathBuf>,
|
||||
pub save_notices: Vec<String>,
|
||||
}
|
||||
impl Project {
|
||||
pub fn directory(&self) -> &Path {
|
||||
@@ -136,6 +145,7 @@ impl Project {
|
||||
documents: BTreeMap::new(),
|
||||
views: BTreeMap::new(),
|
||||
include_paths: Vec::new(),
|
||||
save_notices: Vec::new(),
|
||||
})
|
||||
}
|
||||
pub fn open(path: &Path, include_paths: Vec<PathBuf>) -> Result<Self> {
|
||||
@@ -385,6 +395,9 @@ impl Project {
|
||||
View {
|
||||
document,
|
||||
cursor: 0,
|
||||
anchor: None,
|
||||
bookmarks: [None; 4],
|
||||
overwrite: false,
|
||||
scroll_line: 0,
|
||||
scroll_column: 0,
|
||||
},
|
||||
@@ -415,6 +428,32 @@ impl Project {
|
||||
if old.content == content {
|
||||
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 {
|
||||
content: old.content.clone(),
|
||||
views: self
|
||||
@@ -430,39 +469,64 @@ impl Project {
|
||||
doc.revision += 1;
|
||||
let code = doc.code();
|
||||
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(())
|
||||
}
|
||||
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();
|
||||
ensure!(
|
||||
range.start <= range.end && content.code().get(range.clone()).is_some(),
|
||||
"Ungültiger Textbereich"
|
||||
);
|
||||
content.code_mut().replace_range(range.clone(), text);
|
||||
let cursors: Vec<_> = self
|
||||
let mut end = 0;
|
||||
for (range, _) in edits {
|
||||
ensure!(
|
||||
range.start >= end
|
||||
&& range.start <= range.end
|
||||
&& content.code().get(range.clone()).is_some(),
|
||||
"Ungültiger Textbereich"
|
||||
);
|
||||
end = range.end;
|
||||
}
|
||||
let views: Vec<_> = self
|
||||
.views
|
||||
.iter()
|
||||
.filter(|(_, v)| v.document == id)
|
||||
.map(|(id, v)| (*id, v.cursor))
|
||||
.map(|(id, v)| (*id, v.clone()))
|
||||
.collect();
|
||||
for (range, text) in edits.iter().rev() {
|
||||
content.code_mut().replace_range(range.clone(), text);
|
||||
}
|
||||
self.commit_edit(id, content)?;
|
||||
for (view_id, cursor) in cursors {
|
||||
for (view_id, old) in views {
|
||||
let map = |mut at: usize| {
|
||||
for (range, text) in edits.iter().rev() {
|
||||
if at > range.start {
|
||||
at = if at >= range.end {
|
||||
at - range.len() + text.len()
|
||||
} else {
|
||||
range.start + (at - range.start).min(text.len())
|
||||
};
|
||||
}
|
||||
}
|
||||
boundary(self.documents[&id].code(), at)
|
||||
};
|
||||
let cursor = map(old.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;
|
||||
if view.cursor > range.start {
|
||||
view.cursor = if view.cursor >= range.end {
|
||||
view.cursor - range.len() + text.len()
|
||||
} else {
|
||||
range.start + text.len()
|
||||
};
|
||||
}
|
||||
}
|
||||
// Neue Positionen werden auch nach Ersetzungen mit Unicode begrenzt.
|
||||
let code = self.documents[&id].code();
|
||||
for view in self.views.values_mut().filter(|v| v.document == id) {
|
||||
view.cursor = boundary(code, view.cursor);
|
||||
view.anchor = anchor;
|
||||
view.bookmarks = bookmarks;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -497,6 +561,8 @@ impl Project {
|
||||
let code = doc.code();
|
||||
for view in self.views.values_mut().filter(|v| v.document == id) {
|
||||
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)
|
||||
}
|
||||
@@ -612,10 +678,64 @@ impl Project {
|
||||
pub fn save_file(&mut self, id: DocumentId, target: Option<&Destination>) -> Result<()> {
|
||||
let (path, overwrite) = self.file_target(id, target)?;
|
||||
let doc = self.document(id)?;
|
||||
let content = loaded(
|
||||
let mut content = loaded(
|
||||
self.loader()?
|
||||
.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 expected = if doc.path.as_deref() == Some(&path) {
|
||||
doc.disk.as_deref()
|
||||
@@ -623,6 +743,10 @@ impl Project {
|
||||
None
|
||||
};
|
||||
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)?;
|
||||
let doc = self.documents.get_mut(&id).unwrap();
|
||||
self.manifest.rename(&doc.source_path, &path);
|
||||
@@ -686,8 +810,12 @@ impl Project {
|
||||
expected,
|
||||
plan.project.as_ref().is_some_and(|d| d.overwrite),
|
||||
)?;
|
||||
let mut notices = Vec::new();
|
||||
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.
|
||||
let expected = if self.path.as_deref() == Some(&target) {
|
||||
|
||||
918
crates/tb-ide/src/editor.rs
Normal file
918
crates/tb-ide/src/editor.rs
Normal 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 == ¤t.units
|
||||
&& forms
|
||||
== ¤t
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,5 @@ pub mod export;
|
||||
pub mod options;
|
||||
pub mod render;
|
||||
pub mod terminal;
|
||||
|
||||
pub mod editor;
|
||||
|
||||
@@ -153,17 +153,74 @@ impl App {
|
||||
content_style,
|
||||
);
|
||||
} else {
|
||||
let lines: Vec<_> = doc
|
||||
.code()
|
||||
.split('\n')
|
||||
.map(|line| expanded(line, self.options.tab_width))
|
||||
.collect();
|
||||
let expansion = self.editor.expansions.get(&view);
|
||||
let code = expansion.map(String::as_str).unwrap_or(doc.code());
|
||||
let lines: Vec<_> = code.split('\n').collect();
|
||||
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
|
||||
.into_iter()
|
||||
.skip(v.scroll_line)
|
||||
.map(|s| Line::raw(s.chars().skip(v.scroll_column).collect::<String>()))
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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<_>>();
|
||||
f.render_widget(Paragraph::new(text).style(content_style), inner);
|
||||
if count > inner.height as usize {
|
||||
@@ -186,7 +243,11 @@ impl App {
|
||||
.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 row = before.bytes().filter(|c| *c == b'\n').count();
|
||||
let col = expanded(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crossterm::{
|
||||
cursor::{Hide, Show},
|
||||
event::{DisableMouseCapture, EnableMouseCapture},
|
||||
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
||||
execute,
|
||||
style::ResetColor,
|
||||
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> {
|
||||
let mut guard = Self { writer, raw };
|
||||
(guard.raw)(true)?;
|
||||
execute!(guard.writer, EnterAlternateScreen, EnableMouseCapture, Hide)?;
|
||||
execute!(
|
||||
guard.writer,
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture,
|
||||
EnableBracketedPaste,
|
||||
Hide
|
||||
)?;
|
||||
Ok(guard)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +43,7 @@ impl<W: Write> Drop for TerminalGuard<W> {
|
||||
ResetColor,
|
||||
Show,
|
||||
DisableMouseCapture,
|
||||
DisableBracketedPaste,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
let name = item.text();
|
||||
assert!(
|
||||
reference.contains(name.trim_end_matches('…')),
|
||||
reference.contains(name.trim_end_matches('…'))
|
||||
|| item.command == Some(Command::Diagnostics),
|
||||
"Nicht in Referenz: {name}"
|
||||
);
|
||||
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();
|
||||
key(&mut app, K::Char('c'), M::CONTROL);
|
||||
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));
|
||||
assert!(app.menu.is_some());
|
||||
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() {
|
||||
let t = Temp::new();
|
||||
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");
|
||||
let doc = app.active_document().unwrap();
|
||||
let first = app.active;
|
||||
|
||||
@@ -450,9 +450,10 @@ fn aliases_share_documents_and_form_code_offsets_match_saved_text() {
|
||||
let id = p.new_form("F").unwrap();
|
||||
p.replace_text(id, 0..0, "SUB Form_Load\nERROR 6\nEND SUB\n")
|
||||
.unwrap();
|
||||
let before = p.sources().unwrap();
|
||||
let target = t.0.join("F.frm");
|
||||
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();
|
||||
assert_eq!(
|
||||
before.units[1]
|
||||
|
||||
449
crates/tb-ide/tests/editor.rs
Normal file
449
crates/tb-ide/tests/editor.rs
Normal 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());
|
||||
}
|
||||
@@ -44,7 +44,59 @@ fn compile_all(sources: &[(String, String)]) -> usize {
|
||||
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() {
|
||||
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).
|
||||
let single = generate_module(50, 0);
|
||||
let single_lines = single.lines().count();
|
||||
@@ -90,4 +142,6 @@ fn main() {
|
||||
println!(" Durchsatz: {:.0} Zeilen/s", lps);
|
||||
assert!(best_single < 0.050, "Einzelmodul-Budget verfehlt");
|
||||
assert!(project_secs < 1.0, "Projekt-Budget verfehlt");
|
||||
incremental(&[("EINZEL".into(), single)], 0.050);
|
||||
incremental(&modules, 1.0);
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ pub struct DataItem {
|
||||
}
|
||||
|
||||
/// Übersetztes Modul — Inhalt des `.tbc`-Containers.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledModule {
|
||||
pub modules: Vec<(String, u8)>,
|
||||
pub sources: Vec<SourceFile>,
|
||||
|
||||
@@ -22,135 +22,237 @@ 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(
|
||||
name: &str,
|
||||
units: &[SourceUnit],
|
||||
catalog: &FormCatalog,
|
||||
forms: &[FormFile],
|
||||
) -> Result<CompiledModule, Vec<Diagnostic>> {
|
||||
if units.is_empty() || units.len() > u16::MAX as usize {
|
||||
return Err(vec![diagnostic("Projekt ohne Module oder zu viele Module")]);
|
||||
}
|
||||
let mut sources = Vec::new();
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut names = HashSet::new();
|
||||
let parsed: Vec<_> = units
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, unit)| {
|
||||
let (module, errors) = unit.parse(id as u16, &mut sources);
|
||||
if !names.insert(unit.name.to_uppercase()) {
|
||||
let mut error = diagnostic(format!("Duplicate definition: module {}", unit.name));
|
||||
error.pos = module_pos(&module);
|
||||
error.file = unit.segments.first().map(|s| s.file.clone());
|
||||
diagnostics.push(error);
|
||||
}
|
||||
diagnostics.extend(errors);
|
||||
module
|
||||
})
|
||||
.collect();
|
||||
locate_diagnostics(&mut diagnostics, &sources);
|
||||
if !diagnostics.is_empty() {
|
||||
return Err(diagnostics);
|
||||
}
|
||||
let mut catalog = catalog.clone();
|
||||
if catalog.find("SCREEN").is_none() {
|
||||
catalog.add(
|
||||
"SCREEN",
|
||||
tb_frontend::forms::ObjectClass::Screen,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
}
|
||||
for module in &parsed {
|
||||
if module
|
||||
.body
|
||||
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 {
|
||||
return Err(vec![diagnostic("Projekt ohne Module oder zu viele Module")]);
|
||||
}
|
||||
let mut sources = Vec::new();
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut names = HashSet::new();
|
||||
let parsed: Vec<_> = units
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stmt::MetaForm { .. }))
|
||||
&& catalog.find(&module.name).is_none()
|
||||
{
|
||||
.enumerate()
|
||||
.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 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()) {
|
||||
let mut error =
|
||||
diagnostic(format!("Duplicate definition: module {}", unit.name));
|
||||
error.pos = module_pos(&module);
|
||||
error.file = unit.segments.first().map(|s| s.file.clone());
|
||||
diagnostics.push(error);
|
||||
}
|
||||
diagnostics.extend(errors);
|
||||
module
|
||||
})
|
||||
.collect();
|
||||
locate_diagnostics(&mut diagnostics, &sources);
|
||||
if !diagnostics.is_empty() {
|
||||
return Err(diagnostics);
|
||||
}
|
||||
let mut catalog = catalog.clone();
|
||||
if catalog.find("SCREEN").is_none() {
|
||||
catalog.add(
|
||||
&module.name,
|
||||
tb_frontend::forms::ObjectClass::Form,
|
||||
"SCREEN",
|
||||
tb_frontend::forms::ObjectClass::Screen,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut exports: Vec<_> = parsed
|
||||
.iter()
|
||||
.map(|m| tb_frontend::sema::export_declarations(m, &[]))
|
||||
.collect();
|
||||
// Konstantenabhängigkeiten können beliebig über Module verteilt sein.
|
||||
// Jeder erfolgreiche Durchlauf löst mindestens eine weitere Deklaration auf.
|
||||
let constant_count: usize = exports
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|s| matches!(s, Stmt::ConstDecl { .. }))
|
||||
.count();
|
||||
for _ in 0..constant_count {
|
||||
let mut constants: HashMap<_, Vec<_>> = HashMap::new();
|
||||
for stmt in exports.iter().flatten() {
|
||||
if let Stmt::ConstDecl { items, .. } = stmt {
|
||||
constants.entry(&items[0].0).or_default().push(stmt.clone());
|
||||
for module in &parsed {
|
||||
if module
|
||||
.body
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stmt::MetaForm { .. }))
|
||||
&& catalog.find(&module.name).is_none()
|
||||
{
|
||||
catalog.add(
|
||||
&module.name,
|
||||
tb_frontend::forms::ObjectClass::Form,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let constants: Vec<_> = constants
|
||||
.into_values()
|
||||
.filter(|s| s.len() == 1)
|
||||
.flatten()
|
||||
.collect();
|
||||
let next: Vec<_> = parsed
|
||||
let mut exports: Vec<_> = parsed
|
||||
.iter()
|
||||
.map(|m| tb_frontend::sema::export_declarations(m, &constants))
|
||||
.map(|m| tb_frontend::sema::export_declarations(m, &[]))
|
||||
.collect();
|
||||
if next == exports {
|
||||
break;
|
||||
// Konstantenabhängigkeiten können beliebig über Module verteilt sein.
|
||||
// Jeder erfolgreiche Durchlauf löst mindestens eine weitere Deklaration auf.
|
||||
let constant_count: usize = exports
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|s| matches!(s, Stmt::ConstDecl { .. }))
|
||||
.count();
|
||||
for _ in 0..constant_count {
|
||||
let mut constants: HashMap<_, Vec<_>> = HashMap::new();
|
||||
for stmt in exports.iter().flatten() {
|
||||
if let Stmt::ConstDecl { items, .. } = stmt {
|
||||
constants.entry(&items[0].0).or_default().push(stmt.clone());
|
||||
}
|
||||
}
|
||||
let constants: Vec<_> = constants
|
||||
.into_values()
|
||||
.filter(|s| s.len() == 1)
|
||||
.flatten()
|
||||
.collect();
|
||||
let next: Vec<_> = parsed
|
||||
.iter()
|
||||
.map(|m| tb_frontend::sema::export_declarations(m, &constants))
|
||||
.collect();
|
||||
if next == exports {
|
||||
break;
|
||||
}
|
||||
exports = next;
|
||||
}
|
||||
exports = next;
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
let mut commons = Vec::new();
|
||||
for module in &parsed {
|
||||
let mut module = module.clone();
|
||||
import_declarations(&mut module, &parsed, &exports);
|
||||
let (hir, errors) = tb_frontend::sema::lower_with_forms(&module, &catalog);
|
||||
diagnostics.extend(errors);
|
||||
if let Some(hir) = hir {
|
||||
parts.push(codegen::compile(&hir));
|
||||
commons.push(hir.commons);
|
||||
let mut parts = Vec::new();
|
||||
let mut commons = Vec::new();
|
||||
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();
|
||||
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);
|
||||
diagnostics.extend(errors);
|
||||
if let Some(hir) = hir {
|
||||
let code = codegen::compile(&hir);
|
||||
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);
|
||||
if !diagnostics.is_empty() {
|
||||
return Err(diagnostics);
|
||||
}
|
||||
let mut result = link(name, parts, &parsed, &commons).map_err(|error| {
|
||||
let mut errors = vec![error];
|
||||
locate_diagnostics(&mut errors, &sources);
|
||||
errors
|
||||
})?;
|
||||
result.sources = sources;
|
||||
let objects = FormCatalog {
|
||||
objects: result.objects.clone(),
|
||||
};
|
||||
for form in forms {
|
||||
locate_diagnostics(&mut diagnostics, &sources);
|
||||
if !diagnostics.is_empty() {
|
||||
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 errors = vec![error];
|
||||
locate_diagnostics(&mut errors, &sources);
|
||||
errors
|
||||
})?;
|
||||
result.sources = sources;
|
||||
let objects = FormCatalog {
|
||||
objects: result.objects.clone(),
|
||||
};
|
||||
for form in forms {
|
||||
result
|
||||
.form_initial
|
||||
.extend(form.initial_values(&objects).map_err(|e| {
|
||||
vec![diagnostic(format!(
|
||||
"{}: ungültige Forms-Anfangsdaten ({e})",
|
||||
form.root.name
|
||||
))]
|
||||
})?);
|
||||
}
|
||||
result.startup_form = forms
|
||||
.first()
|
||||
.and_then(|form| objects.find(&form.root.name).map(|(id, _)| id));
|
||||
result
|
||||
.form_initial
|
||||
.extend(form.initial_values(&objects).map_err(|e| {
|
||||
vec![diagnostic(format!(
|
||||
"{}: ungültige Forms-Anfangsdaten ({e})",
|
||||
form.root.name
|
||||
))]
|
||||
})?);
|
||||
.validate()
|
||||
.map_err(|e| vec![diagnostic(e.to_string())])?;
|
||||
Ok(result)
|
||||
}
|
||||
result.startup_form = forms
|
||||
.first()
|
||||
.and_then(|form| objects.find(&form.root.name).map(|(id, _)| id));
|
||||
result
|
||||
.validate()
|
||||
.map_err(|e| vec![diagnostic(e.to_string())])?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Lokale Definition gewinnt; außerhalb ihres Moduls muss ein Name eindeutig sein.
|
||||
@@ -251,7 +353,12 @@ fn import_type(
|
||||
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 mut procedures: HashSet<_> = module.procs.iter().map(|p| p.sig.name.clone()).collect();
|
||||
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);
|
||||
let mut types = Vec::new();
|
||||
imported.retain(|stmt| {
|
||||
|
||||
162
crates/tb-vm/tests/incremental.rs
Normal file
162
crates/tb-vm/tests/incremental.rs
Normal 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, &[]));
|
||||
}
|
||||
Reference in New Issue
Block a user