Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/oxfmt/src-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export type FormatOptions = {
singleAttributePerLine?: boolean;
/** Control whether formats quoted code embedded in the file. (Default: `"auto"`) */
embeddedLanguageFormatting?: "auto" | "off";
/** Whether to insert a final newline at the end of the file. (Default: `true`) */
insertFinalNewline?: boolean;
/** Experimental: Sort import statements. Disabled by default. */
experimentalSortImports?: SortImportsOptions;
/** Experimental: Sort `package.json` keys. (Default: `true`) */
Expand Down
41 changes: 30 additions & 11 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,20 @@ pub enum ResolvedOptions {
format_options: FormatOptions,
/// For embedded language formatting (e.g., CSS in template literals)
external_options: Value,
insert_final_newline: bool,
},
/// For TOML files.
OxfmtToml { toml_options: TomlFormatterOptions },
OxfmtToml { toml_options: TomlFormatterOptions, insert_final_newline: bool },
/// For non-JS files formatted by external formatter (Prettier).
#[cfg(feature = "napi")]
ExternalFormatter { external_options: Value },
ExternalFormatter { external_options: Value, insert_final_newline: bool },
/// For `package.json` files: optionally sorted then formatted.
#[cfg(feature = "napi")]
ExternalFormatterPackageJson { external_options: Value, sort_package_json: bool },
ExternalFormatterPackageJson {
external_options: Value,
sort_package_json: bool,
insert_final_newline: bool,
},
}

