diff --git a/book/src/editor.md b/book/src/editor.md
index 2baa907f..79f7284c 100644
--- a/book/src/editor.md
+++ b/book/src/editor.md
@@ -53,6 +53,8 @@
 | `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 trailing line-ending on write if missing | `true` |
+| `trim-final-newlines` | Whether to automatically remove line-endings after the final one on write | `false` |
+| `trim-trailing-whitespace` | Whether to automatically remove whitespace preceding line endings on write | `false` |
 | `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` |
 | `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid`
 | `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"`
diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index 1d57930c..07374f77 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -326,6 +326,12 @@ fn write_impl(cx: &mut compositor::Context, path: Option<&str>, force: bool) ->
     let jobs = &mut cx.jobs;
     let (view, doc) = current!(cx.editor);
 
+    if config.trim_trailing_whitespace {
+        trim_trailing_whitespace(doc, view.id);
+    }
+    if config.trim_final_newlines {
+        trim_final_newlines(doc, view.id);
+    }
     if config.insert_final_newline {
         insert_final_newline(doc, view.id);
     }
@@ -357,6 +363,56 @@ fn write_impl(cx: &mut compositor::Context, path: Option<&str>, force: bool) ->
     Ok(())
 }
 
+/// Trim all whitespace preceding line-endings in a document.
+fn trim_trailing_whitespace(doc: &mut Document, view_id: ViewId) {
+    let text = doc.text();
+    let mut pos = 0;
+    let transaction = Transaction::delete(
+        text,
+        text.lines().filter_map(|line| {
+            let line_end_len_chars = line_ending::get_line_ending(&line)
+                .map(|le| le.len_chars())
+                .unwrap_or_default();
+            // Char after the last non-whitespace character or the beginning of the line if the
+            // line is all whitespace:
+            let first_trailing_whitespace =
+                pos + line.last_non_whitespace_char().map_or(0, |idx| idx + 1);
+            pos += line.len_chars();
+            // Char before the line ending character(s), or the final char in the text if there
+            // is no line-ending on this line:
+            let line_end = pos - line_end_len_chars;
+            if first_trailing_whitespace != line_end {
+                Some((first_trailing_whitespace, line_end))
+            } else {
+                None
+            }
+        }),
+    );
+    doc.apply(&transaction, view_id);
+}
+
+/// Trim any extra line-endings after the final line-ending.
+fn trim_final_newlines(doc: &mut Document, view_id: ViewId) {
+    let rope = doc.text();
+    let mut text = rope.slice(..);
+    let mut total_char_len = 0;
+    let mut final_char_len = 0;
+    while let Some(line_ending) = line_ending::get_line_ending(&text) {
+        total_char_len += line_ending.len_chars();
+        final_char_len = line_ending.len_chars();
+        text = text.slice(..text.len_chars() - line_ending.len_chars());
+    }
+    let chars_to_delete = total_char_len - final_char_len;
+    if chars_to_delete != 0 {
+        let transaction = Transaction::delete(
+            rope,
+            [(rope.len_chars() - chars_to_delete, rope.len_chars())].into_iter(),
+        );
+        doc.apply(&transaction, view_id);
+    }
+}
+
+/// Ensure that the document is terminated with a line ending.
 fn insert_final_newline(doc: &mut Document, view_id: ViewId) {
     let text = doc.text();
     if line_ending::get_line_ending(&text.slice(..)).is_none() {
@@ -682,6 +738,12 @@ pub fn write_all_impl(
         let doc = doc_mut!(cx.editor, &doc_id);
         let view = view_mut!(cx.editor, target_view);
 
+        if config.trim_trailing_whitespace {
+            trim_trailing_whitespace(doc, target_view);
+        }
+        if config.trim_final_newlines {
+            trim_final_newlines(doc, target_view);
+        }
         if config.insert_final_newline {
             insert_final_newline(doc, target_view);
         }
diff --git a/helix-term/tests/test/commands/write.rs b/helix-term/tests/test/commands/write.rs
index aba101e9..38ab643c 100644
--- a/helix-term/tests/test/commands/write.rs
+++ b/helix-term/tests/test/commands/write.rs
@@ -420,6 +420,50 @@ async fn test_write_utf_bom_file() -> anyhow::Result<()> {
     Ok(())
 }
 
+#[tokio::test(flavor = "multi_thread")]
+async fn test_write_trim_trailing_whitespace() -> anyhow::Result<()> {
+    let mut file = tempfile::NamedTempFile::new()?;
+    let mut app = helpers::AppBuilder::new()
+        .with_config(Config {
+            editor: helix_view::editor::Config {
+                trim_trailing_whitespace: true,
+                ..Default::default()
+            },
+            ..Default::default()
+        })
+        .with_file(file.path(), None)
+        .with_input_text("#[f|]#oo      \n\n \nbar      ")
+        .build()?;
+
+    test_key_sequence(&mut app, Some(":w<ret>"), None, false).await?;
+
+    helpers::assert_file_has_content(&mut file, &LineFeedHandling::Native.apply("foo\n\n\nbar"))?;
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_write_trim_final_newlines() -> anyhow::Result<()> {
+    let mut file = tempfile::NamedTempFile::new()?;
+    let mut app = helpers::AppBuilder::new()
+        .with_config(Config {
+            editor: helix_view::editor::Config {
+                trim_final_newlines: true,
+                ..Default::default()
+            },
+            ..Default::default()
+        })
+        .with_file(file.path(), None)
+        .with_input_text("#[f|]#oo\n \n\n\n")
+        .build()?;
+
+    test_key_sequence(&mut app, Some(":w<ret>"), None, false).await?;
+
+    helpers::assert_file_has_content(&mut file, &LineFeedHandling::Native.apply("foo\n \n"))?;
+
+    Ok(())
+}
+
 #[tokio::test(flavor = "multi_thread")]
 async fn test_write_insert_final_newline_added_if_missing() -> anyhow::Result<()> {
     let mut file = tempfile::NamedTempFile::new()?;
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 739dcfb4..cdc48a54 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -342,6 +342,12 @@ pub struct Config {
     pub default_line_ending: LineEndingConfig,
     /// Whether to automatically insert a trailing line-ending on write if missing. Defaults to `true`.
     pub insert_final_newline: bool,
+    /// Whether to automatically remove all trailing line-endings after the final one on write.
+    /// Defaults to `false`.
+    pub trim_final_newlines: bool,
+    /// Whether to automatically remove all whitespace characters preceding line-endings on write.
+    /// Defaults to `false`.
+    pub trim_trailing_whitespace: bool,
     /// Enables smart tab
     pub smart_tab: Option<SmartTabConfig>,
     /// Draw border around popups.
@@ -994,6 +1000,8 @@ impl Default for Config {
             workspace_lsp_roots: Vec::new(),
             default_line_ending: LineEndingConfig::default(),
             insert_final_newline: true,
+            trim_final_newlines: false,
+            trim_trailing_whitespace: false,
             smart_tab: Some(SmartTabConfig::default()),
             popup_border: PopupBorderConfig::None,
             indent_heuristic: IndentationHeuristic::default(),