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.
This commit is contained in:
leaysgur
2026-08-14 08:12:16 +00:00
parent 2adb8fb329
commit c07fe7c217
12 changed files with 114 additions and 73 deletions
-1
View File
@@ -91,7 +91,6 @@ These Prettier options are skipped during migration:
| ------------------------------- | ------------------------------------------------ |
| `endOfLine: "auto"` | Not supported. Use `"lf"` or `"crlf"` explicitly |
| `experimentalTernaries` | Not supported in JS/TS files yet |
| `experimentalOperatorPosition` | Not supported in JS/TS files yet |
| `requirePragma`, `insertPragma` | Not supported |
| `parser`, `filepath` | Not applicable to oxfmt |
@@ -92,8 +92,8 @@ export async function runMigratePrettier() {
console.error(` - "endOfLine: auto" is not supported, skipping...`);
continue;
}
// Oxfmt does not support these experimental options yet
if (key === "experimentalTernaries" || key === "experimentalOperatorPosition") {
// Oxfmt does not support this experimental option yet
if (key === "experimentalTernaries") {
console.error(` - "${key}" is not supported yet`);
continue;
}
+17
View File
@@ -6,6 +6,7 @@
export type ArrowParensConfig = "always" | "avoid";
export type EmbeddedLanguageFormattingConfig = "auto" | "off";
export type EndOfLineConfig = "lf" | "crlf" | "cr";
export type OperatorPositionConfig = "start" | "end";
export type HtmlWhitespaceSensitivityConfig = "css" | "strict" | "ignore";
export type JsdocUserConfig = boolean | JsdocConfig;
export type CommentLineStrategyConfig = "singleLine" | "multiline" | "keep";
@@ -92,6 +93,14 @@ export interface Oxfmtrc {
* - Overrides `.editorconfig.end_of_line`
*/
endOfLine?: EndOfLineConfig;
/**
* When expressions wrap lines, print operators at the start of new lines (`"start"`)
* or at the end of previous lines (`"end"`).
*
* - Languages: JS, JSX, TS, TSX
* - Default: `"end"`
*/
experimentalOperatorPosition?: OperatorPositionConfig;
/**
* Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.
*
@@ -421,6 +430,14 @@ export interface FormatConfig {
* - Overrides `.editorconfig.end_of_line`
*/
endOfLine?: EndOfLineConfig;
/**
* When expressions wrap lines, print operators at the start of new lines (`"start"`)
* or at the end of previous lines (`"end"`).
*
* - Languages: JS, JSX, TS, TSX
* - Default: `"end"`
*/
experimentalOperatorPosition?: OperatorPositionConfig;
/**
* Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.
*
@@ -3,8 +3,8 @@ use oxc_formatter::SortTailwindcssOptions;
use oxc_formatter::{
ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, CommentLineStrategy,
CustomGroupDefinition, Expand, GroupEntry, ImportModifier, ImportSelector, JsFormatOptions,
JsdocOptions, LineWrappingStyle, QuoteProperties, QuoteStyle, Semicolons, SortImportsOptions,
SortOrder, TrailingCommas,
JsdocOptions, LineWrappingStyle, OperatorPosition, QuoteProperties, QuoteStyle, Semicolons,
SortImportsOptions, SortOrder, TrailingCommas,
};
use oxc_formatter_core::{CoreFormatOptions, FormatOptions};
@@ -13,8 +13,8 @@ use super::super::oxfmtrc::SortTailwindcssUserConfig;
use super::super::oxfmtrc::{
ArrowParensConfig, CommentLineStrategyConfig, FormatConfig, HtmlWhitespaceSensitivityConfig,
ImportModifierConfig, ImportSelectorConfig, JsdocUserConfig, LineWrappingStyleConfig,
ObjectWrapConfig, QuotePropsConfig, SortGroupItemConfig, SortImportsUserConfig,
SortOrderConfig, TrailingCommaConfig,
ObjectWrapConfig, OperatorPositionConfig, QuotePropsConfig, SortGroupItemConfig,
SortImportsUserConfig, SortOrderConfig, TrailingCommaConfig,
};
/// Convert `FormatConfig` into `JsFormatOptions` for `oxc_formatter`.
@@ -29,10 +29,8 @@ pub fn to_oxc_formatter(
let mut format_options = JsFormatOptions::default();
format_options.apply_core(core_options);
// NOTE: Not yet supported options:
// [Prettier] experimentalOperatorPosition: "start" | "end"
// [Prettier] experimentalTernaries: boolean
// These are rejected at deserialize time so they never reach here.
// NOTE: [Prettier] experimentalTernaries is not yet supported;
// rejected at deserialize time (`oxfmtrc::reject_experimental_ternaries`) so it never reaches here.
// [Prettier] singleQuote: boolean
if let Some(single_quote) = config.single_quote {
@@ -104,6 +102,14 @@ pub fn to_oxc_formatter(
};
}
// [Prettier] experimentalOperatorPosition: "start" | "end"
if let Some(position) = config.experimental_operator_position {
format_options.operator_position = match position {
OperatorPositionConfig::Start => OperatorPosition::Start,
OperatorPositionConfig::End => OperatorPosition::End,
};
}
// [Prettier] htmlWhitespaceSensitivity: "css" | "strict" | "ignore"
if let Some(sensitivity) = config.html_whitespace_sensitivity {
format_options.html_whitespace_sensitivity_ignore =
+14 -3
View File
@@ -8,8 +8,9 @@ use oxc_formatter_core::LineWidth;
use super::super::oxfmtrc::{
ArrowParensConfig, EmbeddedLanguageFormattingConfig, EndOfLineConfig, FormatConfig,
HtmlWhitespaceSensitivityConfig, ObjectWrapConfig, ProseWrapConfig, QuotePropsConfig,
SortTailwindcssUserConfig, SvelteConfig, SvelteUserConfig, TrailingCommaConfig,
HtmlWhitespaceSensitivityConfig, ObjectWrapConfig, OperatorPositionConfig, ProseWrapConfig,
QuotePropsConfig, SortTailwindcssUserConfig, SvelteConfig, SvelteUserConfig,
TrailingCommaConfig,
};
/// Build base Prettier-compatible options from a typed `FormatConfig`.
@@ -105,6 +106,15 @@ pub fn to_prettier(config: &FormatConfig) -> Value {
if let Some(v) = config.single_attribute_per_line {
obj.insert("singleAttributePerLine".to_string(), Value::from(v));
}
if let Some(v) = config.experimental_operator_position {
obj.insert(
"experimentalOperatorPosition".to_string(),
Value::from(match v {
OperatorPositionConfig::Start => "start",
OperatorPositionConfig::End => "end",
}),
);
}
if let Some(v) = config.embedded_language_formatting {
obj.insert(
"embeddedLanguageFormatting".to_string(),
@@ -376,6 +386,7 @@ mod tests_to_prettier {
"arrowParens": "avoid",
"quoteProps": "consistent",
"objectWrap": "collapse",
"experimentalOperatorPosition": "start",
"embeddedLanguageFormatting": "off",
"proseWrap": "always",
"htmlWhitespaceSensitivity": "ignore",
@@ -391,6 +402,7 @@ mod tests_to_prettier {
assert_eq!(obj.get("arrowParens"), Some(&Value::from("avoid")));
assert_eq!(obj.get("quoteProps"), Some(&Value::from("consistent")));
assert_eq!(obj.get("objectWrap"), Some(&Value::from("collapse")));
assert_eq!(obj.get("experimentalOperatorPosition"), Some(&Value::from("start")));
assert_eq!(obj.get("embeddedLanguageFormatting"), Some(&Value::from("off")));
assert_eq!(obj.get("proseWrap"), Some(&Value::from("always")));
assert_eq!(obj.get("htmlWhitespaceSensitivity"), Some(&Value::from("ignore")));
@@ -423,7 +435,6 @@ mod tests_to_prettier {
"jsdoc",
"overrides",
"ignorePatterns",
"experimentalOperatorPosition",
"experimentalTernaries",
] {
assert!(!obj.contains_key(key), "Key `{key}` must NOT be in Prettier options");
+15 -56
View File
@@ -159,16 +159,16 @@ pub struct FormatConfig {
/// - Default: `false`
#[serde(skip_serializing_if = "Option::is_none")]
pub single_attribute_per_line: Option<bool>,
/// When expressions wrap lines, print operators at the start of new lines (`"start"`)
/// or at the end of previous lines (`"end"`).
///
/// - Languages: JS, JSX, TS, TSX
/// - Default: `"end"`
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental_operator_position: Option<OperatorPositionConfig>,
// NOTE: These experimental options are not yet supported.
// NOTE: This experimental option is not yet supported.
// Reject at deserialize time so all entry paths (base / overrides / NAPI `resolve()`) are covered uniformly.
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "reject_experimental_operator_position",
default
)]
#[schemars(skip)]
pub experimental_operator_position: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "reject_experimental_ternaries",
@@ -354,17 +354,6 @@ impl FormatConfig {
// ---
fn reject_experimental_operator_position<'de, D>(d: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Option::<String>::deserialize(d)?;
if v.is_some() {
return Err(serde::de::Error::custom("Unsupported option: `experimentalOperatorPosition`"));
}
Ok(v)
}
fn reject_experimental_ternaries<'de, D>(d: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
@@ -416,6 +405,13 @@ pub enum ObjectWrapConfig {
Collapse,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OperatorPositionConfig {
Start,
End,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum EmbeddedLanguageFormattingConfig {
@@ -1007,13 +1003,6 @@ mod tests_json_deep_merge {
mod tests_reject_experimental {
use super::*;
#[test]
fn test_reject_experimental_operator_position_in_base() {
let json = r#"{ "experimentalOperatorPosition": "start" }"#;
let err = serde_json::from_str::<FormatConfig>(json).unwrap_err();
assert!(err.to_string().contains("experimentalOperatorPosition"));
}
#[test]
fn test_reject_experimental_ternaries_in_base() {
let json = r#"{ "experimentalTernaries": true }"#;
@@ -1024,17 +1013,6 @@ mod tests_reject_experimental {
#[test]
fn test_reject_experimental_in_overrides() {
// `OxfmtOverrideConfig.options: FormatConfig` so the same deserialize_with applies
let json = r#"{
"overrides": [
{
"files": ["*.ts"],
"options": { "experimentalOperatorPosition": "end" }
}
]
}"#;
let err = serde_json::from_str::<Oxfmtrc>(json).unwrap_err();
assert!(err.to_string().contains("experimentalOperatorPosition"));
let json = r#"{
"overrides": [
{
@@ -1046,23 +1024,4 @@ mod tests_reject_experimental {
let err = serde_json::from_str::<Oxfmtrc>(json).unwrap_err();
assert!(err.to_string().contains("experimentalTernaries"));
}
#[test]
fn test_reject_experimental_via_napi_resolve_path() {
// NAPI `resolve()` does `serde_json::from_value::<FormatConfig>(raw_config)`,
// which goes through the same deserialize_with.
let raw = serde_json::json!({ "experimentalTernaries": true });
let err = serde_json::from_value::<FormatConfig>(raw).unwrap_err();
assert!(err.to_string().contains("experimentalTernaries"));
}
#[test]
fn test_unset_experimental_does_not_fail() {
// Sanity: omitting both fields parses cleanly
let json = r#"{ "printWidth": 120 }"#;
let config: FormatConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.print_width, Some(120));
assert!(config.experimental_operator_position.is_none());
assert!(config.experimental_ternaries.is_none());
}
}
+2
View File
@@ -108,6 +108,8 @@ export interface OxcFormatterOptions {
objectWrap?: string
/** Put each attribute on its own line (default: false) */
singleAttributePerLine?: boolean
/** Where to print operators when binary expressions wrap lines: "start" | "end" (default: "end") */
experimentalOperatorPosition?: string
/** Sort imports configuration (default: None) */
sortImports?: OxcSortImportsOptions
}
+2
View File
@@ -108,6 +108,8 @@ export interface OxcFormatterOptions {
objectWrap?: string
/** Put each attribute on its own line (default: false) */
singleAttributePerLine?: boolean
/** Where to print operators when binary expressions wrap lines: "start" | "end" (default: "end") */
experimentalOperatorPosition?: string
/** Sort imports configuration (default: None) */
sortImports?: OxcSortImportsOptions
}
+9 -3
View File
@@ -30,9 +30,9 @@ use oxc::{
};
use oxc_formatter::{
ArrowParentheses, AttributePosition, BracketSameLine, BracketSpacing, CustomGroupDefinition,
Expand, GroupEntry, ImportModifier, ImportSelector, JsFormatOptions, QuoteProperties,
QuoteStyle, Semicolons, SortImportsOptions, SortOrder, TrailingCommas, default_groups,
default_internal_patterns,
Expand, GroupEntry, ImportModifier, ImportSelector, JsFormatOptions, OperatorPosition,
QuoteProperties, QuoteStyle, Semicolons, SortImportsOptions, SortOrder, TrailingCommas,
default_groups, default_internal_patterns,
};
use oxc_formatter_core::{IndentStyle, IndentWidth, LineEnding, LineWidth};
use oxc_linter::{
@@ -517,6 +517,12 @@ impl Oxc {
}
}
if let Some(ref operator_position) = options.experimental_operator_position
&& let Ok(position) = operator_position.parse::<OperatorPosition>()
{
format_options.operator_position = position;
}
if let Some(ref sort_imports_config) = options.sort_imports {
let order = sort_imports_config
.order
+2
View File
@@ -150,6 +150,8 @@ pub struct OxcFormatterOptions {
pub object_wrap: Option<String>,
/// Put each attribute on its own line (default: false)
pub single_attribute_per_line: Option<bool>,
/// Where to print operators when binary expressions wrap lines: "start" | "end" (default: "end")
pub experimental_operator_position: Option<String>,
/// Sort imports configuration (default: None)
pub sort_imports: Option<OxcSortImportsOptions>,
}
+25
View File
@@ -41,6 +41,15 @@
],
"markdownDescription": "Which end of line characters to apply.\n\nNOTE: `\"auto\"` is not supported.\n\n- Languages: All\n- Default: `\"lf\"`\n- Overrides `.editorconfig.end_of_line`"
},
"experimentalOperatorPosition": {
"description": "When expressions wrap lines, print operators at the start of new lines (`\"start\"`)\nor at the end of previous lines (`\"end\"`).\n\n- Languages: JS, JSX, TS, TSX\n- Default: `\"end\"`",
"allOf": [
{
"$ref": "#/definitions/OperatorPositionConfig"
}
],
"markdownDescription": "When expressions wrap lines, print operators at the start of new lines (`\"start\"`)\nor at the end of previous lines (`\"end\"`).\n\n- Languages: JS, JSX, TS, TSX\n- Default: `\"end\"`"
},
"htmlWhitespaceSensitivity": {
"description": "Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.\n\n- Languages: HTML, Angular, Vue, Handlebars, Svelte\n- Default: `\"css\"`",
"allOf": [
@@ -307,6 +316,15 @@
],
"markdownDescription": "Which end of line characters to apply.\n\nNOTE: `\"auto\"` is not supported.\n\n- Languages: All\n- Default: `\"lf\"`\n- Overrides `.editorconfig.end_of_line`"
},
"experimentalOperatorPosition": {
"description": "When expressions wrap lines, print operators at the start of new lines (`\"start\"`)\nor at the end of previous lines (`\"end\"`).\n\n- Languages: JS, JSX, TS, TSX\n- Default: `\"end\"`",
"allOf": [
{
"$ref": "#/definitions/OperatorPositionConfig"
}
],
"markdownDescription": "When expressions wrap lines, print operators at the start of new lines (`\"start\"`)\nor at the end of previous lines (`\"end\"`).\n\n- Languages: JS, JSX, TS, TSX\n- Default: `\"end\"`"
},
"htmlWhitespaceSensitivity": {
"description": "Specify the global whitespace sensitivity for HTML, Vue, Angular, and Handlebars.\n\n- Languages: HTML, Angular, Vue, Handlebars, Svelte\n- Default: `\"css\"`",
"allOf": [
@@ -601,6 +619,13 @@
"collapse"
]
},
"OperatorPositionConfig": {
"type": "string",
"enum": [
"start",
"end"
]
},
"OxfmtOverrideConfig": {
"type": "object",
"required": [
@@ -71,6 +71,18 @@ NOTE: `"auto"` is not supported.
- Overrides `.editorconfig.end_of_line`
## experimentalOperatorPosition
type: `"start" | "end"`
When expressions wrap lines, print operators at the start of new lines (`"start"`)
or at the end of previous lines (`"end"`).
- Languages: JS, JSX, TS, TSX
- Default: `"end"`
## htmlWhitespaceSensitivity
type: `"css" | "strict" | "ignore"`