Skip to content

Commit 97a02d1

Browse files
committed
feat(oxfmt): Add insertFinalNewline option (#17251)
Fixes #15066 and closes #16757 - Added `insertFinalNewline: bool` option - `true` by default = The same behavior with current `oxc_formatter` and Prettier - Also respect `insert_final_newline` in `.editorconfig` - Like others, `.oxfmtrc` takes precedence
1 parent 7b810f4 commit 97a02d1

17 files changed

Lines changed: 227 additions & 34 deletions

File tree

apps/oxfmt/src-js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export type FormatOptions = {
8686
singleAttributePerLine?: boolean;
8787
/** Control whether formats quoted code embedded in the file. (Default: `"auto"`) */
8888
embeddedLanguageFormatting?: "auto" | "off";
89+
/** Whether to insert a final newline at the end of the file. (Default: `true`) */
90+
insertFinalNewline?: boolean;
8991
/** Experimental: Sort import statements. Disabled by default. */
9092
experimentalSortImports?: SortImportsOptions;
9193
/** Experimental: Sort `package.json` keys. (Default: `true`) */

apps/oxfmt/src/core/config.rs

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,20 @@ pub enum ResolvedOptions {
5353
format_options: FormatOptions,
5454
/// For embedded language formatting (e.g., CSS in template literals)
5555
external_options: Value,
56+
insert_final_newline: bool,
5657
},
5758
/// For TOML files.
58-
OxfmtToml { toml_options: TomlFormatterOptions },
59+
OxfmtToml { toml_options: TomlFormatterOptions, insert_final_newline: bool },
5960
/// For non-JS files formatted by external formatter (Prettier).
6061
#[cfg(feature = "napi")]
61-
ExternalFormatter { external_options: Value },
62+
ExternalFormatter { external_options: Value, insert_final_newline: bool },
6263
/// For `package.json` files: optionally sorted then formatted.
6364
#[cfg(feature = "napi")]
64-
ExternalFormatterPackageJson { external_options: Value, sort_package_json: bool },
65+
ExternalFormatterPackageJson {
66+
external_options: Value,
67+
sort_package_json: bool,
68+
insert_final_newline: bool,
69+
},
6570
}
6671

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

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

197+
let insert_final_newline = oxfmt_options.insert_final_newline;
198+
193199
match strategy {
194-
FormatFileStrategy::OxcFormatter { .. } => {
195-
ResolvedOptions::OxcFormatter { format_options, external_options }
196-
}
197-
FormatFileStrategy::OxfmtToml { .. } => {
198-
ResolvedOptions::OxfmtToml { toml_options: build_toml_options(&format_options) }
199-
}
200+
FormatFileStrategy::OxcFormatter { .. } => ResolvedOptions::OxcFormatter {
201+
format_options,
202+
external_options,
203+
insert_final_newline,
204+
},
205+
FormatFileStrategy::OxfmtToml { .. } => ResolvedOptions::OxfmtToml {
206+
toml_options: build_toml_options(&format_options),
207+
insert_final_newline,
208+
},
200209
#[cfg(feature = "napi")]
201210
FormatFileStrategy::ExternalFormatter { .. } => {
202-
ResolvedOptions::ExternalFormatter { external_options }
211+
ResolvedOptions::ExternalFormatter { external_options, insert_final_newline }
203212
}
204213
#[cfg(feature = "napi")]
205214
FormatFileStrategy::ExternalFormatterPackageJson { .. } => {
206215
ResolvedOptions::ExternalFormatterPackageJson {
207216
external_options,
208217
sort_package_json: oxfmt_options.sort_package_json,
218+
insert_final_newline,
209219
}
210220
}
211221
#[cfg(not(feature = "napi"))]
@@ -251,6 +261,7 @@ impl ConfigResolver {
251261
/// - end_of_line
252262
/// - indent_style
253263
/// - indent_size
264+
/// - insert_final_newline
254265
fn get_editorconfig_overrides(
255266
editorconfig: &EditorConfig,
256267
path: &Path,
@@ -274,13 +285,15 @@ fn get_editorconfig_overrides(
274285
|| resolved.end_of_line != root.end_of_line
275286
|| resolved.indent_style != root.indent_style
276287
|| resolved.indent_size != root.indent_size
288+
|| resolved.insert_final_newline != root.insert_final_newline
277289
}
278290
// No `[*]` section means any resolved property is an override
279291
None => {
280292
resolved.max_line_length != EditorConfigProperty::Unset
281293
|| resolved.end_of_line != EditorConfigProperty::Unset
282294
|| resolved.indent_style != EditorConfigProperty::Unset
283295
|| resolved.indent_size != EditorConfigProperty::Unset
296+
|| resolved.insert_final_newline != EditorConfigProperty::Unset
284297
}
285298
};
286299

@@ -326,6 +339,12 @@ fn apply_editorconfig(oxfmtrc: &mut Oxfmtrc, props: &EditorConfigProperties) {
326339
{
327340
oxfmtrc.tab_width = Some(size as u8);
328341
}
342+
343+
if oxfmtrc.insert_final_newline.is_none()
344+
&& let EditorConfigProperty::Value(v) = props.insert_final_newline
345+
{
346+
oxfmtrc.insert_final_newline = Some(v);
347+
}
329348
}
330349

331350
// ---

apps/oxfmt/src/core/format.rs

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -48,46 +48,70 @@ impl SourceFormatter {
4848
source_text: &str,
4949
resolved_options: ResolvedOptions,
5050
) -> FormatResult {
51-
let result = match (entry, resolved_options) {
51+
let (result, insert_final_newline) = match (entry, resolved_options) {
5252
(
5353
FormatFileStrategy::OxcFormatter { path, source_type },
54-
ResolvedOptions::OxcFormatter { format_options, external_options },
55-
) => self.format_by_oxc_formatter(
56-
source_text,
57-
path,
58-
*source_type,
59-
format_options,
60-
external_options,
54+
ResolvedOptions::OxcFormatter {
55+
format_options,
56+
external_options,
57+
insert_final_newline,
58+
},
59+
) => (
60+
self.format_by_oxc_formatter(
61+
source_text,
62+
path,
63+
*source_type,
64+
format_options,
65+
external_options,
66+
),
67+
insert_final_newline,
6168
),
62-
(FormatFileStrategy::OxfmtToml { .. }, ResolvedOptions::OxfmtToml { toml_options }) => {
63-
Ok(Self::format_by_toml(source_text, toml_options))
64-
}
69+
(
70+
FormatFileStrategy::OxfmtToml { .. },
71+
ResolvedOptions::OxfmtToml { toml_options, insert_final_newline },
72+
) => (Ok(Self::format_by_toml(source_text, toml_options)), insert_final_newline),
6573
#[cfg(feature = "napi")]
6674
(
6775
FormatFileStrategy::ExternalFormatter { path, parser_name },
68-
ResolvedOptions::ExternalFormatter { external_options },
69-
) => {
70-
self.format_by_external_formatter(source_text, path, parser_name, external_options)
71-
}
76+
ResolvedOptions::ExternalFormatter { external_options, insert_final_newline },
77+
) => (
78+
self.format_by_external_formatter(source_text, path, parser_name, external_options),
79+
insert_final_newline,
80+
),
7281
#[cfg(feature = "napi")]
7382
(
7483
FormatFileStrategy::ExternalFormatterPackageJson { path, parser_name },
7584
ResolvedOptions::ExternalFormatterPackageJson {
7685
external_options,
7786
sort_package_json,
87+
insert_final_newline,
7888
},
79-
) => self.format_by_external_formatter_package_json(
80-
source_text,
81-
path,
82-
parser_name,
83-
external_options,
84-
sort_package_json,
89+
) => (
90+
self.format_by_external_formatter_package_json(
91+
source_text,
92+
path,
93+
parser_name,
94+
external_options,
95+
sort_package_json,
96+
),
97+
insert_final_newline,
8598
),
8699
_ => unreachable!("FormatFileStrategy and ResolvedOptions variant mismatch"),
87100
};
88101

89102
match result {
90-
Ok(code) => FormatResult::Success { is_changed: source_text != code, code },
103+
Ok(mut code) => {
104+
// NOTE: `insert_final_newline` relies on the fact that:
105+
// - each formatter already ensures there is traliling newline
106+
// - each formatter does not have an option to disable trailing newline
107+
// So we can trim it here without allocating new string.
108+
if !insert_final_newline {
109+
let trimmed_len = code.trim_end().len();
110+
code.truncate(trimmed_len);
111+
}
112+
113+
FormatResult::Success { is_changed: source_text != code, code }
114+
}
91115
Err(err) => FormatResult::Error(vec![err]),
92116
}
93117
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`insertFinalNewline > editorconfig setting removes final newline 1`] = `
4+
"--- FILE -----------
5+
test.ts
6+
--- BEFORE ---------
7+
const foo = 1
8+
9+
--- AFTER ----------
10+
const foo = 1;
11+
--------------------
12+
13+
--- FILE -----------
14+
test.css
15+
--- BEFORE ---------
16+
.foo { color: red }
17+
18+
--- AFTER ----------
19+
.foo {
20+
color: red;
21+
}
22+
--------------------
23+
24+
--- FILE -----------
25+
test.toml
26+
--- BEFORE ---------
27+
[foo]
28+
bar = 1
29+
30+
--- AFTER ----------
31+
[foo]
32+
bar = 1
33+
--------------------"
34+
`;
35+
36+
exports[`insertFinalNewline > oxfmtrc setting removes final newline 1`] = `
37+
"--- FILE -----------
38+
test.ts
39+
--- BEFORE ---------
40+
const foo = 1
41+
42+
--- AFTER ----------
43+
const foo = 1;
44+
--------------------
45+
46+
--- FILE -----------
47+
test.css
48+
--- BEFORE ---------
49+
.foo { color: red }
50+
51+
--- AFTER ----------
52+
.foo {
53+
color: red;
54+
}
55+
--------------------
56+
57+
--- FILE -----------
58+
test.toml
59+
--- BEFORE ---------
60+
[foo]
61+
bar = 1
62+
63+
--- AFTER ----------
64+
[foo]
65+
bar = 1
66+
--------------------"
67+
`;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
root = true
2+
3+
[*]
4+
insert_final_newline = false
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.foo { color: red }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[foo]
2+
bar = 1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const foo = 1
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"insertFinalNewline": false
3+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.foo { color: red }

0 commit comments

Comments
 (0)