on master help sign in
Add `insert-final-newline` config option (#8157)
7090555Em Zhan
book/src/configuration.md+1
@@ -64,6 +64,7 @@ Its settings will be merged with the configuration directory `config.toml` and t
64| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
64| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
65| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
65| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
66| `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` |
66| `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` |
67
68
68### `[editor.statusline]` Section
69### `[editor.statusline]` Section
69
70
helix-term/src/commands/typed.rs+43−27
@@ -6,7 +6,7 @@ use crate::job::Job;
6use super::*;
6use super::*;
7
7
8use helix_core::fuzzy::fuzzy_match;
8use helix_core::fuzzy::fuzzy_match;
9−use helix_core::{encoding, shellwords::Shellwords};
9+use helix_core::{encoding, line_ending, shellwords::Shellwords};
10use helix_view::document::DEFAULT_LANGUAGE_NAME;
10use helix_view::document::DEFAULT_LANGUAGE_NAME;
11use helix_view::editor::{Action, CloseError, ConfigEvent};
11use helix_view::editor::{Action, CloseError, ConfigEvent};
12use serde_json::Value;
12use serde_json::Value;
@@ -330,12 +330,16 @@ fn write_impl(
330 path: Option<&Cow<str>>,
330 path: Option<&Cow<str>>,
331 force: bool,
331 force: bool,
332) -> anyhow::Result<()> {
332) -> anyhow::Result<()> {
333− let editor_auto_fmt = cx.editor.config().auto_format;
333+ let config = cx.editor.config();
334 let jobs = &mut cx.jobs;
334 let jobs = &mut cx.jobs;
335 let (view, doc) = current!(cx.editor);
335 let (view, doc) = current!(cx.editor);
336 let path = path.map(AsRef::as_ref);
336 let path = path.map(AsRef::as_ref);
337
337
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 {
339 doc.auto_format().map(|fmt| {
343 doc.auto_format().map(|fmt| {
340 let callback = make_format_callback(
344 let callback = make_format_callback(
341 doc.id(),
345 doc.id(),
@@ -359,6 +363,16 @@ fn write_impl(
359 Ok(())
363 Ok(())
360}
364}
361
365
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+
362fn write(
376fn write(
363 cx: &mut compositor::Context,
377 cx: &mut compositor::Context,
364 args: &[Cow<str>],
378 args: &[Cow<str>],
@@ -658,11 +672,10 @@ pub fn write_all_impl(
658 write_scratch: bool,
672 write_scratch: bool,
659) -> anyhow::Result<()> {
673) -> anyhow::Result<()> {
660 let mut errors: Vec<&'static str> = Vec::new();
674 let mut errors: Vec<&'static str> = Vec::new();
661− let auto_format = cx.editor.config().auto_format;
675+ let config = cx.editor.config();
662 let jobs = &mut cx.jobs;
676 let jobs = &mut cx.jobs;
663 let current_view = view!(cx.editor);
677 let current_view = view!(cx.editor);
664
678
665− // save all documents
666 let saves: Vec<_> = cx
679 let saves: Vec<_> = cx
667 .editor
680 .editor
668 .documents
681 .documents
@@ -693,32 +706,35 @@ pub fn write_all_impl(
693 current_view.id
706 current_view.id
694 };
707 };
695
708
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,
709+ Some((doc.id(), target_view))
702− fmt,
703− Some((None, force)),
704− );
710+ })
705− jobs.add(Job::with_callback(callback).wait_before_exiting());
711+ .collect();
706− })
707− } else {
708− None
709− };
710
712
711− if fmt.is_none() {
713+ for (doc_id, target_view) in saves {
712− return Some(doc.id());
714+ let doc = doc_mut!(cx.editor, &doc_id);
713− }
714
715
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 {
715 None
732 None
716− })
733+ };
717− .collect();
718
734
719− // manually call save for the rest of docs that don't have a formatter
735+ if fmt.is_none() {
720− for id in saves {
721− cx.editor.save::<PathBuf>(id, None, force)?;
736+ cx.editor.save::<PathBuf>(doc_id, None, force)?;
737+ }
722 }
738 }
723
739
724 if !errors.is_empty() && !force {
740 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<()> {
93 )
93 )
94 .await?;
94 .await?;
95
95
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()))?;
97
97
98 Ok(())
98 Ok(())
99}
99}
@@ -209,7 +209,7 @@ async fn test_write_concurrent() -> anyhow::Result<()> {
209
209
210 let mut file_content = String::new();
210 let mut file_content = String::new();
211 file.as_file_mut().read_to_string(&mut file_content)?;
211 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);
213
213
214 Ok(())
214 Ok(())
215}
215}
@@ -424,13 +424,132 @@ async fn test_write_utf_bom_file() -> anyhow::Result<()> {
424 Ok(())
424 Ok(())
425}
425}
426
426
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+
427async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> {
538async fn edit_file_with_content(file_content: &[u8]) -> anyhow::Result<()> {
428 let mut file = tempfile::NamedTempFile::new()?;
539 let mut file = tempfile::NamedTempFile::new()?;
429
540
430 file.as_file_mut().write_all(&file_content)?;
541 file.as_file_mut().write_all(&file_content)?;
431
542
432 helpers::test_key_sequence(
543 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()?,
434 Some(&format!(":o {}<ret>:x<ret>", file.path().to_string_lossy())),
553 Some(&format!(":o {}<ret>:x<ret>", file.path().to_string_lossy())),
435 None,
554 None,
436 true,
555 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
350
350
351 let mut file_content = String::new();
351 let mut file_content = String::new();
352 file.read_to_string(&mut file_content)?;
352 file.read_to_string(&mut file_content)?;
353− assert_eq!(content, file_content);
353+ assert_eq!(file_content, content);
354
354
355 Ok(())
355 Ok(())
356}
356}
helix-term/tests/test/splits.rs+3−3
@@ -62,9 +62,9 @@ async fn test_split_write_quit_all() -> anyhow::Result<()> {
62 )
62 )
63 .await?;
63 .await?;
64
64
65− helpers::assert_file_has_content(file1.as_file_mut(), "hello1")?;
65+ helpers::assert_file_has_content(file1.as_file_mut(), &platform_line("hello1"))?;
66− helpers::assert_file_has_content(file2.as_file_mut(), "hello2")?;
66+ helpers::assert_file_has_content(file2.as_file_mut(), &platform_line("hello2"))?;
67− helpers::assert_file_has_content(file3.as_file_mut(), "hello3")?;
67+ helpers::assert_file_has_content(file3.as_file_mut(), &platform_line("hello3"))?;
68
68
69 Ok(())
69 Ok(())
70}
70}
helix-view/src/editor.rs+3
@@ -287,6 +287,8 @@ pub struct Config {
287 pub workspace_lsp_roots: Vec<PathBuf>,
287 pub workspace_lsp_roots: Vec<PathBuf>,
288 /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`.
288 /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`.
289 pub default_line_ending: LineEndingConfig,
289 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,
290 /// Enables smart tab
292 /// Enables smart tab
291 pub smart_tab: Option<SmartTabConfig>,
293 pub smart_tab: Option<SmartTabConfig>,
292}
294}
@@ -842,6 +844,7 @@ impl Default for Config {
842 completion_replace: false,
844 completion_replace: false,
843 workspace_lsp_roots: Vec::new(),
845 workspace_lsp_roots: Vec::new(),
844 default_line_ending: LineEndingConfig::default(),
846 default_line_ending: LineEndingConfig::default(),
847+ insert_final_newline: true,
845 smart_tab: Some(SmartTabConfig::default()),
848 smart_tab: Some(SmartTabConfig::default()),
846 }
849 }
847 }
850 }