diff --git a/book/src/configuration.md b/book/src/configuration.md index eb2cf473cffd..770993cae729 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -64,6 +64,7 @@ Its settings will be merged with the configuration directory `config.toml` and t | `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` | | `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` | | `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` | +| `insert-final-newline` | Whether to automatically insert a final newline on write if missing | `false` | ### `[editor.statusline]` Section diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 0e1d94317708..419be5db5401 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -6,7 +6,7 @@ use crate::job::Job; use super::*; use helix_core::fuzzy::fuzzy_match; -use helix_core::{encoding, shellwords::Shellwords}; +use helix_core::{encoding, line_ending, shellwords::Shellwords}; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::editor::{Action, CloseError, ConfigEvent}; use serde_json::Value; @@ -330,12 +330,12 @@ fn write_impl( path: Option<&Cow>, force: bool, ) -> anyhow::Result<()> { - let editor_auto_fmt = cx.editor.config().auto_format; + let config = cx.editor.config(); let jobs = &mut cx.jobs; let (view, doc) = current!(cx.editor); let path = path.map(AsRef::as_ref); - let fmt = if editor_auto_fmt { + let fmt = if config.auto_format { doc.auto_format().map(|fmt| { let callback = make_format_callback( doc.id(), @@ -352,6 +352,9 @@ fn write_impl( }; if fmt.is_none() { + if config.insert_final_newline { + insert_final_newline(doc, view); + } let id = doc.id(); cx.editor.save(id, path, force)?; } @@ -359,6 +362,16 @@ fn write_impl( Ok(()) } +fn insert_final_newline(doc: &mut Document, view: &mut View) { + let text = doc.text(); + if line_ending::get_line_ending(&text.slice(..)).is_none() { + let eof = Selection::point(text.len_chars()); + let insert = Transaction::insert(text, &eof, doc.line_ending.as_str().into()); + doc.apply(&insert, view.id); + doc.append_changes_to_history(view); + } +} + fn write( cx: &mut compositor::Context, args: &[Cow], @@ -658,7 +671,7 @@ pub fn write_all_impl( write_scratch: bool, ) -> anyhow::Result<()> { let mut errors: Vec<&'static str> = Vec::new(); - let auto_format = cx.editor.config().auto_format; + let config = cx.editor.config(); let jobs = &mut cx.jobs; let current_view = view!(cx.editor); @@ -693,7 +706,7 @@ pub fn write_all_impl( current_view.id }; - let fmt = if auto_format { + let fmt = if config.auto_format { doc.auto_format().map(|fmt| { let callback = make_format_callback( doc.id(), @@ -709,7 +722,7 @@ pub fn write_all_impl( }; if fmt.is_none() { - return Some(doc.id()); + return Some((doc.id(), target_view)); } None @@ -717,8 +730,11 @@ pub fn write_all_impl( .collect(); // manually call save for the rest of docs that don't have a formatter - for id in saves { - cx.editor.save::(id, None, force)?; + for (doc_id, view_id) in saves { + if config.insert_final_newline { + insert_final_newline(doc_mut!(cx.editor, &doc_id), view_mut!(cx.editor, view_id)); + } + cx.editor.save::(doc_id, None, force)?; } if !errors.is_empty() && !force { diff --git a/helix-term/tests/test/commands/write.rs b/helix-term/tests/test/commands/write.rs index f33c8aaf6fd9..57cd16fb335a 100644 --- a/helix-term/tests/test/commands/write.rs +++ b/helix-term/tests/test/commands/write.rs @@ -424,6 +424,120 @@ async fn test_write_utf_bom_file() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_write_insert_final_newline_added_if_missing() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + let mut app = helpers::AppBuilder::new() + .with_config(Config { + editor: helix_view::editor::Config { + insert_final_newline: true, + ..Default::default() + }, + ..Default::default() + }) + .with_file(file.path(), None) + .with_input_text("#[h|]#ave you tried chamomile tea?") + .build()?; + + test_key_sequence(&mut app, Some(":w"), None, false).await?; + + helpers::assert_file_has_content( + file.as_file_mut(), + &helpers::platform_line("have you tried chamomile tea?\n"), + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_write_insert_final_newline_unchanged_if_not_missing() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + let mut app = helpers::AppBuilder::new() + .with_config(Config { + editor: helix_view::editor::Config { + insert_final_newline: true, + ..Default::default() + }, + ..Default::default() + }) + .with_file(file.path(), None) + .with_input_text(&helpers::platform_line("#[t|]#en minutes, please\n")) + .build()?; + + test_key_sequence(&mut app, Some(":w"), None, false).await?; + + helpers::assert_file_has_content( + file.as_file_mut(), + &helpers::platform_line("ten minutes, please\n"), + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_write_all_insert_final_newline_add_if_missing_and_modified() -> anyhow::Result<()> { + let mut file1 = tempfile::NamedTempFile::new()?; + let mut file2 = tempfile::NamedTempFile::new()?; + let mut app = helpers::AppBuilder::new() + .with_config(Config { + editor: helix_view::editor::Config { + insert_final_newline: true, + ..Default::default() + }, + ..Default::default() + }) + .with_file(file1.path(), None) + .with_input_text("#[w|]#e don't serve time travelers here") + .build()?; + + test_key_sequence( + &mut app, + Some(&format!( + ":o {}ia time traveler walks into a bar:wa", + file2.path().to_string_lossy() + )), + None, + false, + ) + .await?; + + helpers::assert_file_has_content( + file1.as_file_mut(), + &helpers::platform_line("we don't serve time travelers here\n"), + )?; + + helpers::assert_file_has_content( + file2.as_file_mut(), + &helpers::platform_line("a time traveler walks into a bar\n"), + )?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_write_all_insert_final_newline_do_not_add_if_unmodified() -> anyhow::Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + let mut app = helpers::AppBuilder::new() + .with_config(Config { + editor: helix_view::editor::Config { + insert_final_newline: true, + ..Default::default() + }, + ..Default::default() + }) + .with_file(file.path(), None) + .build()?; + + file.write_all(b"i lost on Jeopardy!")?; + file.rewind()?; + + test_key_sequence(&mut app, Some(":wa"), None, false).await?; + + helpers::assert_file_has_content(file.as_file_mut(), "i lost on Jeopardy!")?; + + Ok(()) +} + async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> { let mut file = tempfile::NamedTempFile::new()?; diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 86f35e0db7bd..b46e3fda583d 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -287,6 +287,8 @@ pub struct Config { pub workspace_lsp_roots: Vec, /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`. pub default_line_ending: LineEndingConfig, + /// Whether to automatically insert a final newline on write if missing. Defaults to `false`. + pub insert_final_newline: bool, /// Enables smart tab pub smart_tab: Option, } @@ -842,6 +844,7 @@ impl Default for Config { completion_replace: false, workspace_lsp_roots: Vec::new(), default_line_ending: LineEndingConfig::default(), + insert_final_newline: false, smart_tab: Some(SmartTabConfig::default()), } }