diff --git a/apps/oxfmt/src/cli/service.rs b/apps/oxfmt/src/cli/service.rs index 6202c59c2efd6..bdac7c08af3f1 100644 --- a/apps/oxfmt/src/cli/service.rs +++ b/apps/oxfmt/src/cli/service.rs @@ -1,4 +1,4 @@ -use std::{fs, path::Path, sync::mpsc, time::Instant}; +use std::{path::Path, sync::mpsc, time::Instant}; use cow_utils::CowUtils; use rayon::prelude::*; @@ -6,7 +6,10 @@ use rayon::prelude::*; use oxc_diagnostics::{DiagnosticSender, DiagnosticService}; use super::command::OutputMode; -use crate::core::{FormatFileStrategy, FormatResult, SourceFormatter, utils}; +use crate::core::{ + FormatFileStrategy, FormatResult, SourceFormatter, equals_with_eof_adjustment, utils, + write_with_eof_adjustment, +}; pub enum SuccessResult { Changed(String), @@ -59,7 +62,17 @@ impl FormatService { tracing::debug!("Format {}", path.strip_prefix(&self.cwd).unwrap().display()); let (code, is_changed) = match self.formatter.format(&entry, &source_text) { - FormatResult::Success { code, is_changed } => (code, is_changed), + FormatResult::Success { code, .. } => { + // Compute change status considering EOF adjustment (zero allocations) + let format_options = self.formatter.format_options(); + let is_changed = !equals_with_eof_adjustment( + &source_text, + &code, + format_options.insert_final_newline, + format_options.line_ending, + ); + (code, is_changed) + } FormatResult::Error(diagnostics) => { let errors = DiagnosticService::wrap_diagnostics( self.cwd.clone(), @@ -72,11 +85,28 @@ impl FormatService { } }; - // Write back if needed + // Write back if needed (EOF adjustment applied during write) if matches!(self.format_mode, OutputMode::Write) && is_changed { - fs::write(path, code) - .map_err(|_| format!("Failed to write to '{}'", path.to_string_lossy())) - .unwrap(); + let format_options = self.formatter.format_options(); + if let Err(err) = write_with_eof_adjustment( + path, + &code, + &source_text, + format_options.insert_final_newline, + format_options.line_ending, + ) { + // Handle write error + let diagnostics = DiagnosticService::wrap_diagnostics( + self.cwd.clone(), + path, + "", + vec![oxc_diagnostics::OxcDiagnostic::error(format!( + "Failed to write file: {err}" + ))], + ); + tx_error.send(diagnostics).unwrap(); + return; + } } // Report result diff --git a/apps/oxfmt/src/core/eof.rs b/apps/oxfmt/src/core/eof.rs new file mode 100644 index 0000000000000..b94c999c16383 --- /dev/null +++ b/apps/oxfmt/src/core/eof.rs @@ -0,0 +1,315 @@ +//! EOF newline adjustment logic +//! +//! This module provides zero-allocation helpers for adjusting end-of-file newlines +//! based on the `insert_final_newline` option, applied at the point of write or check. + +use std::{io::Write, path::Path}; + +use oxc_formatter::{InsertFinalNewline, LineEnding}; + +/// Calculate and apply EOF newline adjustment, returning content slice and optional newline. +/// +/// This function uses string slicing to avoid allocations. It returns: +/// - A slice of the formatted code (possibly trimmed) +/// - An optional line ending to append +/// +/// The caller can then write these two parts separately with zero allocations. +pub fn apply_eof_adjustment<'a>( + formatted_code: &'a str, + original_source: &str, + insert_final_newline: InsertFinalNewline, + line_ending: LineEnding, +) -> (&'a str, Option<&'static [u8]>) { + let original_has_newline = has_trailing_newline(original_source); + let line_ending_bytes = line_ending.as_bytes(); + + let needs_newline = match insert_final_newline { + InsertFinalNewline::Auto => original_has_newline, + InsertFinalNewline::Always => true, + InsertFinalNewline::Never => false, + }; + + let trimmed = formatted_code.trim_end_matches(['\r', '\n']); + + if needs_newline { + // Check if already has exactly the right single line ending + if has_correct_single_trailing_newline(formatted_code, line_ending) { + // Perfect - return as-is (zero allocation) + (formatted_code, None) + } else { + // Needs adjustment - return trimmed + newline + (trimmed, Some(line_ending_bytes)) + } + } else { + // Should have no newline + if trimmed.len() == formatted_code.len() { + // Already has no trailing newline (zero allocation) + (formatted_code, None) + } else { + // Has trailing newline(s), return trimmed + (trimmed, None) + } + } +} + +/// Check if two contents are equal considering EOF adjustment. +/// +/// Used for change detection in check mode. This performs the comparison +/// without allocating a new string. +pub fn equals_with_eof_adjustment( + original: &str, + formatted: &str, + insert_final_newline: InsertFinalNewline, + line_ending: LineEnding, +) -> bool { + let (content, newline) = + apply_eof_adjustment(formatted, original, insert_final_newline, line_ending); + + match newline { + Some(newline_bytes) => { + // Would write: content + newline + // SAFETY: line_ending_bytes comes from LineEnding::as_bytes() which is valid UTF-8 + let newline_str = unsafe { std::str::from_utf8_unchecked(newline_bytes) }; + original.len() == content.len() + newline_str.len() + && original.starts_with(content) + && original.ends_with(newline_str) + } + None => { + // Would write: content (as-is or trimmed) + original == content + } + } +} + +/// Write formatted code to file with EOF adjustment. +/// +/// This function applies the EOF adjustment during the write operation, +/// using two separate writes when a newline needs to be added. This avoids +/// allocating a new string for the adjusted content. +pub fn write_with_eof_adjustment( + path: &Path, + formatted_code: &str, + original_source: &str, + insert_final_newline: InsertFinalNewline, + line_ending: LineEnding, +) -> std::io::Result<()> { + let (content, newline) = + apply_eof_adjustment(formatted_code, original_source, insert_final_newline, line_ending); + + if let Some(newline) = newline { + // Write content + newline in two operations + let file = std::fs::File::create(path)?; + let mut writer = std::io::BufWriter::new(file); + writer.write_all(content.as_bytes())?; + writer.write_all(newline)?; + writer.flush()?; + } else { + // Write content as-is (might be trimmed or untouched) + std::fs::write(path, content)?; + } + + Ok(()) +} + +/// Check if string ends with any newline character(s). +#[inline] +fn has_trailing_newline(s: &str) -> bool { + s.ends_with('\n') || s.ends_with('\r') +} + +/// Check if string ends with exactly one instance of the specified line ending. +/// +/// Returns false if: +/// - The string doesn't end with the specified line ending +/// - The string has multiple trailing newlines +fn has_correct_single_trailing_newline(s: &str, line_ending: LineEnding) -> bool { + let line_ending_bytes = line_ending.as_bytes(); + // SAFETY: line_ending_bytes comes from LineEnding::as_bytes() which is valid UTF-8 + let line_ending_str = unsafe { std::str::from_utf8_unchecked(line_ending_bytes) }; + + if !s.ends_with(line_ending_str) { + return false; + } + + // Check if there's only one line ending (no multiple trailing newlines) + let before_newline = &s[..s.len() - line_ending_bytes.len()]; + !has_trailing_newline(before_newline) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_eof_adjustment_auto_preserve_no_newline() { + let formatted = "const x = 1;"; + let original = "const x=1;"; // No newline + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Auto, LineEnding::Lf); + + assert_eq!(content, "const x = 1;"); + assert_eq!(newline, None); + } + + #[test] + fn test_apply_eof_adjustment_auto_preserve_with_newline() { + let formatted = "const x = 1;\n"; + let original = "const x=1;\n"; // Has newline + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Auto, LineEnding::Lf); + + // Already has correct newline, return as-is + assert_eq!(content, "const x = 1;\n"); + assert_eq!(newline, None); + } + + #[test] + fn test_apply_eof_adjustment_always_add_newline() { + let formatted = "const x = 1;"; // No newline + let original = "const x=1;"; // No newline + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Always, LineEnding::Lf); + + assert_eq!(content, "const x = 1;"); + assert_eq!(newline, Some(b"\n".as_slice())); + } + + #[test] + fn test_apply_eof_adjustment_never_remove_newline() { + let formatted = "const x = 1;\n"; // Has newline + let original = "const x=1;\n"; // Has newline + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Never, LineEnding::Lf); + + assert_eq!(content, "const x = 1;"); // Trimmed + assert_eq!(newline, None); + } + + #[test] + fn test_apply_eof_adjustment_normalize_multiple_newlines() { + let formatted = "const x = 1;\n\n\n"; // Multiple newlines + let original = "const x=1;\n"; // Single newline + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Auto, LineEnding::Lf); + + assert_eq!(content, "const x = 1;"); + assert_eq!(newline, Some(b"\n".as_slice())); // Normalized to one + } + + #[test] + fn test_apply_eof_adjustment_respects_line_ending() { + let formatted = "const x = 1;"; + let original = "const x=1;"; + + // Test CRLF + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Always, LineEnding::Crlf); + assert_eq!(content, "const x = 1;"); + assert_eq!(newline, Some(b"\r\n".as_slice())); + + // Test CR + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Always, LineEnding::Cr); + assert_eq!(content, "const x = 1;"); + assert_eq!(newline, Some(b"\r".as_slice())); + } + + #[test] + fn test_equals_with_eof_adjustment_auto_mode() { + let original = "const x = 1;\n"; + let formatted = "const x = 1;\n\n"; // Extra newline + + // Should be equal after adjustment (normalize to single newline) + assert!(equals_with_eof_adjustment( + original, + formatted, + InsertFinalNewline::Auto, + LineEnding::Lf, + )); + } + + #[test] + fn test_equals_with_eof_adjustment_different_content() { + let original = "const x = 1;\n"; + let formatted = "const y = 2;\n"; + + // Should not be equal - different content + assert!(!equals_with_eof_adjustment( + original, + formatted, + InsertFinalNewline::Auto, + LineEnding::Lf, + )); + } + + #[test] + fn test_has_correct_single_trailing_newline() { + // Single LF - correct + assert!(has_correct_single_trailing_newline("test\n", LineEnding::Lf)); + + // Multiple LF - incorrect + assert!(!has_correct_single_trailing_newline("test\n\n", LineEnding::Lf)); + + // No newline - incorrect + assert!(!has_correct_single_trailing_newline("test", LineEnding::Lf)); + + // Single CRLF - correct + assert!(has_correct_single_trailing_newline("test\r\n", LineEnding::Crlf)); + + // LF when expecting CRLF - incorrect + assert!(!has_correct_single_trailing_newline("test\n", LineEnding::Crlf)); + + // CRLF when expecting LF - incorrect (ends with LF but has extra CR) + assert!(!has_correct_single_trailing_newline("test\r\n", LineEnding::Lf)); + } + + #[test] + fn test_has_trailing_newline() { + assert!(has_trailing_newline("test\n")); + assert!(has_trailing_newline("test\r")); + assert!(has_trailing_newline("test\r\n")); + assert!(!has_trailing_newline("test")); + assert!(!has_trailing_newline("")); + } + + #[test] + fn test_empty_file_auto_mode() { + let formatted = ""; + let original = ""; + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Auto, LineEnding::Lf); + + assert_eq!(content, ""); + assert_eq!(newline, None); + } + + #[test] + fn test_empty_file_always_mode() { + let formatted = ""; + let original = ""; + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Always, LineEnding::Lf); + + assert_eq!(content, ""); + assert_eq!(newline, Some(b"\n".as_slice())); + } + + #[test] + fn test_empty_file_never_mode() { + let formatted = ""; + let original = ""; + + let (content, newline) = + apply_eof_adjustment(formatted, original, InsertFinalNewline::Never, LineEnding::Lf); + + assert_eq!(content, ""); + assert_eq!(newline, None); + } +} diff --git a/apps/oxfmt/src/core/format.rs b/apps/oxfmt/src/core/format.rs index 2474549410b57..60ee8061cf2a5 100644 --- a/apps/oxfmt/src/core/format.rs +++ b/apps/oxfmt/src/core/format.rs @@ -11,7 +11,7 @@ use oxc_span::SourceType; use super::FormatFileStrategy; pub enum FormatResult { - Success { is_changed: bool, code: String }, + Success { code: String }, Error(Vec), } @@ -36,6 +36,11 @@ impl SourceFormatter { } } + /// Get the format options + pub fn format_options(&self) -> &FormatOptions { + &self.format_options + } + #[cfg(feature = "napi")] #[must_use] pub fn with_external_formatter( @@ -70,7 +75,7 @@ impl SourceFormatter { }; match result { - Ok(code) => FormatResult::Success { is_changed: source_text != code, code }, + Ok(code) => FormatResult::Success { code }, Err(err) => FormatResult::Error(vec![err]), } } diff --git a/apps/oxfmt/src/core/mod.rs b/apps/oxfmt/src/core/mod.rs index 24c9fa4d4e87f..4fb6c0e9cfd21 100644 --- a/apps/oxfmt/src/core/mod.rs +++ b/apps/oxfmt/src/core/mod.rs @@ -1,4 +1,5 @@ mod config; +mod eof; mod format; mod support; pub mod utils; @@ -7,6 +8,7 @@ pub mod utils; mod external_formatter; pub use config::{load_config, resolve_config_path}; +pub use eof::{equals_with_eof_adjustment, write_with_eof_adjustment}; pub use format::{FormatResult, SourceFormatter}; pub use support::FormatFileStrategy; diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/always/.oxfmtrc.json b/apps/oxfmt/test/fixtures/insert_final_newline/always/.oxfmtrc.json new file mode 100644 index 0000000000000..babe991d9e9f6 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/always/.oxfmtrc.json @@ -0,0 +1,3 @@ +{ + "insertFinalNewline": true +} diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/always/multiple_newlines.js b/apps/oxfmt/test/fixtures/insert_final_newline/always/multiple_newlines.js new file mode 100644 index 0000000000000..0a521b7ac5b8c --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/always/multiple_newlines.js @@ -0,0 +1,3 @@ +const x=1; + + diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/always/with_newline.js b/apps/oxfmt/test/fixtures/insert_final_newline/always/with_newline.js new file mode 100644 index 0000000000000..5a3ccbf176693 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/always/with_newline.js @@ -0,0 +1 @@ +const x=1; diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/always/without_newline.js b/apps/oxfmt/test/fixtures/insert_final_newline/always/without_newline.js new file mode 100644 index 0000000000000..2db147504e8b9 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/always/without_newline.js @@ -0,0 +1 @@ +const x=1; \ No newline at end of file diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/auto/.oxfmtrc.json b/apps/oxfmt/test/fixtures/insert_final_newline/auto/.oxfmtrc.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/auto/.oxfmtrc.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/auto/multiple_newlines.js b/apps/oxfmt/test/fixtures/insert_final_newline/auto/multiple_newlines.js new file mode 100644 index 0000000000000..0a521b7ac5b8c --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/auto/multiple_newlines.js @@ -0,0 +1,3 @@ +const x=1; + + diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/auto/with_newline.js b/apps/oxfmt/test/fixtures/insert_final_newline/auto/with_newline.js new file mode 100644 index 0000000000000..5a3ccbf176693 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/auto/with_newline.js @@ -0,0 +1 @@ +const x=1; diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/auto/without_newline.js b/apps/oxfmt/test/fixtures/insert_final_newline/auto/without_newline.js new file mode 100644 index 0000000000000..2db147504e8b9 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/auto/without_newline.js @@ -0,0 +1 @@ +const x=1; \ No newline at end of file diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/never/.oxfmtrc.json b/apps/oxfmt/test/fixtures/insert_final_newline/never/.oxfmtrc.json new file mode 100644 index 0000000000000..09d4c01a74737 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/never/.oxfmtrc.json @@ -0,0 +1,3 @@ +{ + "insertFinalNewline": false +} diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/never/multiple_newlines.js b/apps/oxfmt/test/fixtures/insert_final_newline/never/multiple_newlines.js new file mode 100644 index 0000000000000..0a521b7ac5b8c --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/never/multiple_newlines.js @@ -0,0 +1,3 @@ +const x=1; + + diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/never/with_newline.js b/apps/oxfmt/test/fixtures/insert_final_newline/never/with_newline.js new file mode 100644 index 0000000000000..5a3ccbf176693 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/never/with_newline.js @@ -0,0 +1 @@ +const x=1; diff --git a/apps/oxfmt/test/fixtures/insert_final_newline/never/without_newline.js b/apps/oxfmt/test/fixtures/insert_final_newline/never/without_newline.js new file mode 100644 index 0000000000000..2db147504e8b9 --- /dev/null +++ b/apps/oxfmt/test/fixtures/insert_final_newline/never/without_newline.js @@ -0,0 +1 @@ +const x=1; \ No newline at end of file diff --git a/apps/oxfmt/test/insert_final_newline.test.ts b/apps/oxfmt/test/insert_final_newline.test.ts new file mode 100644 index 0000000000000..7b3ba4da284bc --- /dev/null +++ b/apps/oxfmt/test/insert_final_newline.test.ts @@ -0,0 +1,92 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runWriteModeAndSnapshot } from "./utils"; + +const fixtureDir = join(import.meta.dirname, "fixtures", "insert_final_newline"); + +describe("insertFinalNewline option", () => { + describe("auto mode (default - preserve original)", () => { + const testDir = join(fixtureDir, "auto"); + + it("should preserve EOF newline behavior", async () => { + // Reset files to original state + writeFileSync(join(testDir, "without_newline.js"), "const x=1;"); + writeFileSync(join(testDir, "with_newline.js"), "const x=1;\n"); + writeFileSync(join(testDir, "multiple_newlines.js"), "const x=1;\n\n\n"); + + const snapshot = await runWriteModeAndSnapshot(testDir, [ + "without_newline.js", + "with_newline.js", + "multiple_newlines.js", + ]); + + // Check the formatted results + const withoutNewline = readFileSync(join(testDir, "without_newline.js"), "utf8"); + const withNewline = readFileSync(join(testDir, "with_newline.js"), "utf8"); + const multipleNewlines = readFileSync(join(testDir, "multiple_newlines.js"), "utf8"); + + expect(withoutNewline).toBe("const x = 1;"); // No newline added + expect(withNewline).toBe("const x = 1;\n"); // Single newline preserved + expect(multipleNewlines).toBe("const x = 1;\n"); // Normalized to one + + expect(snapshot).toMatchSnapshot(); + }); + }); + + describe("always mode", () => { + const testDir = join(fixtureDir, "always"); + + it("should always ensure EOF newline", async () => { + // Reset files to original state + writeFileSync(join(testDir, "without_newline.js"), "const x=1;"); + writeFileSync(join(testDir, "with_newline.js"), "const x=1;\n"); + writeFileSync(join(testDir, "multiple_newlines.js"), "const x=1;\n\n\n"); + + const snapshot = await runWriteModeAndSnapshot(testDir, [ + "without_newline.js", + "with_newline.js", + "multiple_newlines.js", + ]); + + // Check the formatted results + const withoutNewline = readFileSync(join(testDir, "without_newline.js"), "utf8"); + const withNewline = readFileSync(join(testDir, "with_newline.js"), "utf8"); + const multipleNewlines = readFileSync(join(testDir, "multiple_newlines.js"), "utf8"); + + expect(withoutNewline).toBe("const x = 1;\n"); // Newline added + expect(withNewline).toBe("const x = 1;\n"); // Single newline preserved + expect(multipleNewlines).toBe("const x = 1;\n"); // Normalized to one + + expect(snapshot).toMatchSnapshot(); + }); + }); + + describe("never mode", () => { + const testDir = join(fixtureDir, "never"); + + it("should never add EOF newline", async () => { + // Reset files to original state + writeFileSync(join(testDir, "without_newline.js"), "const x=1;"); + writeFileSync(join(testDir, "with_newline.js"), "const x=1;\n"); + writeFileSync(join(testDir, "multiple_newlines.js"), "const x=1;\n\n\n"); + + const snapshot = await runWriteModeAndSnapshot(testDir, [ + "without_newline.js", + "with_newline.js", + "multiple_newlines.js", + ]); + + // Check the formatted results + const withoutNewline = readFileSync(join(testDir, "without_newline.js"), "utf8"); + const withNewline = readFileSync(join(testDir, "with_newline.js"), "utf8"); + const multipleNewlines = readFileSync(join(testDir, "multiple_newlines.js"), "utf8"); + + expect(withoutNewline).toBe("const x = 1;"); // No newline + expect(withNewline).toBe("const x = 1;"); // Newline removed + expect(multipleNewlines).toBe("const x = 1;"); // All newlines removed + + expect(snapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/crates/oxc_formatter/src/options.rs b/crates/oxc_formatter/src/options.rs index e3fbd19f43f02..7850bc6298050 100644 --- a/crates/oxc_formatter/src/options.rs +++ b/crates/oxc_formatter/src/options.rs @@ -74,6 +74,9 @@ pub struct FormatOptions { /// Sort import statements. By default disabled. pub experimental_sort_imports: Option, + + /// Control whether to insert a final newline at the end of files. Defaults to "auto". + pub insert_final_newline: InsertFinalNewline, } impl FormatOptions { @@ -97,6 +100,7 @@ impl FormatOptions { experimental_ternaries: false, embedded_language_formatting: EmbeddedLanguageFormatting::default(), experimental_sort_imports: None, + insert_final_newline: InsertFinalNewline::default(), } } @@ -123,7 +127,8 @@ impl fmt::Display for FormatOptions { writeln!(f, "Expand lists: {}", self.expand)?; writeln!(f, "Experimental operator position: {}", self.experimental_operator_position)?; writeln!(f, "Embedded language formatting: {}", self.embedded_language_formatting)?; - writeln!(f, "Experimental sort imports: {:?}", self.experimental_sort_imports) + writeln!(f, "Experimental sort imports: {:?}", self.experimental_sort_imports)?; + writeln!(f, "Insert final newline: {}", self.insert_final_newline) } } @@ -985,3 +990,52 @@ impl fmt::Display for EmbeddedLanguageFormatting { f.write_str(s) } } + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub enum InsertFinalNewline { + /// Preserve the original file's EOF newline behavior + #[default] + Auto, + /// Always ensure file ends with a newline + Always, + /// Never add a final newline + Never, +} + +impl InsertFinalNewline { + pub const fn is_auto(self) -> bool { + matches!(self, Self::Auto) + } + + pub const fn is_always(self) -> bool { + matches!(self, Self::Always) + } + + pub const fn is_never(self) -> bool { + matches!(self, Self::Never) + } +} + +impl FromStr for InsertFinalNewline { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "auto" => Ok(Self::Auto), + "always" => Ok(Self::Always), + "never" => Ok(Self::Never), + _ => Err("Value not supported for InsertFinalNewline"), + } + } +} + +impl fmt::Display for InsertFinalNewline { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + InsertFinalNewline::Auto => "Auto", + InsertFinalNewline::Always => "Always", + InsertFinalNewline::Never => "Never", + }; + f.write_str(s) + } +} diff --git a/crates/oxc_formatter/src/service/oxfmtrc.rs b/crates/oxc_formatter/src/service/oxfmtrc.rs index 1f0c2b2849429..49711f8089f03 100644 --- a/crates/oxc_formatter/src/service/oxfmtrc.rs +++ b/crates/oxc_formatter/src/service/oxfmtrc.rs @@ -6,9 +6,9 @@ use serde_json::Value; use crate::{ ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, - EmbeddedLanguageFormatting, Expand, FormatOptions, IndentStyle, IndentWidth, LineEnding, - LineWidth, QuoteProperties, QuoteStyle, Semicolons, SortImportsOptions, SortOrder, - TrailingCommas, default_groups, default_internal_patterns, + EmbeddedLanguageFormatting, Expand, FormatOptions, IndentStyle, IndentWidth, + InsertFinalNewline, LineEnding, LineWidth, QuoteProperties, QuoteStyle, Semicolons, + SortImportsOptions, SortOrder, TrailingCommas, default_groups, default_internal_patterns, }; /// Configuration options for the Oxfmt. @@ -85,6 +85,14 @@ pub struct Oxfmtrc { #[serde(skip_serializing_if = "Option::is_none")] pub experimental_sort_package_json: Option, + /// Ensure files end with a newline. (Default: unset - preserve original) + /// + /// When unset (or `null`), the formatter will preserve the original file's + /// EOF newline behavior. Set to `true` to always add a final newline, + /// or `false` to never add one. + #[serde(skip_serializing_if = "Option::is_none")] + pub insert_final_newline: Option, + /// Ignore files matching these glob patterns. Current working directory is used as the root. #[serde(skip_serializing_if = "Option::is_none")] pub ignore_patterns: Option>, @@ -395,6 +403,15 @@ impl Oxfmtrc { // Below are our own extensions + // [EditorConfig] insertFinalNewline: boolean + if let Some(insert_final_newline) = self.insert_final_newline { + format_options.insert_final_newline = if insert_final_newline { + InsertFinalNewline::Always + } else { + InsertFinalNewline::Never + }; + } + if let Some(sort_imports_config) = self.experimental_sort_imports { // `partition_by_newline: true` and `newlines_between` cannot be used together if sort_imports_config.partition_by_newline && sort_imports_config.newlines_between { @@ -554,6 +571,7 @@ impl Oxfmtrc { obj.remove("ignorePatterns"); obj.remove("experimentalSortImports"); obj.remove("experimentalSortPackageJson"); + obj.remove("insertFinalNewline"); // Any other unknown fields are preserved as-is. // e.g. `plugins`, `htmlWhitespaceSensitivity`, `vueIndentScriptAndStyle`, etc. @@ -820,6 +838,24 @@ mod tests { assert_eq!(sort_imports.groups[4], vec!["index".to_string()]); } + #[test] + fn test_insert_final_newline_config() { + // Test true -> Always + let config: Oxfmtrc = serde_json::from_str(r#"{"insertFinalNewline": true}"#).unwrap(); + let (format_options, _) = config.into_options().unwrap(); + assert!(format_options.insert_final_newline.is_always()); + + // Test false -> Never + let config: Oxfmtrc = serde_json::from_str(r#"{"insertFinalNewline": false}"#).unwrap(); + let (format_options, _) = config.into_options().unwrap(); + assert!(format_options.insert_final_newline.is_never()); + + // Test unset -> Auto (default) + let config: Oxfmtrc = serde_json::from_str(r#"{}"#).unwrap(); + let (format_options, _) = config.into_options().unwrap(); + assert!(format_options.insert_final_newline.is_auto()); + } + #[test] fn test_populate_prettier_config_defaults() { let json_string = r"{}"; @@ -838,7 +874,8 @@ mod tests { let json_string = r#"{ "printWidth": 80, "ignorePatterns": ["*.min.js"], - "experimentalSortImports": { "order": "asc" } + "experimentalSortImports": { "order": "asc" }, + "insertFinalNewline": true }"#; let mut raw_config: Value = serde_json::from_str(json_string).unwrap(); let oxfmtrc: Oxfmtrc = serde_json::from_str(json_string).unwrap(); @@ -852,5 +889,6 @@ mod tests { // oxfmt extensions are removed assert!(!obj.contains_key("ignorePatterns")); assert!(!obj.contains_key("experimentalSortImports")); + assert!(!obj.contains_key("insertFinalNewline")); } } diff --git a/crates/oxc_formatter/tests/snapshots/schema_json.snap b/crates/oxc_formatter/tests/snapshots/schema_json.snap index d53bc24ce16db..f67bfa3020de0 100644 --- a/crates/oxc_formatter/tests/snapshots/schema_json.snap +++ b/crates/oxc_formatter/tests/snapshots/schema_json.snap @@ -205,6 +205,14 @@ expression: json "null" ] }, + "insertFinalNewline": { + "description": "Ensure files end with a newline. (Default: unset - preserve original)\n\nWhen unset (or `null`), the formatter will preserve the original file's\nEOF newline behavior. Set to `true` to always add a final newline,\nor `false` to never add one.", + "markdownDescription": "Ensure files end with a newline. (Default: unset - preserve original)\n\nWhen unset (or `null`), the formatter will preserve the original file's\nEOF newline behavior. Set to `true` to always add a final newline,\nor `false` to never add one.", + "type": [ + "boolean", + "null" + ] + }, "jsxSingleQuote": { "description": "Use single quotes instead of double quotes in JSX. (Default: `false`)", "markdownDescription": "Use single quotes instead of double quotes in JSX. (Default: `false`)", diff --git a/npm/oxfmt/configuration_schema.json b/npm/oxfmt/configuration_schema.json index a8047728b15cf..8716db18e3988 100644 --- a/npm/oxfmt/configuration_schema.json +++ b/npm/oxfmt/configuration_schema.json @@ -201,6 +201,14 @@ "null" ] }, + "insertFinalNewline": { + "description": "Ensure files end with a newline. (Default: unset - preserve original)\n\nWhen unset (or `null`), the formatter will preserve the original file's\nEOF newline behavior. Set to `true` to always add a final newline,\nor `false` to never add one.", + "markdownDescription": "Ensure files end with a newline. (Default: unset - preserve original)\n\nWhen unset (or `null`), the formatter will preserve the original file's\nEOF newline behavior. Set to `true` to always add a final newline,\nor `false` to never add one.", + "type": [ + "boolean", + "null" + ] + }, "jsxSingleQuote": { "description": "Use single quotes instead of double quotes in JSX. (Default: `false`)", "markdownDescription": "Use single quotes instead of double quotes in JSX. (Default: `false`)",