450 lines
16 KiB
Rust
450 lines
16 KiB
Rust
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());
|
||
}
|