/// Configuration resolver that derives all config values from a single `serde_json::Value`.
Expand Down Expand Up @@ -174,7 +179,6 @@ impl ConfigResolver {

/// Resolve format options for a specific file.
pub fn resolve(&self, strategy: &FormatFileStrategy) -> ResolvedOptions {
#[cfg_attr(not(feature = "napi"), expect(unused_variables))]
let (format_options, oxfmt_options, external_options) = if let Some(editorconfig) =
&self.editorconfig
&& let Some(props) = get_editorconfig_overrides(editorconfig, strategy.path())
Expand All @@ -190,22 +194,28 @@ impl ConfigResolver {
.expect("`build_and_validate()` must be called before `resolve()`")
};

let insert_final_newline = oxfmt_options.insert_final_newline;

match strategy {
FormatFileStrategy::OxcFormatter { .. } => {
ResolvedOptions::OxcFormatter { format_options, external_options }
}
FormatFileStrategy::OxfmtToml { .. } => {
ResolvedOptions::OxfmtToml { toml_options: build_toml_options(&format_options) }
}
FormatFileStrategy::OxcFormatter { .. } => ResolvedOptions::OxcFormatter {
format_options,
external_options,
insert_final_newline,
},
FormatFileStrategy::OxfmtToml { .. } => ResolvedOptions::OxfmtToml {
toml_options: build_toml_options(&format_options),
insert_final_newline,
},
#[cfg(feature = "napi")]
FormatFileStrategy::ExternalFormatter { .. } => {
ResolvedOptions::ExternalFormatter { external_options }
ResolvedOptions::ExternalFormatter { external_options, insert_final_newline }
}
#[cfg(feature = "napi")]
FormatFileStrategy::ExternalFormatterPackageJson { .. } => {
ResolvedOptions::ExternalFormatterPackageJson {
external_options,
sort_package_json: oxfmt_options.sort_package_json,
insert_final_newline,
}
}
#[cfg(not(feature = "napi"))]
Expand Down Expand Up @@ -251,6 +261,7 @@ impl ConfigResolver {
/// - end_of_line
/// - indent_style
/// - indent_size
/// - insert_final_newline
fn get_editorconfig_overrides(
editorconfig: &EditorConfig,
path: &Path,
Expand All @@ -274,13 +285,15 @@ fn get_editorconfig_overrides(
|| resolved.end_of_line != root.end_of_line
|| resolved.indent_style != root.indent_style
|| resolved.indent_size != root.indent_size
|| resolved.insert_final_newline != root.insert_final_newline
}
// No `[*]` section means any resolved property is an override
None => {
resolved.max_line_length != EditorConfigProperty::Unset
|| resolved.end_of_line != EditorConfigProperty::Unset
|| resolved.indent_style != EditorConfigProperty::Unset
|| resolved.indent_size != EditorConfigProperty::Unset
|| resolved.insert_final_newline != EditorConfigProperty::Unset
}
};

Expand Down Expand Up @@ -326,6 +339,12 @@ fn apply_editorconfig(oxfmtrc: &mut Oxfmtrc, props: &EditorConfigProperties) {
{
oxfmtrc.tab_width = Some(size as u8);
}

if oxfmtrc.insert_final_newline.is_none()
&& let EditorConfigProperty::Value(v) = props.insert_final_newline
{
oxfmtrc.insert_final_newline = Some(v);
}
}

// ---
Expand Down
68 changes: 46 additions & 22 deletions apps/oxfmt/src/core/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,46 +48,70 @@ impl SourceFormatter {
source_text: &str,
resolved_options: ResolvedOptions,
) -> FormatResult {
let result = match (entry, resolved_options) {
let (result, insert_final_newline) = match (entry, resolved_options) {
(
FormatFileStrategy::OxcFormatter { path, source_type },
ResolvedOptions::OxcFormatter { format_options, external_options },
) => self.format_by_oxc_formatter(
source_text,
path,
*source_type,
format_options,
external_options,
ResolvedOptions::OxcFormatter {
format_options,
external_options,
insert_final_newline,
},
) => (
self.format_by_oxc_formatter(
source_text,
path,
*source_type,
format_options,
external_options,
),
insert_final_newline,
),
(FormatFileStrategy::OxfmtToml { .. }, ResolvedOptions::OxfmtToml { toml_options }) => {
Ok(Self::format_by_toml(source_text, toml_options))
}
(
FormatFileStrategy::OxfmtToml { .. },
ResolvedOptions::OxfmtToml { toml_options, insert_final_newline },
) => (Ok(Self::format_by_toml(source_text, toml_options)), insert_final_newline),
#[cfg(feature = "napi")]
(
FormatFileStrategy::ExternalFormatter { path, parser_name },
ResolvedOptions::ExternalFormatter { external_options },
) => {
self.format_by_external_formatter(source_text, path, parser_name, external_options)
}
ResolvedOptions::ExternalFormatter { external_options, insert_final_newline },
) => (
self.format_by_external_formatter(source_text, path, parser_name, external_options),
insert_final_newline,
),
#[cfg(feature = "napi")]
(
FormatFileStrategy::ExternalFormatterPackageJson { path, parser_name },
ResolvedOptions::ExternalFormatterPackageJson {
external_options,
sort_package_json,
insert_final_newline,
},
) => self.format_by_external_formatter_package_json(
source_text,
path,
parser_name,
external_options,
sort_package_json,
) => (
self.format_by_external_formatter_package_json(
source_text,
path,
parser_name,
external_options,
sort_package_json,
),
insert_final_newline,
),
_ => unreachable!("FormatFileStrategy and ResolvedOptions variant mismatch"),
};

match result {
Ok(code) => FormatResult::Success { is_changed: source_text != code, code },
Ok(mut code) => {
// NOTE: `insert_final_newline` relies on the fact that:
// - each formatter already ensures there is traliling newline
// - each formatter does not have an option to disable trailing newline
// So we can trim it here without allocating new string.
if !insert_final_newline {
let trimmed_len = code.trim_end().len();
code.truncate(trimmed_len);
}

FormatResult::Success { is_changed: source_text != code, code }
}
Err(err) => FormatResult::Error(vec![err]),
}
}
Expand Down
67 changes: 67 additions & 0 deletions apps/oxfmt/test/__snapshots__/insert_final_newline.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`insertFinalNewline > editorconfig setting removes final newline 1`] = `
"--- FILE -----------
test.ts
--- BEFORE ---------
const foo = 1

--- AFTER ----------
const foo = 1;
--------------------

--- FILE -----------
test.css
--- BEFORE ---------
.foo { color: red }

--- AFTER ----------
.foo {
color: red;
}
--------------------

--- FILE -----------
test.toml
--- BEFORE ---------
[foo]
bar = 1

--- AFTER ----------
[foo]
bar = 1
--------------------"
`;

exports[`insertFinalNewline > oxfmtrc setting removes final newline 1`] = `
"--- FILE -----------
test.ts
--- BEFORE ---------
const foo = 1

--- AFTER ----------
const foo = 1;
--------------------

--- FILE -----------
test.css
--- BEFORE ---------
.foo { color: red }

--- AFTER ----------
.foo {
color: red;
}
--------------------

--- FILE -----------
test.toml
--- BEFORE ---------
[foo]
bar = 1

--- AFTER ----------
[foo]
bar = 1
--------------------"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
root = true

[*]
insert_final_newline = false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.foo { color: red }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[foo]
bar = 1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const foo = 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"insertFinalNewline": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.foo { color: red }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[foo]
bar = 1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const foo = 1
33 changes: 33 additions & 0 deletions apps/oxfmt/test/insert_final_newline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { runWriteModeAndSnapshot } from "./utils";

const fixturesDir = join(__dirname, "fixtures", "insert_final_newline");

describe("insertFinalNewline", () => {
// .oxfmtrc.json:
// insertFinalNewline=false
//
// Expected: No trailing newline in formatted output
// - test.ts: TypeScript file (oxc_formatter)
// - test.css: CSS file (external formatter)
// - test.toml: TOML file (toml formatter)
it("oxfmtrc setting removes final newline", async () => {
const cwd = join(fixturesDir, "oxfmtrc_only");
const snapshot = await runWriteModeAndSnapshot(cwd, ["test.ts", "test.css", "test.toml"]);
expect(snapshot).toMatchSnapshot();
});

// .editorconfig:
// [*] insert_final_newline=false
//
// Expected: No trailing newline in formatted output
// - test.ts: TypeScript file (oxc_formatter)
// - test.css: CSS file (external formatter)
// - test.toml: TOML file (toml formatter)
it("editorconfig setting removes final newline", async () => {
const cwd = join(fixturesDir, "editorconfig_only");
const snapshot = await runWriteModeAndSnapshot(cwd, ["test.ts", "test.css", "test.toml"]);
expect(snapshot).toMatchSnapshot();
});
});
11 changes: 10 additions & 1 deletion crates/oxc_formatter/src/oxfmtrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ pub struct Oxfmtrc {
#[serde(skip_serializing_if = "Option::is_none")]
pub embedded_language_formatting: Option<EmbeddedLanguageFormattingConfig>,

/// Whether to insert a final newline at the end of the file. (Default: `true`)
#[serde(skip_serializing_if = "Option::is_none")]
pub insert_final_newline: Option<bool>,

/// Experimental: Sort import statements. Disabled by default.
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental_sort_imports: Option<SortImportsConfig>,
Expand Down Expand Up @@ -239,11 +243,12 @@ pub enum SortOrderConfig {
pub struct OxfmtOptions {
pub ignore_patterns: Vec<String>,
pub sort_package_json: bool,
pub insert_final_newline: bool,
}

impl Default for OxfmtOptions {
fn default() -> Self {
Self { ignore_patterns: vec![], sort_package_json: true }
Self { ignore_patterns: vec![], sort_package_json: true, insert_final_newline: true }
}
}

Expand Down Expand Up @@ -426,6 +431,9 @@ impl Oxfmtrc {
if let Some(sort_package_json) = self.experimental_sort_package_json {
oxfmt_options.sort_package_json = sort_package_json;
}
if let Some(insert_final_newline) = self.insert_final_newline {
oxfmt_options.insert_final_newline = insert_final_newline;
}

Ok((format_options, oxfmt_options))
}
Expand Down Expand Up @@ -556,6 +564,7 @@ impl Oxfmtrc {

// Below are our own extensions, just remove them
obj.remove("ignorePatterns");
obj.remove("insertFinalNewline");
obj.remove("experimentalSortImports");
obj.remove("experimentalSortPackageJson");

Expand Down
8 changes: 8 additions & 0 deletions crates/oxc_formatter/tests/snapshots/schema_json.snap
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,14 @@ expression: json
"null"
]
},
"insertFinalNewline": {
"description": "Whether to insert a final newline at the end of the file. (Default: `true`)",
"markdownDescription": "Whether to insert a final newline at the end of the file. (Default: `true`)",
"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`)",
Expand Down
Loading
Loading