Repository: posit-dev/air (rust)

Issue:
RHS assignment persistent line breaks introduce extra indentation in pipes
```bash
❯ cat test.R
data <-
  starwars |>
  filter(height > 172) |>
  select(1:3)

❯ air format test.R
❯ cat test.R       
data <-
  starwars |>
    filter(height > 172) |>
    select(1:3)

❯ air --version    
air 0.3.0
```

As a fan of the persistent line break in this case, I really want all of the expressions in the pipeline to be indented to the same level:

```r
# 😊
data <-
  starwars |>
  filter(height > 172) |>
  select(1:3)

# 😥
data <-
  starwars |>
    filter(height > 172) |>
    select(1:3)
```

Pull request description:
Fix indentation of assigned pipelines
Closes #220.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2217cc..3544ff0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
 
 # Development version
 
+- Assigned pipelines no longer double-indent when a persistent line break is used (#220).
+
+
 # 0.3.0
 
 - Air has gained support for excluding files and folders (#128).
@@ -21,6 +24,7 @@
 
 - ARM Windows binaries are now available (#170).
 
+
 # 0.2.0
 
 - Initial public release, yay!
diff --git a/crates/air_r_formatter/src/either.rs b/crates/air_r_formatter/src/either.rs
new file mode 100644
index 0000000..647fe66
--- /dev/null
+++ b/crates/air_r_formatter/src/either.rs
@@ -0,0 +1,22 @@
+use biome_formatter::{prelude::Formatter, Format, FormatResult};
+
+/// An owned value that generically represents two `Format` types
+///
+/// Can be chained.
+pub enum Either<L, R> {
+    Left(L),
+    Right(R),
+}
+
+impl<L, R, Context> Format<Context> for Either<L, R>
+where
+    L: Format<Context>,
+    R: Format<Context>,
+{
+    fn fmt(&self, f: &mut Formatter<Context>) -> FormatResult<()> {
+        match self {
+            Either::Left(left) => left.fmt(f),
+            Either::Right(right) => right.fmt(f),
+        }
+    }
+}
diff --git a/crates/air_r_formatter/src/lib.rs b/crates/air_r_formatter/src/lib.rs
index 4da1788..3471c84 100644
--- a/crates/air_r_formatter/src/lib.rs
+++ b/crates/air_r_formatter/src/lib.rs
@@ -21,6 +21,7 @@ use crate::cst::FormatRSyntaxNode;
 pub mod comments;
 pub mod context;
 mod cst;
+pub mod either;
 mod prelude;
 mod r;
 pub(crate) mod separated;
diff --git a/crates/air_r_formatter/src/r/auxiliary/binary_expression.rs b/crates/air_r_formatter/src/r/auxiliary/binary_expression.rs
index 71727a7..b9925d2 100644
--- a/crates/air_r_formatter/src/r/auxiliary/binary_expression.rs
+++ b/crates/air_r_formatter/src/r/auxiliary/binary_expression.rs
@@ -1,4 +1,5 @@
 use crate::context::RFormatOptions;
+use crate::either::Either;
 use crate::prelude::*;
 use air_r_syntax::AnyRExpression;
 use air_r_syntax::RBinaryExpression;
@@ -8,12 +9,55 @@ use air_r_syntax::RSyntaxKind;
 use biome_formatter::format_args;
 use biome_formatter::write;
 use biome_formatter::CstFormatContext;
+use biome_formatter::FormatRuleWithOptions;
 use biome_rowan::AstNode;
 use biome_rowan::SyntaxResult;
 use biome_rowan::SyntaxToken;
 
-#[derive(Debug, Clone, Default)]
-pub(crate) struct FormatRBinaryExpression;
+#[derive(Default, Debug, Clone, Copy)]
+pub(crate) enum ChainAlignment {
+    #[default]
+    Indented,
+
+    /// Used in the RHS of assign operators to align pipelines.
+    LeftAligned,
+}
+
+#[derive(Default, Debug, Clone)]
+pub(crate) struct FormatRBinaryExpression {
+    /// Alignment to use with chained expressions
+    ///
+    /// Left alignment is used to prevent "double-indenting" an
+    /// assigned pipeline, e.g.:
+    ///
+    /// ```r
+    /// foo <-
+    ///   bar |>
+    ///   baz()
+    ///
+    /// foo <-
+    ///   bar +
+    ///   baz()
+    /// ```
+    ///
+    /// See https://github.com/posit-dev/air/issues/220.
+    pub(crate) alignment: ChainAlignment,
+}
+
+#[derive(Default, Debug, Clone)]
+pub(crate) struct FormatRBinaryExpressionOptions {
+    pub(crate) alignment: ChainAlignment,
+}
+
+impl FormatRuleWithOptions<RBinaryExpression> for FormatRBinaryExpression {
+    type Options = FormatRBinaryExpressionOptions;
+
+    fn with_options(mut self, options: Self::Options) -> Self {
+        self.alignment = options.alignment;
+        self
+    }
+}
+
 impl FormatNodeRule<RBinaryExpression> for FormatRBinaryExpression {
     fn fmt_fields(&self, node: &RBinaryExpression, f: &mut RFormatter) -> FormatResult<()> {
         let RBinaryExpressionFields {
@@ -42,7 +86,7 @@ impl FormatNodeRule<RBinaryExpression> for FormatRBinaryExpression {
             | RSyntaxKind::SUPER_ASSIGN_RIGHT => fmt_binary_assignment(left, operator, right, f),
 
             // Chainable (pipes, logical, arithmetic)
-            kind if is_chainable_binary_operator(kind)  => fmt_binary_chain(left, operator, right, f),
+            kind if is_chainable_binary_operator(kind)  => fmt_binary_chain(left, operator, right, self.alignment, f),
 
             // Not chainable
             // Formulas (debatable)
@@ -147,10 +191,15 @@ fn fmt_binary_assignment(
 ) -> FormatResult<()> {
     let right = format_with(|f| {
         if binary_assignment_has_persistent_line_break(&operator, &right, f.options()) {
-            write!(
-                f,
-                [indent(&format_args![hard_line_break(), right.format()])]
-            )
+            let right = match &right {
+                AnyRExpression::RBinaryExpression(right) => {
+                    Either::Left(right.format().with_options(FormatRBinaryExpressionOptions {
+                        alignment: ChainAlignment::LeftAligned,
+                    }))
+                }
+                right => Either::Right(right.format()),
+            };
+            write!(f, [indent(&format_args![hard_line_break(), right])])
         } else {
             write!(f, [space(), right.format()])
         }
@@ -424,6 +473,7 @@ fn fmt_binary_chain(
     mut left: AnyRExpression,
     operator: SyntaxToken<RLanguage>,
     right: AnyRExpression,
+    alignment: ChainAlignment,
     f: &mut Formatter<RFormatContext>,
 ) -> FormatResult<()> {
     // For the lead node in a binary chain, comments are handled by the standard
@@ -529,9 +579,14 @@ fn fmt_binary_chain(
         Ok(())
     });
 
+    let chain = match alignment {
+        ChainAlignment::Indented => Either::Left(indent(&chain)),
+        ChainAlignment::LeftAligned => Either::Right(chain),
+    };
+
     write!(
         f,
-        [group(&format_args![left.format(), indent(&chain)])
+        [group(&format_args![left.format(), &chain])
             .should_expand(has_persistent_line_break(&tail, f.options()))]
     )
 }


Test patch:
diff --git a/crates/air_r_formatter/tests/specs/r/binary_expression.R b/crates/air_r_formatter/tests/specs/r/binary_expression.R
index 143269d..e438fa0 100644
--- a/crates/air_r_formatter/tests/specs/r/binary_expression.R
+++ b/crates/air_r_formatter/tests/specs/r/binary_expression.R
@@ -508,3 +508,26 @@ base_version <-
   b_get(brand, "defaults", "shiny", "theme", "version") %||%
   b_get(brand, "defaults", "bootstrap", "version") %||%
   version_default()
+
+
+# https://github.com/posit-dev/air/issues/220
+data <-
+  starwars |>
+  filter(height > 172) |>
+  select(1:3)
+
+plot <-
+  ggplot() +
+  geom_point()
+
+foo <-
+  1 +
+  2
+
+foo <-
+  TRUE ||
+  FALSE
+
+# Persistent assign-newline and unbroken pipeline
+data <-
+  ggplot() + geom_point()
diff --git a/crates/air_r_formatter/tests/specs/r/binary_expression.R.snap b/crates/air_r_formatter/tests/specs/r/binary_expression.R.snap
index 5f408ba..3d40b37 100644
--- a/crates/air_r_formatter/tests/specs/r/binary_expression.R.snap
+++ b/crates/air_r_formatter/tests/specs/r/binary_expression.R.snap
@@ -516,6 +516,29 @@ base_version <-
   b_get(brand, "defaults", "bootstrap", "version") %||%
   version_default()
 
+
+# https://github.com/posit-dev/air/issues/220
+data <-
+  starwars |>
+  filter(height > 172) |>
+  select(1:3)
+
+plot <-
+  ggplot() +
+  geom_point()
+
+foo <-
+  1 +
+  2
+
+foo <-
+  TRUE ||
+  FALSE
+
+# Persistent assign-newline and unbroken pipeline
+data <-
+  ggplot() + geom_point()
+
 ```
 
 
@@ -1161,9 +1184,31 @@ is_condition_true <-
 # https://github.com/posit-dev/air/issues/91
 base_version <-
   version %||%
-    b_get(brand, "defaults", "shiny", "theme", "version") %||%
-    b_get(brand, "defaults", "bootstrap", "version") %||%
-    version_default()
+  b_get(brand, "defaults", "shiny", "theme", "version") %||%
+  b_get(brand, "defaults", "bootstrap", "version") %||%
+  version_default()
+
+# https://github.com/posit-dev/air/issues/220
+data <-
+  starwars |>
+  filter(height > 172) |>
+  select(1:3)
+
+plot <-
+  ggplot() +
+  geom_point()
+
+foo <-
+  1 +
+  2
+
+foo <-
+  TRUE ||
+  FALSE
+
+# Persistent assign-newline and unbroken pipeline
+data <-
+  ggplot() + geom_point()
 ```
 
 # Lines exceeding max width of 80 characters