diff options
Diffstat (limited to 'helix-term')
-rw-r--r-- | helix-term/Cargo.toml | 6 | ||||
-rw-r--r-- | helix-term/src/application.rs | 157 | ||||
-rw-r--r-- | helix-term/src/commands.rs | 13 | ||||
-rw-r--r-- | helix-term/src/commands/lsp.rs | 6 | ||||
-rw-r--r-- | helix-term/src/commands/typed.rs | 14 | ||||
-rw-r--r-- | helix-term/src/compositor.rs | 26 | ||||
-rw-r--r-- | helix-term/src/job.rs | 21 | ||||
-rw-r--r-- | helix-term/src/main.rs | 28 | ||||
-rw-r--r-- | helix-term/src/ui/mod.rs | 2 | ||||
-rw-r--r-- | helix-term/tests/integration.rs | 25 | ||||
-rw-r--r-- | helix-term/tests/test/auto_indent.rs | 26 | ||||
-rw-r--r-- | helix-term/tests/test/auto_pairs.rs | 21 | ||||
-rw-r--r-- | helix-term/tests/test/commands.rs | 93 | ||||
-rw-r--r-- | helix-term/tests/test/helpers.rs | 213 | ||||
-rw-r--r-- | helix-term/tests/test/movement.rs | 87 | ||||
-rw-r--r-- | helix-term/tests/test/write.rs | 169 |
16 files changed, 831 insertions, 76 deletions
diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 0f80c416..f1903f04 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -17,6 +17,7 @@ app = true [features] unicode-lines = ["helix-core/unicode-lines"] +integration = [] [[bin]] name = "hx" @@ -73,3 +74,8 @@ signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } [build-dependencies] helix-loader = { version = "0.6", path = "../helix-loader" } + +[dev-dependencies] +smallvec = "1.8" +indoc = "1.0.3" +tempfile = "3.3.0" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 0c8de2ab..48e9c275 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,4 +1,5 @@ use arc_swap::{access::Map, ArcSwap}; +use futures_util::Stream; use helix_core::{ config::{default_syntax_loader, user_syntax_loader}, pos_at_coords, syntax, Selection, @@ -24,10 +25,10 @@ use std::{ time::{Duration, Instant}, }; -use anyhow::Error; +use anyhow::{Context, Error}; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, + event::{DisableMouseCapture, EnableMouseCapture, Event}, execute, terminal, tty::IsTty, }; @@ -39,9 +40,11 @@ use { #[cfg(windows)] type Signals = futures_util::stream::Empty<()>; +const LSP_DEADLINE: Duration = Duration::from_millis(16); + pub struct Application { compositor: Compositor, - editor: Editor, + pub editor: Editor, config: Arc<ArcSwap<Config>>, @@ -53,32 +56,39 @@ pub struct Application { signals: Signals, jobs: Jobs, lsp_progress: LspProgressMap, + last_render: Instant, +} + +#[cfg(feature = "integration")] +fn setup_integration_logging() { + let level = std::env::var("HELIX_LOG_LEVEL") + .map(|lvl| lvl.parse().unwrap()) + .unwrap_or(log::LevelFilter::Info); + + // Separate file config so we can include year, month and day in file logs + let _ = fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{} {} [{}] {}", + chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), + record.target(), + record.level(), + message + )) + }) + .level(level) + .chain(std::io::stdout()) + .apply(); } impl Application { - pub fn new(args: Args) -> Result<Self, Error> { + pub fn new(args: Args, config: Config) -> Result<Self, Error> { + #[cfg(feature = "integration")] + setup_integration_logging(); + use helix_view::editor::Action; let config_dir = helix_loader::config_dir(); - if !config_dir.exists() { - std::fs::create_dir_all(&config_dir).ok(); - } - - let config = match std::fs::read_to_string(config_dir.join("config.toml")) { - Ok(config) => toml::from_str(&config) - .map(crate::keymap::merge_keys) - .unwrap_or_else(|err| { - eprintln!("Bad config: {}", err); - eprintln!("Press <ENTER> to continue with default config"); - use std::io::Read; - // This waits for an enter press. - let _ = std::io::stdin().read(&mut []); - Config::default() - }), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(), - Err(err) => return Err(Error::new(err)), - }; - let theme_loader = std::sync::Arc::new(theme::Loader::new( &config_dir, &helix_loader::runtime_dir(), @@ -116,7 +126,7 @@ impl Application { }); let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); - let mut compositor = Compositor::new()?; + let mut compositor = Compositor::new().context("build compositor")?; let config = Arc::new(ArcSwap::from_pointee(config)); let mut editor = Editor::new( compositor.size(), @@ -135,26 +145,28 @@ impl Application { if args.load_tutor { let path = helix_loader::runtime_dir().join("tutor.txt"); - editor.open(path, Action::VerticalSplit)?; + editor.open(&path, Action::VerticalSplit)?; // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(editor).set_path(None)?; } else if !args.files.is_empty() { let first = &args.files[0].0; // we know it's not empty if first.is_dir() { - std::env::set_current_dir(&first)?; + std::env::set_current_dir(&first).context("set current dir")?; editor.new_file(Action::VerticalSplit); let picker = ui::file_picker(".".into(), &config.load().editor); compositor.push(Box::new(overlayed(picker))); } else { let nr_of_files = args.files.len(); - editor.open(first.to_path_buf(), Action::VerticalSplit)?; + editor.open(first, Action::VerticalSplit)?; for (file, pos) in args.files { if file.is_dir() { return Err(anyhow::anyhow!( "expected a path to file, found a directory. (to open a directory pass it as first argument)" )); } else { - let doc_id = editor.open(file, Action::Load)?; + let doc_id = editor + .open(&file, Action::Load) + .context(format!("open '{}'", file.to_string_lossy()))?; // with Action::Load all documents have the same view let view_id = editor.tree.focus; let doc = editor.document_mut(doc_id).unwrap(); @@ -168,7 +180,7 @@ impl Application { let (view, doc) = current!(editor); align_view(doc, view, Align::Center); } - } else if stdin().is_tty() { + } else if stdin().is_tty() || cfg!(feature = "integration") { editor.new_file(Action::VerticalSplit); } else if cfg!(target_os = "macos") { // On Linux and Windows, we allow the output of a command to be piped into the new buffer. @@ -186,7 +198,8 @@ impl Application { #[cfg(windows)] let signals = futures_util::stream::empty(); #[cfg(not(windows))] - let signals = Signals::new(&[signal::SIGTSTP, signal::SIGCONT])?; + let signals = + Signals::new(&[signal::SIGTSTP, signal::SIGCONT]).context("build signal handler")?; let app = Self { compositor, @@ -200,40 +213,57 @@ impl Application { signals, jobs: Jobs::new(), lsp_progress: LspProgressMap::new(), + last_render: Instant::now(), }; Ok(app) } fn render(&mut self) { + let compositor = &mut self.compositor; + let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - self.compositor.render(&mut cx); + compositor.render(&mut cx); } - pub async fn event_loop(&mut self) { - let mut reader = EventStream::new(); - let mut last_render = Instant::now(); - let deadline = Duration::from_secs(1) / 60; - + pub async fn event_loop<S>(&mut self, input_stream: &mut S) + where + S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, + { self.render(); + self.last_render = Instant::now(); loop { - if self.editor.should_close() { + if !self.event_loop_until_idle(input_stream).await { break; } + } + } + + pub async fn event_loop_until_idle<S>(&mut self, input_stream: &mut S) -> bool + where + S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, + { + #[cfg(feature = "integration")] + let mut idle_handled = false; + + loop { + if self.editor.should_close() { + return false; + } use futures_util::StreamExt; tokio::select! { biased; - event = reader.next() => { - self.handle_terminal_events(event) + Some(event) = input_stream.next() => { + self.handle_terminal_events(event); } Some(signal) = self.signals.next() => { self.handle_signals(signal).await; @@ -242,9 +272,10 @@ impl Application { self.handle_language_server_message(call, id).await; // limit render calls for fast language server messages let last = self.editor.language_servers.incoming.is_empty(); - if last || last_render.elapsed() > deadline { + + if last || self.last_render.elapsed() > LSP_DEADLINE { self.render(); - last_render = Instant::now(); + self.last_render = Instant::now(); } } Some(payload) = self.editor.debugger_events.next() => { @@ -269,8 +300,24 @@ impl Application { // idle timeout self.editor.clear_idle_timer(); self.handle_idle_timeout(); + + #[cfg(feature = "integration")] + { + idle_handled = true; + } } } + + // for integration tests only, reset the idle timer after every + // event to make a signal when test events are done processing + #[cfg(feature = "integration")] + { + if idle_handled { + return true; + } + + self.editor.reset_idle_timer(); + } } } @@ -370,7 +417,7 @@ impl Application { } } - pub fn handle_terminal_events(&mut self, event: Option<Result<Event, crossterm::ErrorKind>>) { + pub fn handle_terminal_events(&mut self, event: Result<Event, crossterm::ErrorKind>) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, @@ -378,15 +425,14 @@ impl Application { }; // Handle key events let should_redraw = match event { - Some(Ok(Event::Resize(width, height))) => { + Ok(Event::Resize(width, height)) => { self.compositor.resize(width, height); self.compositor .handle_event(Event::Resize(width, height), &mut cx) } - Some(Ok(event)) => self.compositor.handle_event(event, &mut cx), - Some(Err(x)) => panic!("{}", x), - None => panic!(), + Ok(event) => self.compositor.handle_event(event, &mut cx), + Err(x) => panic!("{}", x), }; if should_redraw && !self.editor.should_close() { @@ -740,7 +786,10 @@ impl Application { Ok(()) } - pub async fn run(&mut self) -> Result<i32, Error> { + pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error> + where + S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, + { self.claim_term().await?; // Exit the alternate screen and disable raw mode before panicking @@ -755,16 +804,20 @@ impl Application { hook(info); })); - self.event_loop().await; + self.event_loop(input_stream).await; + self.close().await?; + self.restore_term()?; + + Ok(self.editor.exit_code) + } - self.jobs.finish().await; + pub async fn close(&mut self) -> anyhow::Result<()> { + self.jobs.finish().await?; if self.editor.close_language_servers(None).await.is_err() { log::error!("Timed out waiting for language servers to shutdown"); }; - self.restore_term()?; - - Ok(self.editor.exit_code) + Ok(()) } } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 68c585b0..9239b49f 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1026,7 +1026,7 @@ fn goto_file_impl(cx: &mut Context, action: Action) { for sel in paths { let p = sel.trim(); if !p.is_empty() { - if let Err(e) = cx.editor.open(PathBuf::from(p), action) { + if let Err(e) = cx.editor.open(&PathBuf::from(p), action) { cx.editor.set_error(format!("Open file failed: {:?}", e)); } } @@ -1855,7 +1855,7 @@ fn global_search(cx: &mut Context) { } }, move |cx, (line_num, path), action| { - match cx.editor.open(path.into(), action) { + match cx.editor.open(path, action) { Ok(_) => {} Err(e) => { cx.editor.set_error(format!( @@ -2100,10 +2100,17 @@ fn insert_mode(cx: &mut Context) { let (view, doc) = current!(cx.editor); enter_insert_mode(doc); + log::trace!( + "entering insert mode with sel: {:?}, text: {:?}", + doc.selection(view.id), + doc.text().to_string() + ); + let selection = doc .selection(view.id) .clone() .transform(|range| Range::new(range.to(), range.from())); + doc.set_selection(view.id, selection); } @@ -2449,8 +2456,8 @@ fn normal_mode(cx: &mut Context) { graphemes::prev_grapheme_boundary(text, range.to()), ) }); - doc.set_selection(view.id, selection); + doc.set_selection(view.id, selection); doc.restore_cursor = false; } } diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 3ee3c54a..b8c5e5d1 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -61,7 +61,7 @@ fn jump_to_location( return; } }; - let _id = editor.open(path, action).expect("editor.open failed"); + let _id = editor.open(&path, action).expect("editor.open failed"); let (view, doc) = current!(editor); let definition_pos = location.range.start; // TODO: convert inside server @@ -114,7 +114,7 @@ fn sym_picker( return; } }; - if let Err(err) = cx.editor.open(path, action) { + if let Err(err) = cx.editor.open(&path, action) { let err = format!("failed to open document: {}: {}", uri, err); log::error!("{}", err); cx.editor.set_error(err); @@ -383,7 +383,7 @@ pub fn apply_workspace_edit( }; let current_view_id = view!(editor).id; - let doc_id = match editor.open(path, Action::Load) { + let doc_id = match editor.open(&path, Action::Load) { Ok(doc_id) => doc_id, Err(err) => { let err = format!("failed to open document: {}: {}", uri, err); diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 58256c7d..19c6a5dc 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -50,7 +50,7 @@ fn open( ensure!(!args.is_empty(), "wrong argument count"); for arg in args { let (path, pos) = args::parse_file(arg); - let _ = cx.editor.open(path, Action::Replace)?; + let _ = cx.editor.open(&path, Action::Replace)?; let (view, doc) = current!(cx.editor); let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); doc.set_selection(view.id, pos); @@ -233,6 +233,7 @@ fn write_impl( doc.detect_language(cx.editor.syn_loader.clone()); let _ = cx.editor.refresh_language_server(id); } + Ok(()) } @@ -422,6 +423,7 @@ fn write_quit( event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first(), false)?; + helix_lsp::block_on(cx.jobs.finish())?; quit(cx, &[], event) } @@ -819,7 +821,7 @@ fn vsplit( } else { for arg in args { cx.editor - .open(PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; + .open(&PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; } } @@ -838,7 +840,7 @@ fn hsplit( } else { for arg in args { cx.editor - .open(PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; + .open(&PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; } } @@ -923,7 +925,7 @@ fn tutor( _event: PromptEvent, ) -> anyhow::Result<()> { let path = helix_loader::runtime_dir().join("tutor.txt"); - cx.editor.open(path, Action::Replace)?; + cx.editor.open(&path, Action::Replace)?; // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(cx.editor).set_path(None)?; Ok(()) @@ -1150,7 +1152,7 @@ fn open_config( _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor - .open(helix_loader::config_file(), Action::Replace)?; + .open(&helix_loader::config_file(), Action::Replace)?; Ok(()) } @@ -1159,7 +1161,7 @@ fn open_log( _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - cx.editor.open(helix_loader::log_file(), Action::Replace)?; + cx.editor.open(&helix_loader::log_file(), Action::Replace)?; Ok(()) } diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index e3cec643..61a3bfaf 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -5,6 +5,9 @@ use helix_core::Position; use helix_view::graphics::{CursorKind, Rect}; use crossterm::event::Event; + +#[cfg(feature = "integration")] +use tui::backend::TestBackend; use tui::buffer::Buffer as Surface; pub type Callback = Box<dyn FnOnce(&mut Compositor, &mut Context)>; @@ -63,11 +66,21 @@ pub trait Component: Any + AnyComponent { } } -use anyhow::Error; +use anyhow::Context as AnyhowContext; +use tui::backend::Backend; + +#[cfg(not(feature = "integration"))] +use tui::backend::CrosstermBackend; + +#[cfg(not(feature = "integration"))] use std::io::stdout; -use tui::backend::{Backend, CrosstermBackend}; + +#[cfg(not(feature = "integration"))] type Terminal = tui::terminal::Terminal<CrosstermBackend<std::io::Stdout>>; +#[cfg(feature = "integration")] +type Terminal = tui::terminal::Terminal<TestBackend>; + pub struct Compositor { layers: Vec<Box<dyn Component>>, terminal: Terminal, @@ -76,9 +89,14 @@ pub struct Compositor { } impl Compositor { - pub fn new() -> Result<Self, Error> { + pub fn new() -> anyhow::Result<Self> { + #[cfg(not(feature = "integration"))] let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::new(backend)?; + + #[cfg(feature = "integration")] + let backend = TestBackend::new(120, 150); + + let terminal = Terminal::new(backend).context("build terminal")?; Ok(Self { layers: Vec::new(), terminal, diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index a6a77021..e5147992 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -2,7 +2,7 @@ use helix_view::Editor; use crate::compositor::Compositor; -use futures_util::future::{self, BoxFuture, Future, FutureExt}; +use futures_util::future::{BoxFuture, Future, FutureExt}; use futures_util::stream::{FuturesUnordered, StreamExt}; pub type Callback = Box<dyn FnOnce(&mut Editor, &mut Compositor) + Send>; @@ -93,8 +93,21 @@ impl Jobs { } /// Blocks until all the jobs that need to be waited on are done. - pub async fn finish(&mut self) { - let wait_futures = std::mem::take(&mut self.wait_futures); - wait_futures.for_each(|_| future::ready(())).await + pub async fn finish(&mut self) -> anyhow::Result<()> { + log::debug!("waiting on jobs..."); + let mut wait_futures = std::mem::take(&mut self.wait_futures); + while let (Some(job), tail) = wait_futures.into_future().await { + match job { + Ok(_) => { + wait_futures = tail; + } + Err(e) => { + self.wait_futures = tail; + return Err(e); + } + } + } + + Ok(()) } } diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 58a90131..7b26fb11 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -1,6 +1,8 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Error, Result}; +use crossterm::event::EventStream; use helix_term::application::Application; use helix_term::args::Args; +use helix_term::config::Config; use std::path::PathBuf; fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { @@ -110,10 +112,30 @@ FLAGS: setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; + let config_dir = helix_loader::config_dir(); + if !config_dir.exists() { + std::fs::create_dir_all(&config_dir).ok(); + } + + let config = match std::fs::read_to_string(config_dir.join("config.toml")) { + Ok(config) => toml::from_str(&config) + .map(helix_term::keymap::merge_keys) + .unwrap_or_else(|err| { + eprintln!("Bad config: {}", err); + eprintln!("Press <ENTER> to continue with default config"); + use std::io::Read; + // This waits for an enter press. + let _ = std::io::stdin().read(&mut []); + Config::default() + }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(), + Err(err) => return Err(Error::new(err)), + }; + // TODO: use the thread local executor to spawn the application task separately from the work pool - let mut app = Application::new(args).context("unable to create new application")?; + let mut app = Application::new(args, config).context("unable to create new application")?; - let exit_code = app.run().await?; + let exit_code = app.run(&mut EventStream::new()).await?; Ok(exit_code) } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 23d0dca0..76ddaf89 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -175,7 +175,7 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi path.strip_prefix(&root).unwrap_or(path).to_string_lossy() }, move |cx, path: &PathBuf, action| { - if let Err(e) = cx.editor.open(path.into(), action) { + if let Err(e) = cx.editor.open(path, action) { let err = if let Some(err) = e.source() { format!("{}", err) } else { diff --git a/helix-term/tests/integration.rs b/helix-term/tests/integration.rs new file mode 100644 index 00000000..11bc4e4c --- /dev/null +++ b/helix-term/tests/integration.rs @@ -0,0 +1,25 @@ +#[cfg(feature = "integration")] +mod test { + mod helpers; + + use std::path::PathBuf; + + use helix_core::{syntax::AutoPairConfig, Position, Selection}; + use helix_term::{args::Args, config::Config}; + + use indoc::indoc; + + use self::helpers::*; + + #[tokio::test] + async fn hello_world() -> anyhow::Result<()> { + test(("#[\n|]#", "ihello world<esc>", "hello world#[|\n]#")).await?; + Ok(()) + } + + mod auto_indent; + mod auto_pairs; + mod commands; + mod movement; + mod write; +} diff --git a/helix-term/tests/test/auto_indent.rs b/helix-term/tests/test/auto_indent.rs new file mode 100644 index 00000000..2f638893 --- /dev/null +++ b/helix-term/tests/test/auto_indent.rs @@ -0,0 +1,26 @@ +use super::*; + +#[tokio::test] +async fn auto_indent_c() -> anyhow::Result<()> { + test_with_config( + Args { + files: vec![(PathBuf::from("foo.c"), Position::default())], + ..Default::default() + }, + Config::default(), + // switches to append mode? + ( + helpers::platform_line("void foo() {#[|}]#").as_ref(), + "i<ret><esc>", + helpers::platform_line(indoc! {"\ + void foo() { + #[|\n]#\ + } + "}) + .as_ref(), + ), + ) + .await?; + + Ok(()) +} diff --git a/helix-term/tests/test/auto_pairs.rs b/helix-term/tests/test/auto_pairs.rs new file mode 100644 index 00000000..ec47a5b4 --- /dev/null +++ b/helix-term/tests/test/auto_pairs.rs @@ -0,0 +1,21 @@ +use super::*; + +#[tokio::test] +async fn auto_pairs_basic() -> anyhow::Result<()> { + test(("#[\n|]#", "i(<esc>", "(#[|)]#\n")).await?; + + test_with_config( + Args::default(), + Config { + editor: helix_view::editor::Config { + auto_pairs: AutoPairConfig::Enable(false), + ..Default::default() + }, + ..Default::default() + }, + ("#[\n|]#", "i(<esc>", "(#[|\n]#"), + ) + .await?; + + Ok(()) +} diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs new file mode 100644 index 00000000..0cd79bc7 --- /dev/null +++ b/helix-term/tests/test/commands.rs @@ -0,0 +1,93 @@ +use std::{ + io::{Read, Write}, + ops::RangeInclusive, +}; + +use helix_core::diagnostic::Severity; +use helix_term::application::Application; + +use super::*; + +#[tokio::test] +async fn test_write_quit_fail() -> anyhow::Result<()> { + let file = helpers::new_readonly_tempfile()?; + + test_key_sequence( + &mut helpers::app_with_file(file.path())?, + Some("ihello<esc>:wq<ret>"), + Some(&|app| { + assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1); + }), + false, + ) + .await?; + + Ok(()) +} + +#[tokio::test] +#[ignore] +async fn test_buffer_close_concurrent() -> anyhow::Result<()> { + test_key_sequences( + &mut Application::new(Args::default(), Config::default())?, + vec![ + ( + None, + Some(&|app| { + assert_eq!(1, app.editor.documents().count()); + assert!(!app.editor.is_err()); + }), + ), + ( + Some("ihello<esc>:new<ret>"), + Some(&|app| { + assert_eq!(2, app.editor.documents().count()); + assert!(!app.editor.is_err()); + }), + ), + ( + Some(":buffer<minus>close<ret>"), + Some(&|app| { + assert_eq!(1, app.editor.documents().count()); + assert!(!app.editor.is_err()); + }), + ), + ], + false, + ) + .await?; + + // verify if writes are queued up, it finishes them before closing the buffer + let mut file = tempfile::NamedTempFile::new()?; + let mut command = String::new(); + const RANGE: RangeInclusive<i32> = 1..=1000; + + for i in RANGE { + let cmd = format!("%c{}<esc>:w<ret>", i); + command.push_str(&cmd); + } + + command.push_str(":buffer<minus>close<ret>"); + + test_key_sequence( + &mut helpers::app_with_file(file.path())?, + Some(&command), + Some(&|app| { + assert!(!app.editor.is_err(), "error: {:?}", app.editor.get_status()); + + let doc = app.editor.document_by_path(file.path()); + assert!(doc.is_none(), "found doc: {:?}", doc); + }), + false, + ) + .await?; + + file.as_file_mut().flush()?; + file.as_file_mut().sync_all()?; + + let mut file_content = String::new(); + file.as_file_mut().read_to_string(&mut file_content)?; + assert_eq!(RANGE.end().to_string(), file_content); + + Ok(()) +} diff --git a/helix-term/tests/test/helpers.rs b/helix-term/tests/test/helpers.rs new file mode 100644 index 00000000..8f2501e6 --- /dev/null +++ b/helix-term/tests/test/helpers.rs @@ -0,0 +1,213 @@ +use std::{io::Write, path::PathBuf, time::Duration}; + +use anyhow::bail; +use crossterm::event::{Event, KeyEvent}; +use helix_core::{test, Selection, Transaction}; +use helix_term::{application::Application, args::Args, config::Config}; +use helix_view::{doc, input::parse_macro}; +use tempfile::NamedTempFile; +use tokio_stream::wrappers::UnboundedReceiverStream; + +#[derive(Clone, Debug)] +pub struct TestCase { + pub in_text: String, + pub in_selection: Selection, + pub in_keys: String, + pub out_text: String, + pub out_selection: Selection, +} + +impl<S: Into<String>> From<(S, S, S)> for TestCase { + fn from((input, keys, output): (S, S, S)) -> Self { + let (in_text, in_selection) = test::print(&input.into()); + let (out_text, out_selection) = test::print(&output.into()); + + TestCase { + in_text, + in_selection, + in_keys: keys.into(), + out_text, + out_selection, + } + } +} + +#[inline] +pub async fn test_key_sequence( + app: &mut Application, + in_keys: Option<&str>, + test_fn: Option<&dyn Fn(&Application)>, + should_exit: bool, +) -> anyhow::Result<()> { + test_key_sequences(app, vec![(in_keys, test_fn)], should_exit).await +} + +#[allow(clippy::type_complexity)] +pub async fn test_key_sequences( + app: &mut Application, + inputs: Vec<(Option<&str>, Option<&dyn Fn(&Application)>)>, + should_exit: bool, +) -> anyhow::Result<()> { + const TIMEOUT: Duration = Duration::from_millis(500); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let mut rx_stream = UnboundedReceiverStream::new(rx); + let num_inputs = inputs.len(); + + for (i, (in_keys, test_fn)) in inputs.into_iter().enumerate() { + if let Some(in_keys) = in_keys { + for key_event in parse_macro(in_keys)?.into_iter() { + tx.send(Ok(Event::Key(KeyEvent::from(key_event))))?; + } + } + + let app_exited = !app.event_loop_until_idle(&mut rx_stream).await; + + // the app should not exit from any test until the last one + if i < num_inputs - 1 && app_exited { + bail!("application exited before test function could run"); + } + + // verify if it exited on the last iteration if it should have and + // the inverse + if i == num_inputs - 1 && app_exited != should_exit { + bail!("expected app to exit: {} != {}", app_exited, should_exit); + } + + if let Some(test) = test_fn { + test(app); + }; + } + + if !should_exit { + for key_event in parse_macro("<esc>:q!<ret>")?.into_iter() { + tx.send(Ok(Event::Key(KeyEvent::from(key_event))))?; + } + + let event_loop = app.event_loop(&mut rx_stream); + tokio::time::timeout(TIMEOUT, event_loop).await?; + } + + app.close().await?; + + Ok(()) +} + +pub async fn test_key_sequence_with_input_text<T: Into<TestCase>>( + app: Option<Application>, + test_case: T, + test_fn: &dyn Fn(&Application), + should_exit: bool, +) -> anyhow::Result<()> { + let test_case = test_case.into(); + let mut app = match app { + Some(app) => app, + None => Application::new(Args::default(), Config::default())?, + }; + + let (view, doc) = helix_view::current!(app.editor); + let sel = doc.selection(view.id).clone(); + + // replace the initial text with the input text + doc.apply( + &Transaction::change_by_selection(doc.text(), &sel, |_| { + (0, doc.text().len_chars(), Some((&test_case.in_text).into())) + }) + .with_selection(test_case.in_selection.clone()), + view.id, + ); + + test_key_sequence( + &mut app, + Some(&test_case.in_keys), + Some(test_fn), + should_exit, + ) + .await +} + +/// Use this for very simple test cases where there is one input +/// document, selection, and sequence of key presses, and you just +/// want to verify the resulting document and selection. +pub async fn test_with_config<T: Into<TestCase>>( + args: Args, + config: Config, + test_case: T, +) -> anyhow::Result<()> { + let test_case = test_case.into(); + let app = Application::new(args, config)?; + + test_key_sequence_with_input_text( + Some(app), + test_case.clone(), + &|app| { + let doc = doc!(app.editor); + assert_eq!(&test_case.out_text, doc.text()); + + let mut selections: Vec<_> = doc.selections().values().cloned().collect(); + assert_eq!(1, selections.len()); + + let sel = selections.pop().unwrap(); + assert_eq!(test_case.out_selection, sel); + }, + false, + ) + .await +} + +pub async fn test<T: Into<TestCase>>(test_case: T) -> anyhow::Result<()> { + test_with_config(Args::default(), Config::default(), test_case).await +} + +pub fn temp_file_with_contents<S: AsRef<str>>( + content: S, +) -> anyhow::Result<tempfile::NamedTempFile> { + let mut temp_file = tempfile::NamedTempFile::new()?; + + temp_file + .as_file_mut() + .write_all(content.as_ref().as_bytes())?; + + temp_file.flush()?; + temp_file.as_file_mut().sync_all()?; + Ok(temp_file) +} + +/// Replaces all LF chars with the system's appropriate line feed +/// character, and if one doesn't exist already, appends the system's +/// appropriate line ending to the end of a string. +pub fn platform_line(input: &str) -> String { + let line_end = helix_core::DEFAULT_LINE_ENDING.as_str(); + + // we can assume that the source files in this code base will always + // be LF, so indoc strings will always insert LF + let mut output = input.replace('\n', line_end); + + if !output.ends_with(line_end) { + output.push_str(line_end); + } + + output +} + +/// Creates a new temporary file that is set to read only. Useful for +/// testing write failures. +pub fn new_readonly_tempfile() -> anyhow::Result<NamedTempFile> { + let mut file = tempfile::NamedTempFile::new()?; + let metadata = file.as_file().metadata()?; + let mut perms = metadata.permissions(); + perms.set_readonly(true); + file.as_file_mut().set_permissions(perms)?; + Ok(file) +} + +/// Creates a new Application with default config that opens the given file +/// path +pub fn app_with_file<P: Into<PathBuf>>(path: P) -> anyhow::Result<Application> { + Application::new( + Args { + files: vec![(path.into(), helix_core::Position::default())], + ..Default::default() + }, + Config::default(), + ) +} diff --git a/helix-term/tests/test/movement.rs b/helix-term/tests/test/movement.rs new file mode 100644 index 00000000..088685df --- /dev/null +++ b/helix-term/tests/test/movement.rs @@ -0,0 +1,87 @@ +use super::*; + +#[tokio::test] +async fn insert_mode_cursor_position() -> anyhow::Result<()> { + test(TestCase { + in_text: String::new(), + in_selection: Selection::single(0, 0), + in_keys: "i".into(), + out_text: String::new(), + out_selection: Selection::single(0, 0), + }) + .await?; + + test(("#[\n|]#", "i", "#[|\n]#")).await?; + test(("#[\n|]#", "i<esc>", "#[|\n]#")).await?; + test(("#[\n|]#", "i<esc>i", "#[|\n]#")).await?; + + Ok(()) +} + +/// Range direction is preserved when escaping insert mode to normal +#[tokio::test] +async fn insert_to_normal_mode_cursor_position() -> anyhow::Result<()> { + test(("#[f|]#oo\n", "vll<A-;><esc>", "#[|foo]#\n")).await?; + test(( + indoc! {"\ + #[f|]#oo + #(b|)#ar" + }, + "vll<A-;><esc>", + indoc! {"\ + #[|foo]# + #(|bar)#" + }, + )) + .await?; + + test(( + indoc! {"\ + #[f|]#oo + #(b|)#ar" + }, + "a", + indoc! {"\ + #[fo|]#o + #(ba|)#r" + }, + )) + .await?; + + test(( + indoc! {"\ + #[f|]#oo + #(b|)#ar" + }, + "a<esc>", + indoc! {"\ + #[f|]#oo + #(b|)#ar" + }, + )) + .await?; + + Ok(()) +} + +/// Ensure the very initial cursor in an opened file is the width of +/// the first grapheme +#[tokio::test] +async fn cursor_position_newly_opened_file() -> anyhow::Result<()> { + let test = |content: &str, expected_sel: Selection| -> anyhow::Result<()> { + let file = helpers::temp_file_with_contents(content)?; + let mut app = helpers::app_with_file(file.path())?; + + let (view, doc) = helix_view::current!(app.editor); + let sel = doc.selection(view.id).clone(); + assert_eq!(expected_sel, sel); + + Ok(()) + }; + + test("foo", Selection::single(0, 1))?; + test("๐จโ๐ฉโ๐งโ๐ฆ foo", Selection::single(0, 7))?; + test("", Selection::single(0, 0))?; + + Ok(()) +} diff --git a/helix-term/tests/test/write.rs b/helix-term/tests/test/write.rs new file mode 100644 index 00000000..8869d881 --- /dev/null +++ b/helix-term/tests/test/write.rs @@ -0,0 +1,169 @@ +use std::{ + io::{Read, Write}, + ops::RangeInclusive, +}; + +use helix_core::diagnostic::Severity; +use helix_term::application::Application; +use helix_view::doc; + +use super::*; + +#[tokio::test] +async fn test_write() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + + test_key_sequence( + &mut helpers::app_with_file(file.path())?, + Some("ithe gostak distims the doshes<ret><esc>:w<ret>"), + None, + false, + ) + .await?; + + file.as_file_mut().flush()?; + file.as_file_mut().sync_all()?; + + let mut file_content = String::new(); + file.as_file_mut().read_to_string(&mut file_content)?; + + assert_eq!( + helpers::platform_line("the gostak distims the doshes"), + file_content + ); + + Ok(()) +} + +#[tokio::test] +async fn test_write_quit() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + + test_key_sequence( + &mut helpers::app_with_file(file.path())?, + Some("ithe gostak distims the doshes<ret><esc>:wq<ret>"), + None, + true, + ) + .await?; + + file.as_file_mut().flush()?; + file.as_file_mut().sync_all()?; + + let mut file_content = String::new(); + file.as_file_mut().read_to_string(&mut file_content)?; + + assert_eq!( + helpers::platform_line("the gostak distims the doshes"), + file_content + ); + + Ok(()) +} + +#[tokio::test] +#[ignore] +async fn test_write_concurrent() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + let mut command = String::new(); + const RANGE: RangeInclusive<i32> = 1..=5000; + + for i in RANGE { + let cmd = format!("%c{}<esc>:w<ret>", i); + command.push_str(&cmd); + } + + test_key_sequence( + &mut helpers::app_with_file(file.path())?, + Some(&command), + None, + false, + ) + .await?; + + file.as_file_mut().flush()?; + file.as_file_mut().sync_all()?; + + let mut file_content = String::new(); + file.as_file_mut().read_to_string(&mut file_content)?; + assert_eq!(RANGE.end().to_string(), file_content); + + Ok(()) +} + +#[tokio::test] +#[ignore] +async fn test_write_fail_mod_flag() -> anyhow::Result<()> { + let file = helpers::new_readonly_tempfile()?; + + test_key_sequences( + &mut helpers::app_with_file(file.path())?, + vec![ + ( + None, + Some(&|app| { + let doc = doc!(app.editor); + assert!(!doc.is_modified()); + }), + ), + ( + Some("ihello<esc>"), + Some(&|app| { + let doc = doc!(app.editor); + assert!(doc.is_modified()); + }), + ), + ( + Some(":w<ret>"), + Some(&|app| { + assert_eq!(&Severity::Error, app.editor.get_status().unwrap().1); + + let doc = doc!(app.editor); + assert!(doc.is_modified()); + }), + ), + ], + false, + ) + .await?; + + Ok(()) +} + +#[tokio::test] +#[ignore] +async fn test_write_fail_new_path() -> anyhow::Result<()> { + let file = helpers::new_readonly_tempfile()?; + + test_key_sequences( + &mut Application::new(Args::default(), Config::default())?, + vec![ + ( + None, + Some(&|app| { + let doc = doc!(app.editor); + assert_ne!( + Some(&Severity::Error), + app.editor.get_status().map(|status| status.1) + ); + assert_eq!(None, doc.path()); + }), + ), + ( + Some(&format!(":w {}<ret>", file.path().to_string_lossy())), + Some(&|app| { + let doc = doc!(app.editor); + assert_eq!( + Some(&Severity::Error), + app.editor.get_status().map(|status| status.1) + ); + assert_eq!(None, doc.path()); + }), + ), + ], + false, + ) + .await?; + + Ok(()) +} |