Skip to content

Commit c07fe7c

Browse files
committed
feat(oxfmt): support experimentalOperatorPosition (#25643)
Fixes #16366 --- Option name requires `experimental` prefix for now. Values are: - `"start"` - `"end"`(default) This is also available on playground.
1 parent 2adb8fb commit c07fe7c

12 files changed

Lines changed: 114 additions & 73 deletions

File tree

‎.agents/skills/migrate-oxfmt/SKILL.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ These Prettier options are skipped during migration:
9191
| ------------------------------- | ------------------------------------------------ |
9292
| `endOfLine: "auto"` | Not supported. Use `"lf"` or `"crlf"` explicitly |
9393
| `experimentalTernaries` | Not supported in JS/TS files yet |
94-
| `experimentalOperatorPosition` | Not supported in JS/TS files yet |
9594
| `requirePragma`, `insertPragma` | Not supported |
9695
| `parser`, `filepath` | Not applicable to oxfmt |
9796

‎apps/oxfmt/src-js/cli/migration/migrate-prettier.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ export async function runMigratePrettier() {
9292
console.error(` - "endOfLine: auto" is not supported, skipping...`);
9393
continue;
9494
}
95-
// Oxfmt does not support these experimental options yet
96-
if (key === "experimentalTernaries" || key === "experimentalOperatorPosition") {
95+
// Oxfmt does not support this experimental option yet
96+
if (key === "experimentalTernaries") {
9797
console.error(` - "${key}" is not supported yet`);
9898
continue;
9999
}

‎apps/oxfmt/src-js/config.generated.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export type ArrowParensConfig = "always" | "avoid";
77
export type EmbeddedLanguageFormattingConfig = "auto" | "off";
88
export type EndOfLineConfig = "lf" | "crlf" | "cr";
9+
export type OperatorPositionConfig = "start" | "end";
910
export type HtmlWhitespaceSensitivityConfig = "css" | "strict" | "ignore";
1011
export type JsdocUserConfig = boolean | JsdocConfig;
1112
export type CommentLineStrategyConfig = "singleLine" | "multiline" | "keep";
@@ -92,6 +93,14 @@ export interface Oxfmtrc {
9293
* - Overrides `.editorconfig.end_of_line`
9394
*/
9495
endOfLine?: EndOfLineConfig;
96+
/**
97+
* When expressions wrap lines, print operators at the start of new lines (`"start"`)
98+
* or at the end of previous lines (`"end"`).
99+
*
100+
* - Languages: JS, JSX, TS, TSX
101+
* - Default: `"end"`
102+
*/
103+
experimentalOperatorPosition?: OperatorPositionConfig;
95104
/**
96105
* Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.
97106
*
@@ -421,6 +430,14 @@ export interface FormatConfig {
421430
* - Overrides `.editorconfig.end_of_line`
422431
*/
423432
endOfLine?: EndOfLineConfig;
433+
/**
434+
* When expressions wrap lines, print operators at the start of new lines (`"start"`)
435+
* or at the end of previous lines (`"end"`).
436+
*
437+
* - Languages: JS, JSX, TS, TSX
438+
* - Default: `"end"`
439+
*/
440+
experimentalOperatorPosition?: OperatorPositionConfig;
424441
/**
425442
* Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.
426443
*

‎apps/oxfmt/src/core/options/to_oxc_formatter.rs‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use oxc_formatter::SortTailwindcssOptions;
33
use oxc_formatter::{
44
ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, CommentLineStrategy,
55
CustomGroupDefinition, Expand, GroupEntry, ImportModifier, ImportSelector, JsFormatOptions,
6-
JsdocOptions, LineWrappingStyle, QuoteProperties, QuoteStyle, Semicolons, SortImportsOptions,
7-
SortOrder, TrailingCommas,
6+
JsdocOptions, LineWrappingStyle, OperatorPosition, QuoteProperties, QuoteStyle, Semicolons,
7+
SortImportsOptions, SortOrder, TrailingCommas,
88
};
99
use oxc_formatter_core::{CoreFormatOptions, FormatOptions};
1010

@@ -13,8 +13,8 @@ use super::super::oxfmtrc::SortTailwindcssUserConfig;
1313
use super::super::oxfmtrc::{
1414
ArrowParensConfig, CommentLineStrategyConfig, FormatConfig, HtmlWhitespaceSensitivityConfig,
1515
ImportModifierConfig, ImportSelectorConfig, JsdocUserConfig, LineWrappingStyleConfig,
16-
ObjectWrapConfig, QuotePropsConfig, SortGroupItemConfig, SortImportsUserConfig,
17-
SortOrderConfig, TrailingCommaConfig,
16+
ObjectWrapConfig, OperatorPositionConfig, QuotePropsConfig, SortGroupItemConfig,
17+
SortImportsUserConfig, SortOrderConfig, TrailingCommaConfig,
1818
};
1919

2020
/// Convert `FormatConfig` into `JsFormatOptions` for `oxc_formatter`.
@@ -29,10 +29,8 @@ pub fn to_oxc_formatter(
2929
let mut format_options = JsFormatOptions::default();
3030
format_options.apply_core(core_options);
3131

32-
// NOTE: Not yet supported options:
33-
// [Prettier] experimentalOperatorPosition: "start" | "end"
34-
// [Prettier] experimentalTernaries: boolean
35-
// These are rejected at deserialize time so they never reach here.
32+
// NOTE: [Prettier] experimentalTernaries is not yet supported;
33+
// rejected at deserialize time (`oxfmtrc::reject_experimental_ternaries`) so it never reaches here.
3634

3735
// [Prettier] singleQuote: boolean
3836
if let Some(single_quote) = config.single_quote {
@@ -104,6 +102,14 @@ pub fn to_oxc_formatter(
104102
};
105103
}
106104

105+
// [Prettier] experimentalOperatorPosition: "start" | "end"
106+
if let Some(position) = config.experimental_operator_position {
107+
format_options.operator_position = match position {
108+
OperatorPositionConfig::Start => OperatorPosition::Start,
109+
OperatorPositionConfig::End => OperatorPosition::End,
110+
};
111+
}
112+
107113
// [Prettier] htmlWhitespaceSensitivity: "css" | "strict" | "ignore"
108114
if let Some(sensitivity) = config.html_whitespace_sensitivity {
109115
format_options.html_whitespace_sensitivity_ignore =

‎apps/oxfmt/src/core/options/to_prettier.rs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ use oxc_formatter_core::LineWidth;
88

99
use super::super::oxfmtrc::{
1010
ArrowParensConfig, EmbeddedLanguageFormattingConfig, EndOfLineConfig, FormatConfig,
11-
HtmlWhitespaceSensitivityConfig, ObjectWrapConfig, ProseWrapConfig, QuotePropsConfig,
12-
SortTailwindcssUserConfig, SvelteConfig, SvelteUserConfig, TrailingCommaConfig,
11+
HtmlWhitespaceSensitivityConfig, ObjectWrapConfig, OperatorPositionConfig, ProseWrapConfig,
12+
QuotePropsConfig, SortTailwindcssUserConfig, SvelteConfig, SvelteUserConfig,
13+
TrailingCommaConfig,
1314
};
1415

1516
/// Build base Prettier-compatible options from a typed `FormatConfig`.
@@ -105,6 +106,15 @@ pub fn to_prettier(config: &FormatConfig) -> Value {
105106
if let Some(v) = config.single_attribute_per_line {
106107
obj.insert("singleAttributePerLine".to_string(), Value::from(v));
107108
}
109+
if let Some(v) = config.experimental_operator_position {
110+
obj.insert(
111+
"experimentalOperatorPosition".to_string(),
112+
Value::from(match v {
113+
OperatorPositionConfig::Start => "start",
114+
OperatorPositionConfig::End => "end",
115+
}),
116+
);
117+
}
108118
if let Some(v) = config.embedded_language_formatting {
109119
obj.insert(
110120
"embeddedLanguageFormatting".to_string(),
@@ -376,6 +386,7 @@ mod tests_to_prettier {
376386
"arrowParens": "avoid",
377387
"quoteProps": "consistent",
378388
"objectWrap": "collapse",
389+
"experimentalOperatorPosition": "start",
379390
"embeddedLanguageFormatting": "off",
380391
"proseWrap": "always",
381392
"htmlWhitespaceSensitivity": "ignore",
@@ -391,6 +402,7 @@ mod tests_to_prettier {
391402
assert_eq!(obj.get("arrowParens"), Some(&Value::from("avoid")));
392403
assert_eq!(obj.get("quoteProps"), Some(&Value::from("consistent")));
393404
assert_eq!(obj.get("objectWrap"), Some(&Value::from("collapse")));
405+
assert_eq!(obj.get("experimentalOperatorPosition"), Some(&Value::from("start")));
394406
assert_eq!(obj.get("embeddedLanguageFormatting"), Some(&Value::from("off")));
395407
assert_eq!(obj.get("proseWrap"), Some(&Value::from("always")));
396408
assert_eq!(obj.get("htmlWhitespaceSensitivity"), Some(&Value::from("ignore")));
@@ -423,7 +435,6 @@ mod tests_to_prettier {
423435
"jsdoc",
424436
"overrides",
425437
"ignorePatterns",
426-
"experimentalOperatorPosition",
427438
"experimentalTernaries",
428439
] {
429440
assert!(!obj.contains_key(key), "Key `{key}` must NOT be in Prettier options");

‎apps/oxfmt/src/core/oxfmtrc.rs‎

Lines changed: 15 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,16 @@ pub struct FormatConfig {
159159
/// - Default: `false`
160160
#[serde(skip_serializing_if = "Option::is_none")]
161161
pub single_attribute_per_line: Option<bool>,
162+
/// When expressions wrap lines, print operators at the start of new lines (`"start"`)
163+
/// or at the end of previous lines (`"end"`).
164+
///
165+
/// - Languages: JS, JSX, TS, TSX
166+
/// - Default: `"end"`
167+
#[serde(skip_serializing_if = "Option::is_none")]
168+
pub experimental_operator_position: Option<OperatorPositionConfig>,
162169

163-
// NOTE: These experimental options are not yet supported.
170+
// NOTE: This experimental option is not yet supported.
164171
// Reject at deserialize time so all entry paths (base / overrides / NAPI `resolve()`) are covered uniformly.
165-
#[serde(
166-
skip_serializing_if = "Option::is_none",
167-
deserialize_with = "reject_experimental_operator_position",
168-
default
169-
)]
170-
#[schemars(skip)]
171-
pub experimental_operator_position: Option<String>,
172172
#[serde(
173173
skip_serializing_if = "Option::is_none",
174174
deserialize_with = "reject_experimental_ternaries",
@@ -354,17 +354,6 @@ impl FormatConfig {
354354

355355
// ---
356356

357-
fn reject_experimental_operator_position<'de, D>(d: D) -> Result<Option<String>, D::Error>
358-
where
359-
D: serde::Deserializer<'de>,
360-
{
361-
let v = Option::<String>::deserialize(d)?;
362-
if v.is_some() {
363-
return Err(serde::de::Error::custom("Unsupported option: `experimentalOperatorPosition`"));
364-
}
365-
Ok(v)
366-
}
367-
368357
fn reject_experimental_ternaries<'de, D>(d: D) -> Result<Option<bool>, D::Error>
369358
where
370359
D: serde::Deserializer<'de>,
@@ -416,6 +405,13 @@ pub enum ObjectWrapConfig {
416405
Collapse,
417406
}
418407

408+
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
409+
#[serde(rename_all = "lowercase")]
410+
pub enum OperatorPositionConfig {
411+
Start,
412+
End,
413+
}
414+
419415
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
420416
#[serde(rename_all = "lowercase")]
421417
pub enum EmbeddedLanguageFormattingConfig {
@@ -1007,13 +1003,6 @@ mod tests_json_deep_merge {
10071003
mod tests_reject_experimental {
10081004
use super::*;
10091005

1010-
#[test]
1011-
fn test_reject_experimental_operator_position_in_base() {
1012-
let json = r#"{ "experimentalOperatorPosition": "start" }"#;
1013-
let err = serde_json::from_str::<FormatConfig>(json).unwrap_err();
1014-
assert!(err.to_string().contains("experimentalOperatorPosition"));
1015-
}
1016-
10171006
#[test]
10181007
fn test_reject_experimental_ternaries_in_base() {
10191008
let json = r#"{ "experimentalTernaries": true }"#;
@@ -1024,17 +1013,6 @@ mod tests_reject_experimental {
10241013
#[test]
10251014
fn test_reject_experimental_in_overrides() {
10261015
// `OxfmtOverrideConfig.options: FormatConfig` so the same deserialize_with applies
1027-
let json = r#"{
1028-
"overrides": [
1029-
{
1030-
"files": ["*.ts"],
1031-
"options": { "experimentalOperatorPosition": "end" }
1032-
}
1033-
]
1034-
}"#;
1035-
let err = serde_json::from_str::<Oxfmtrc>(json).unwrap_err();
1036-
assert!(err.to_string().contains("experimentalOperatorPosition"));
1037-
10381016
let json = r#"{
10391017
"overrides": [
10401018
{
@@ -1046,23 +1024,4 @@ mod tests_reject_experimental {
10461024
let err = serde_json::from_str::<Oxfmtrc>(json).unwrap_err();
10471025
assert!(err.to_string().contains("experimentalTernaries"));
10481026
}
1049-
1050-
#[test]
1051-
fn test_reject_experimental_via_napi_resolve_path() {
1052-
// NAPI `resolve()` does `serde_json::from_value::<FormatConfig>(raw_config)`,
1053-
// which goes through the same deserialize_with.
1054-
let raw = serde_json::json!({ "experimentalTernaries": true });
1055-
let err = serde_json::from_value::<FormatConfig>(raw).unwrap_err();
1056-
assert!(err.to_string().contains("experimentalTernaries"));
1057-
}
1058-
1059-
#[test]
1060-
fn test_unset_experimental_does_not_fail() {
1061-
// Sanity: omitting both fields parses cleanly
1062-
let json = r#"{ "printWidth": 120 }"#;
1063-
let config: FormatConfig = serde_json::from_str(json).unwrap();
1064-
assert_eq!(config.print_width, Some(120));
1065-
assert!(config.experimental_operator_position.is_none());
1066-
assert!(config.experimental_ternaries.is_none());
1067-
}
10681027
}

‎napi/playground/index.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ export interface OxcFormatterOptions {
108108
objectWrap?: string
109109
/** Put each attribute on its own line (default: false) */
110110
singleAttributePerLine?: boolean
111+
/** Where to print operators when binary expressions wrap lines: "start" | "end" (default: "end") */
112+
experimentalOperatorPosition?: string
111113
/** Sort imports configuration (default: None) */
112114
sortImports?: OxcSortImportsOptions
113115
}

‎napi/playground/playground.wasi.d.cts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ export interface OxcFormatterOptions {
108108
objectWrap?: string
109109
/** Put each attribute on its own line (default: false) */
110110
singleAttributePerLine?: boolean
111+
/** Where to print operators when binary expressions wrap lines: "start" | "end" (default: "end") */
112+
experimentalOperatorPosition?: string
111113
/** Sort imports configuration (default: None) */
112114
sortImports?: OxcSortImportsOptions
113115
}

‎napi/playground/src/lib.rs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ use oxc::{
3030
};
3131
use oxc_formatter::{
3232
ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, CustomGroupDefinition,
33-
Expand, GroupEntry, ImportModifier, ImportSelector, JsFormatOptions, QuoteProperties,
34-
QuoteStyle, Semicolons, SortImportsOptions, SortOrder, TrailingCommas, default_groups,
35-
default_internal_patterns,
33+
Expand, GroupEntry, ImportModifier, ImportSelector, JsFormatOptions, OperatorPosition,
34+
QuoteProperties, QuoteStyle, Semicolons, SortImportsOptions, SortOrder, TrailingCommas,
35+
default_groups, default_internal_patterns,
3636
};
3737
use oxc_formatter_core::{IndentStyle, IndentWidth, LineEnding, LineWidth};
3838
use oxc_linter::{
@@ -517,6 +517,12 @@ impl Oxc {
517517
}
518518
}
519519

520+
if let Some(ref operator_position) = options.experimental_operator_position
521+
&& let Ok(position) = operator_position.parse::<OperatorPosition>()
522+
{
523+
format_options.operator_position = position;
524+
}
525+
520526
if let Some(ref sort_imports_config) = options.sort_imports {
521527
let order = sort_imports_config
522528
.order

‎napi/playground/src/options.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ pub struct OxcFormatterOptions {
150150
pub object_wrap: Option<String>,
151151
/// Put each attribute on its own line (default: false)
152152
pub single_attribute_per_line: Option<bool>,
153+
/// Where to print operators when binary expressions wrap lines: "start" | "end" (default: "end")
154+
pub experimental_operator_position: Option<String>,
153155
/// Sort imports configuration (default: None)
154156
pub sort_imports: Option<OxcSortImportsOptions>,
155157
}

0 commit comments

Comments
 (0)