on master help sign in
Add `insert-final-newline` config option (#8157)

Co-authored-by: Xalfer <64538944+Xalfer@users.noreply.github.com>

commit7090555daba6bce37dc6cc900387015a10a1e791 authorEm Zhan <zqianem@gmail.com> dateMon 11 Sep 2023, 19:06 (3 years ago) parentef23847
6 files changed+173−34
book/src/configuration.md+1 helix-term/src/commands/typed.rs+43−27 helix-term/tests/test/commands/write.rs+122−3 helix-term/tests/test/helpers.rs+1−1 helix-term/tests/test/splits.rs+3−3 helix-view/src/editor.rs+3
book/src/configuration.md+1
@@ -64,6 +64,7 @@ Its settings will be merged with the configuration directory `config.toml` and t
6464| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
6565| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
6666| `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` |
67+| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` |
6768
6869### `[editor.statusline]` Section
6970
helix-term/src/commands/typed.rs+43−27
@@ -6,7 +6,7 @@ use crate::job::Job;
66use super::*;
77
88use helix_core::fuzzy::fuzzy_match;
9−use helix_core::{encoding, shellwords::Shellwords};
9+use helix_core::{encoding, line_ending, shellwords::Shellwords};
1010use helix_view::document::DEFAULT_LANGUAGE_NAME;
1111use helix_view::editor::{Action, CloseError, ConfigEvent};
1212use serde_json::Value;
@@ -330,12 +330,16 @@ fn write_impl(
330330 path: Option<&Cow<str>>,
331331 force: bool,
332332) -> anyhow::Result<()> {
333− let editor_auto_fmt = cx.editor.config().auto_format;
333+ let config = cx.editor.config();
334334 let jobs = &mut cx.jobs;
335335 let (view, doc) = current!(cx.editor);
336336 let path = path.map(AsRef::as_ref);
337337
338− let fmt = if editor_auto_fmt {
338+ if config.insert_final_newline {
339+ insert_final_newline(doc, view);
340+ }
341+
342+ let fmt = if config.auto_format {
339343 doc.auto_format().map(|fmt| {
340344 let callback = make_format_callback(
341345 doc.id(),
@@ -359,6 +363,16 @@ fn write_impl(
359363 Ok(())
360364}
361365
366+fn insert_final_newline(doc: &mut Document, view: &mut View) {
367+ let text = doc.text();
368+ if line_ending::get_line_ending(&text.slice(..)).is_none() {
369+ let eof = Selection::point(text.len_chars());
370+ let insert = Transaction::insert(text, &eof, doc.line_ending.as_str().into());
371+ doc.apply(&insert, view.id);
372+ doc.append_changes_to_history(view);
373+ }
374+}
375+
362376fn write(
363377 cx: &mut compositor::Context,
364378 args: &[Cow<str>],
@@ -658,11 +672,10 @@ pub fn write_all_impl(
658672 write_scratch: bool,
659673) -> anyhow::Result<()> {
660674 let mut errors: Vec<&'static str> = Vec::new();
661− let auto_format = cx.editor.config().auto_format;
675+ let config = cx.editor.config();
662676 let jobs = &mut cx.jobs;
663677 let current_view = view!(cx.editor);
664678
665− // save all documents
666679 let saves: Vec<_> = cx
667680 .editor
668681 .documents
@@ -693,32 +706,35 @@ pub fn write_all_impl(
693706 current_view.id
694707 };
695708
696− let fmt = if auto_format {
697− doc.auto_format().map(|fmt| {
698− let callback = make_format_callback(
699− doc.id(),
700− doc.version(),
701− target_view,
702− fmt,
703− Some((None, force)),
704− );
705− jobs.add(Job::with_callback(callback).wait_before_exiting());
706− })
707− } else {
708− None
709− };
709+ Some((doc.id(), target_view))
710+ })
711+ .collect();
710712
711− if fmt.is_none() {
712− return Some(doc.id());
713− }
713+ for (doc_id, target_view) in saves {
714+ let doc = doc_mut!(cx.editor, &doc_id);
714715
716+ if config.insert_final_newline {
717+ insert_final_newline(doc, view_mut!(cx.editor, target_view));
718+ }
719+
720+ let fmt = if config.auto_format {
721+ doc.auto_format().map(|fmt| {
722+ let callback = make_format_callback(
723+ doc_id,
724+ doc.version(),
725+ target_view,
726+ fmt,
727+ Some((None, force)),
728+ );
729+ jobs.add(Job::with_callback(callback).wait_before_exiting());
730+ })
731+ } else {
715732 None
716− })
717− .collect();
733+ };
718734
719− // manually call save for the rest of docs that don't have a formatter
720− for id in saves {
721− cx.editor.save::<PathBuf>(id, None, force)?;
735+ if fmt.is_none() {
736+ cx.editor.save::<PathBuf>(doc_id, None, force)?;
737+ }
722738 }
723739
724740 if !errors.is_empty() && !force {
helix-term/tests/test/commands/write.rs+122−3
@@ -93,7 +93,7 @@ async fn test_buffer_close_concurrent() -> anyhow::Result<()> {
9393 )
9494 .await?;
9595
96− helpers::assert_file_has_content(file.as_file_mut(), &RANGE.end().to_string())?;
96+ helpers::assert_file_has_content(file.as_file_mut(), &platform_line(&RANGE.end().to_string()))?;
9797
9898 Ok(())
9999}
@@ -209,7 +209,7 @@ async fn test_write_concurrent() -> anyhow::Result<()> {
209209
210210 let mut file_content = String::new();
211211 file.as_file_mut().read_to_string(&mut file_content)?;
212− assert_eq!(RANGE.end().to_string(), file_content);
212+ assert_eq!(platform_line(&RANGE.end().to_string()), file_content);
213213
214214 Ok(())
215215}
@@ -424,13 +424,132 @@ async fn test_write_utf_bom_file() -> anyhow::Result<()> {
424424 Ok(())
425425}
426426
427+#[tokio::test(flavor = "multi_thread")]
428+async fn test_write_insert_final_newline_added_if_missing() -> anyhow::Result<()> {
429+ let mut file = tempfile::NamedTempFile::new()?;
430+ let mut app = helpers::AppBuilder::new()
431+ .with_file(file.path(), None)
432+ .with_input_text("#[h|]#ave you tried chamomile tea?")
433+ .build()?;
434+
435+ test_key_sequence(&mut app, Some(":w<ret>"), None, false).await?;
436+
437+ helpers::assert_file_has_content(
438+ file.as_file_mut(),
439+ &helpers::platform_line("have you tried chamomile tea?\n"),
440+ )?;
441+
442+ Ok(())
443+}
444+
445+#[tokio::test(flavor = "multi_thread")]
446+async fn test_write_insert_final_newline_unchanged_if_not_missing() -> anyhow::Result<()> {
447+ let mut file = tempfile::NamedTempFile::new()?;
448+ let mut app = helpers::AppBuilder::new()
449+ .with_file(file.path(), None)
450+ .with_input_text(&helpers::platform_line("#[t|]#en minutes, please\n"))
451+ .build()?;
452+
453+ test_key_sequence(&mut app, Some(":w<ret>"), None, false).await?;
454+
455+ helpers::assert_file_has_content(
456+ file.as_file_mut(),
457+ &helpers::platform_line("ten minutes, please\n"),
458+ )?;
459+
460+ Ok(())
461+}
462+
463+#[tokio::test(flavor = "multi_thread")]
464+async fn test_write_insert_final_newline_unchanged_if_missing_and_false() -> anyhow::Result<()> {
465+ let mut file = tempfile::NamedTempFile::new()?;
466+ let mut app = helpers::AppBuilder::new()
467+ .with_config(Config {
468+ editor: helix_view::editor::Config {
469+ insert_final_newline: false,
470+ ..Default::default()
471+ },
472+ ..Default::default()
473+ })
474+ .with_file(file.path(), None)
475+ .with_input_text("#[t|]#he quiet rain continued through the night")
476+ .build()?;
477+
478+ test_key_sequence(&mut app, Some(":w<ret>"), None, false).await?;
479+
480+ helpers::assert_file_has_content(
481+ file.as_file_mut(),
482+ "the quiet rain continued through the night",
483+ )?;
484+
485+ Ok(())
486+}
487+
488+#[tokio::test(flavor = "multi_thread")]
489+async fn test_write_all_insert_final_newline_add_if_missing_and_modified() -> anyhow::Result<()> {
490+ let mut file1 = tempfile::NamedTempFile::new()?;
491+ let mut file2 = tempfile::NamedTempFile::new()?;
492+ let mut app = helpers::AppBuilder::new()
493+ .with_file(file1.path(), None)
494+ .with_input_text("#[w|]#e don't serve time travelers here")
495+ .build()?;
496+
497+ test_key_sequence(
498+ &mut app,
499+ Some(&format!(
500+ ":o {}<ret>ia time traveler walks into a bar<esc>:wa<ret>",
501+ file2.path().to_string_lossy()
502+ )),
503+ None,
504+ false,
505+ )
506+ .await?;
507+
508+ helpers::assert_file_has_content(
509+ file1.as_file_mut(),
510+ &helpers::platform_line("we don't serve time travelers here\n"),
511+ )?;
512+
513+ helpers::assert_file_has_content(
514+ file2.as_file_mut(),
515+ &helpers::platform_line("a time traveler walks into a bar\n"),
516+ )?;
517+
518+ Ok(())
519+}
520+
521+#[tokio::test(flavor = "multi_thread")]
522+async fn test_write_all_insert_final_newline_do_not_add_if_unmodified() -> anyhow::Result<()> {
523+ let mut file = tempfile::NamedTempFile::new()?;
524+ let mut app = helpers::AppBuilder::new()
525+ .with_file(file.path(), None)
526+ .build()?;
527+
528+ file.write_all(b"i lost on Jeopardy!")?;
529+ file.rewind()?;
530+
531+ test_key_sequence(&mut app, Some(":wa<ret>"), None, false).await?;
532+
533+ helpers::assert_file_has_content(file.as_file_mut(), "i lost on Jeopardy!")?;
534+
535+ Ok(())
536+}
537+
427538async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> {
428539 let mut file = tempfile::NamedTempFile::new()?;
429540
430541 file.as_file_mut().write_all(&file_content)?;
431542
432543 helpers::test_key_sequence(
433− &mut helpers::AppBuilder::new().build()?,
544+ &mut helpers::AppBuilder::new()
545+ .with_config(Config {
546+ editor: helix_view::editor::Config {
547+ insert_final_newline: false,
548+ ..Default::default()
549+ },
550+ ..Default::default()
551+ })
552+ .build()?,
434553 Some(&format!(":o {}<ret>:x<ret>", file.path().to_string_lossy())),
435554 None,
436555 true,
helix-term/tests/test/helpers.rs+1−1
@@ -350,7 +350,7 @@ pub fn assert_file_has_content(file: &mut File, content: &str) -> anyhow::Result
350350
351351 let mut file_content = String::new();
352352 file.read_to_string(&mut file_content)?;
353− assert_eq!(content, file_content);
353+ assert_eq!(file_content, content);
354354
355355 Ok(())
356356}
helix-term/tests/test/splits.rs+3−3
@@ -62,9 +62,9 @@ async fn test_split_write_quit_all() -> anyhow::Result<()> {
6262 )
6363 .await?;
6464
65− helpers::assert_file_has_content(file1.as_file_mut(), "hello1")?;
66− helpers::assert_file_has_content(file2.as_file_mut(), "hello2")?;
67− helpers::assert_file_has_content(file3.as_file_mut(), "hello3")?;
65+ helpers::assert_file_has_content(file1.as_file_mut(), &platform_line("hello1"))?;
66+ helpers::assert_file_has_content(file2.as_file_mut(), &platform_line("hello2"))?;
67+ helpers::assert_file_has_content(file3.as_file_mut(), &platform_line("hello3"))?;
6868
6969 Ok(())
7070}
helix-view/src/editor.rs+3
@@ -287,6 +287,8 @@ pub struct Config {
287287 pub workspace_lsp_roots: Vec<PathBuf>,
288288 /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`.
289289 pub default_line_ending: LineEndingConfig,
290+ /// Whether to automatically insert a trailing line-ending on write if missing. Defaults to `true`.
291+ pub insert_final_newline: bool,
290292 /// Enables smart tab
291293 pub smart_tab: Option<SmartTabConfig>,
292294}
@@ -842,6 +844,7 @@ impl Default for Config {
842844 completion_replace: false,
843845 workspace_lsp_roots: Vec::new(),
844846 default_line_ending: LineEndingConfig::default(),
847+ insert_final_newline: true,
845848 smart_tab: Some(SmartTabConfig::default()),
846849 }
847850 }