From 5077b32b32ca7ce44f7038bfc2502001f9b8f777 Mon Sep 17 00:00:00 2001 From: harehare Date: Thu, 16 Jul 2026 22:00:00 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mq-formatter):=20add=20--max-w?= =?UTF-8?q?idth=20for=20automatic=20pipeline=20wrapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormatterConfig gains an optional max_width. When set, `|`-chained pipelines that grow past the limit are wrapped onto multiple lines using the same style as manually written multi-line pipelines, instead of collapsing onto a single ever-growing line. Exposed as `mq-fmt --max-width`/`-w` and via mq-web-api's format endpoint. --- crates/mq-formatter/README.md | 4 ++ crates/mq-formatter/benches/benchmark.rs | 1 + crates/mq-formatter/src/formatter.rs | 67 +++++++++++++++++++++++- crates/mq-formatter/src/main.rs | 9 +++- crates/mq-web-api/src/api.rs | 4 ++ 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/mq-formatter/README.md b/crates/mq-formatter/README.md index 2c8e9cd51..1ccb373f5 100644 --- a/crates/mq-formatter/README.md +++ b/crates/mq-formatter/README.md @@ -53,6 +53,9 @@ mq-fmt --indent-width 4 file.mq # Sort imports and functions mq-fmt --sort-imports --sort-functions file.mq + +# Wrap long `|`-chained pipelines at 80 columns +mq-fmt --max-width 80 file.mq ``` ### Via mq @@ -67,6 +70,7 @@ mq fmt --check file.mq | Option | Short | Description | Default | | ------------------ | ----- | ---------------------------------------- | ------- | | `--indent-width` | `-i` | Number of spaces for indentation | `2` | +| `--max-width` | `-w` | Wrap `\|`-chained pipelines that exceed this line width | unset (no wrapping) | | `--check` | `-c` | Check formatting without modifying files | `false` | | `--sort-imports` | | Sort import statements | `false` | | `--sort-functions` | | Sort function definitions | `false` | diff --git a/crates/mq-formatter/benches/benchmark.rs b/crates/mq-formatter/benches/benchmark.rs index 7ac3f890a..21fa4c90f 100644 --- a/crates/mq-formatter/benches/benchmark.rs +++ b/crates/mq-formatter/benches/benchmark.rs @@ -52,6 +52,7 @@ macro m(): test;"#; sort_imports: true, sort_functions: true, sort_fields: true, + max_width: None, }; let mut formatter = Formatter::new(Some(config)); formatter.format(code).unwrap(); diff --git a/crates/mq-formatter/src/formatter.rs b/crates/mq-formatter/src/formatter.rs index 183d3fb5c..8c60e49cd 100644 --- a/crates/mq-formatter/src/formatter.rs +++ b/crates/mq-formatter/src/formatter.rs @@ -30,6 +30,7 @@ pub struct FormatterConfig { pub sort_imports: bool, pub sort_functions: bool, pub sort_fields: bool, + pub max_width: Option, } impl Default for FormatterConfig { @@ -39,6 +40,7 @@ impl Default for FormatterConfig { sort_imports: false, sort_functions: false, sort_fields: false, + max_width: None, } } } @@ -212,7 +214,7 @@ impl Formatter { } fn format_node(&mut self, node: mq_lang::Shared, indent_level: usize) { - let has_leading_new_line = node.has_new_line(); + let has_leading_new_line = node.has_new_line() || (node.is_pipe() && self.should_wrap_pipe()); let indent_level_consider_new_line = if has_leading_new_line { indent_level } else { 0 }; if !matches!( @@ -1570,8 +1572,15 @@ impl Formatter { self.output.push(' '); } mq_lang::TokenKind::Pipe => { - if node.has_new_line() { + let force_wrap = !node.has_new_line() && self.should_wrap_pipe(); + + if node.has_new_line() || force_wrap { self.append_leading_trivia(node, indent_level); + + if force_wrap { + self.append_newline(); + } + self.append_indent(indent_level); self.output.push_str(&token.to_string()); self.output.push(' '); @@ -1619,6 +1628,19 @@ impl Formatter { last_line.chars().take_while(|c| *c == ' ').count() / self.config.indent_width } + #[inline(always)] + fn current_line_width(&self) -> usize { + let start = self.output.rfind('\n').map_or(0, |pos| pos + 1); + self.output[start..].chars().count() + } + + #[inline(always)] + fn should_wrap_pipe(&self) -> bool { + self.config + .max_width + .is_some_and(|max_width| self.current_line_width() >= max_width) + } + #[inline(always)] pub fn is_last_line_pipe(&self) -> bool { let output = self.output.trim_end_matches('\n'); @@ -3079,8 +3101,49 @@ macro mac_x(): test;"#, sort_imports: true, sort_functions: true, sort_fields: true, + max_width: None, }; let result = Formatter::new(Some(config)).format(code); assert_eq!(result.unwrap(), expected); } + + #[rstest] + #[case::wraps_top_level_pipeline( + "select(\"h1\") | upcase() | add_class(\"title\") | trim() | to_text() | replace(\"a\",\"b\") | join(\",\")", + 40, + "select(\"h1\") | upcase() | add_class(\"title\")\n| trim() | to_text() | replace(\"a\", \"b\")\n| join(\",\")\n" + )] + #[case::wraps_pipeline_inside_def( + "def foo(x):\n select(\"h1\") | upcase() | add_class(\"title\") | trim() | to_text() | replace(\"a\",\"b\") | join(\",\");", + 40, + "def foo(x):\n select(\"h1\") | upcase() | add_class(\"title\")\n | trim() | to_text() | replace(\"a\", \"b\")\n | join(\",\");\n" + )] + #[case::keeps_short_pipeline_inline("select(\"h1\") | upcase()", 80, "select(\"h1\") | upcase()")] + fn test_format_with_max_width(#[case] code: &str, #[case] max_width: usize, #[case] expected: &str) { + let config = FormatterConfig { + max_width: Some(max_width), + ..Default::default() + }; + let result = Formatter::new(Some(config)).format(code); + assert_eq!(result.unwrap(), expected); + } + + #[test] + fn test_format_with_max_width_is_idempotent() { + let code = "select(\"h1\") | upcase() | add_class(\"title\") | trim() | to_text() | replace(\"a\",\"b\") | join(\",\")"; + let config = || FormatterConfig { + max_width: Some(40), + ..Default::default() + }; + let once = Formatter::new(Some(config())).format(code).unwrap(); + let twice = Formatter::new(Some(config())).format(&once).unwrap(); + assert_eq!(once, twice); + } + + #[test] + fn test_format_without_max_width_does_not_wrap() { + let code = "select(\"h1\") | upcase() | add_class(\"title\") | trim() | to_text() | replace(\"a\", \"b\") | join(\",\")"; + let result = Formatter::default().format(code); + assert_eq!(result.unwrap(), code); + } } diff --git a/crates/mq-formatter/src/main.rs b/crates/mq-formatter/src/main.rs index 3d0c9a39c..b29ff2a70 100644 --- a/crates/mq-formatter/src/main.rs +++ b/crates/mq-formatter/src/main.rs @@ -15,12 +15,18 @@ use std::{fs, path::PathBuf}; ## To check if files are formatted without modifying them:\n\ mq-fmt --check file.mq\n\n\ ## To format with custom indent width:\n\ - mq-fmt --indent-width 4 file.mq")] + mq-fmt --indent-width 4 file.mq\n\n\ + ## To wrap long pipelines at a maximum line width:\n\ + mq-fmt --max-width 80 file.mq")] struct Cli { /// Number of spaces for indentation #[arg(short, long, default_value_t = 2)] indent_width: usize, + /// Maximum line width before long `|`-chained pipelines are wrapped onto multiple lines + #[arg(short = 'w', long)] + max_width: Option, + /// Check if files are formatted without modifying them #[arg(short, long)] check: bool, @@ -49,6 +55,7 @@ fn main() -> miette::Result<()> { sort_imports: cli.sort_imports, sort_fields: cli.sort_fields, sort_functions: cli.sort_functions, + max_width: cli.max_width, })); let files = match cli.files { diff --git a/crates/mq-web-api/src/api.rs b/crates/mq-web-api/src/api.rs index c5627dbd8..ccfbab287 100644 --- a/crates/mq-web-api/src/api.rs +++ b/crates/mq-web-api/src/api.rs @@ -141,6 +141,7 @@ pub struct FormatApiRequest { pub sort_imports: Option, pub sort_functions: Option, pub sort_fields: Option, + pub max_width: Option, } /// Response body for `POST /api/format`. @@ -260,6 +261,7 @@ pub fn format_query(request: FormatApiRequest) -> miette::Result