aboutsummaryrefslogtreecommitdiff
path: root/helix-term
diff options
context:
space:
mode:
Diffstat (limited to 'helix-term')
-rw-r--r--helix-term/Cargo.toml6
-rw-r--r--helix-term/src/application.rs157
-rw-r--r--helix-term/src/commands.rs13
-rw-r--r--helix-term/src/commands/lsp.rs6
-rw-r--r--helix-term/src/commands/typed.rs14
-rw-r--r--helix-term/src/compositor.rs26
-rw-r--r--helix-term/src/job.rs21
-rw-r--r--helix-term/src/main.rs28
-rw-r--r--helix-term/src/ui/mod.rs2
-rw-r--r--helix-term/tests/integration.rs25
-rw-r--r--helix-term/tests/test/auto_indent.rs26
-rw-r--r--helix-term/tests/test/auto_pairs.rs21
-rw-r--r--helix-term/tests/test/commands.rs93
-rw-r--r--helix-term/tests/test/helpers.rs213
-rw-r--r--helix-term/tests/test/movement.rs87
-rw-r--r--helix-term/tests/test/write.rs169
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 d7937ff5..897712f0 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));
}
}
@@ -1853,7 +1853,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!(
@@ -2098,10 +2098,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);
}
@@ -2448,8 +2455,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 b6bea8d6..ff61ee63 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);
@@ -385,7 +385,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 ae3e63af..5b48ca48 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(())
+}