Repository: posit-dev/air (rust)

Issue:
Single sided `~` formulas with non-identifier RHS should add a space
I think we mentioned just keeping things simple here and never adding the space (instead encouraging anonymous functions), but it adds enough noise in dplyr diffs that I feel like we should probably just do it. I don't think it will be that hard. It's also in the style guide

<img width="759" alt="Image" src="https://github.com/user-attachments/assets/628bfe84-aaf5-4273-b738-d7e2f34d5ee1" />

<img width="457" alt="Image" src="https://github.com/user-attachments/assets/17a828a9-5c15-4298-b982-0fab2b30b6a4" />

Pull request:
Add a space in complex anonymous functions
Closes #235 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56a4417..4aaf3d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 # Development version
 
+- Unary formulas (i.e. anonymous functions) like `~ .x + 1` now add a space between the `~` and the right-hand side, unless the right-hand side is very simple, like `~foo` or `~1` (#235).
+
 - Semicolons at the very start or very end of a file no longer cause the parser to panic (#238).
 
 - Assigned pipelines no longer double-indent when a persistent line break is used (#220).
diff --git a/crates/air_r_formatter/src/r/auxiliary/unary_expression.rs b/crates/air_r_formatter/src/r/auxiliary/unary_expression.rs
index c4dd07e..46573f7 100644
--- a/crates/air_r_formatter/src/r/auxiliary/unary_expression.rs
+++ b/crates/air_r_formatter/src/r/auxiliary/unary_expression.rs
@@ -1,4 +1,7 @@
 use crate::prelude::*;
+use air_r_syntax::AnyRExpression;
+use air_r_syntax::RSyntaxKind;
+use air_r_syntax::RSyntaxToken;
 use air_r_syntax::{RUnaryExpression, RUnaryExpressionFields};
 use biome_formatter::write;
 
@@ -11,17 +14,68 @@ impl FormatNodeRule<RUnaryExpression> for FormatRUnaryExpression {
         let operator = operator?;
         let argument = argument?;
 
-        write!(f, [operator.format()])?;
-
-        if f.comments().has_comments(argument.syntax())
-            && !f.comments().is_suppressed(argument.syntax())
-        {
-            // We never break between the operator and its argument except if a
-            // newline separates them
-            write!(f, [soft_block_indent(&argument.format())])
-        } else {
-            // A unary operator always sticks to its argument
-            write!(f, [argument.format()])
-        }
+        let needs_space = is_complex_unary_formula(&operator, &argument);
+
+        let argument = format_with(|f| {
+            if f.comments().has_comments(argument.syntax())
+                && !f.comments().is_suppressed(argument.syntax())
+            {
+                // If an existing comment separates the operator and its argument, we are
+                // forced to line break
+                write!(f, [soft_block_indent(&argument.format())])
+            } else {
+                // Otherwise we never line break
+                write!(f, [argument.format()])
+            }
+        });
+
+        write!(f, [operator.format(), maybe_space(needs_space), argument])
+    }
+}
+
+/// Is this a complex unary formula, like `~ .x + 1`?
+///
+/// Simple unary formulas like `~foo` and `~1` don't have a space between the `~` and the
+/// argument (often used in `tribble()` calls, ggvis, the survey package, etc), but
+/// "complex" unary formulas do.
+///
+/// We say a unary formula is "complex" if the `argument` is non-terminal.
+fn is_complex_unary_formula(operator: &RSyntaxToken, argument: &AnyRExpression) -> bool {
+    matches!(operator.kind(), RSyntaxKind::TILDE) && !is_terminal(argument)
+}
+
+/// Is this [AnyRExpression] considered terminal (or leaf-like)?
+///
+/// This is a bit hand wavy, but we consider a node to be terminal if we format it
+/// verbatim. The goal is for anything that looks something like a call to get a space,
+/// i.e. `~ x + 1`, but for very simple tokens to not get a space, i.e. `~foo` or `~1` or
+/// `~NULL`.
+fn is_terminal(node: &AnyRExpression) -> bool {
+    match node {
+        // Identifiers
+        AnyRExpression::RIdentifier(_) => true,
+
+        // Integer, double, complex, and string literals
+        AnyRExpression::AnyRValue(_) => true,
+
+        // `TRUE` and `FALSE`
+        AnyRExpression::RTrueExpression(_) | AnyRExpression::RFalseExpression(_) => true,
+
+        // `NA` variants, `NaN`, and `NULL`
+        AnyRExpression::RNaExpression(_)
+        | AnyRExpression::RNanExpression(_)
+        | AnyRExpression::RNullExpression(_) => true,
+
+        // `Inf`
+        AnyRExpression::RInfExpression(_) => true,
+
+        // `...` and `..i`
+        AnyRExpression::RDots(_) | AnyRExpression::RDotDotI(_) => true,
+
+        // `next` and `break`
+        AnyRExpression::RNextExpression(_) | AnyRExpression::RBreakExpression(_) => true,
+
+        // Anything else is non-terminal
+        _ => false,
     }
 }


Test patch:
diff --git a/crates/air_r_formatter/tests/specs/r/unary_expression.R b/crates/air_r_formatter/tests/specs/r/unary_expression.R
index ea82747..9733071 100644
--- a/crates/air_r_formatter/tests/specs/r/unary_expression.R
+++ b/crates/air_r_formatter/tests/specs/r/unary_expression.R
@@ -1,6 +1,5 @@
 +1
 -1
-~1
 ?1
 !1
 
@@ -22,3 +21,51 @@
 1 + ++1
 
 ++argument_that_is_really_really_really_really_really_really_really_really_really_long
+
+# ----------------------------------------------------------------------------
+# Unary formulas (i.e. anonymous functions)
+
+# "Terminal" arguments don't have a space between `~` and the argument itself
+~foo
+~.
+~1L
+~1.5
+~1i
+~"foo"
+~TRUE
+~FALSE
+~NA
+~NA_integer_
+~NA_real_
+~NA_character_
+~NA_complex_
+~NaN
+~NULL
+~Inf
+~...
+~..1
+~next
+~break
+
+# But anything else does have a space
+~.x + .y
+~function(x) x
+~identity(.x)
+~{
+  1
+}
+
+# Removes line break
+~
+foo
+~
+1
+~
+.x + .y
+
+# Chained formulas
+~~foo
+~~1 + 2
+1~2
+1~~2
+1~~foo
diff --git a/crates/air_r_formatter/tests/specs/r/unary_expression.R.snap b/crates/air_r_formatter/tests/specs/r/unary_expression.R.snap
index 07bab8b..c943948 100644
--- a/crates/air_r_formatter/tests/specs/r/unary_expression.R.snap
+++ b/crates/air_r_formatter/tests/specs/r/unary_expression.R.snap
@@ -7,7 +7,6 @@ info: r/unary_expression.R
 ```R
 +1
 -1
-~1
 ?1
 !1
 
@@ -30,6 +29,54 @@ info: r/unary_expression.R
 
 ++argument_that_is_really_really_really_really_really_really_really_really_really_long
 
+# ----------------------------------------------------------------------------
+# Unary formulas (i.e. anonymous functions)
+
+# "Terminal" arguments don't have a space between `~` and the argument itself
+~foo
+~.
+~1L
+~1.5
+~1i
+~"foo"
+~TRUE
+~FALSE
+~NA
+~NA_integer_
+~NA_real_
+~NA_character_
+~NA_complex_
+~NaN
+~NULL
+~Inf
+~...
+~..1
+~next
+~break
+
+# But anything else does have a space
+~.x + .y
+~function(x) x
+~identity(.x)
+~{
+  1
+}
+
+# Removes line break
+~
+foo
+~
+1
+~
+.x + .y
+
+# Chained formulas
+~~foo
+~~1 + 2
+1~2
+1~~2
+1~~foo
+
 ```
 
 
@@ -50,7 +97,6 @@ Persistent line breaks: Respect
 ```R
 +1
 -1
-~1
 ?1
 !1
 
@@ -74,9 +120,54 @@ Persistent line breaks: Respect
 1 + ++1
 
 ++argument_that_is_really_really_really_really_really_really_really_really_really_long
+
+# ----------------------------------------------------------------------------
+# Unary formulas (i.e. anonymous functions)
+
+# "Terminal" arguments don't have a space between `~` and the argument itself
+~foo
+~.
+~1L
+~1.5
+~1i
+~"foo"
+~TRUE
+~FALSE
+~NA
+~NA_integer_
+~NA_real_
+~NA_character_
+~NA_complex_
+~NaN
+~NULL
+~Inf
+~...
+~..1
+~next
+~break
+
+# But anything else does have a space
+~ .x + .y
+~ function(x) x
+~ identity(.x)
+~ {
+  1
+}
+
+# Removes line break
+~foo
+~1
+~ .x + .y
+
+# Chained formulas
+~ ~foo
+~ ~ 1 + 2
+1 ~ 2
+1 ~ ~2
+1 ~ ~foo
 ```
 
 # Lines exceeding max width of 80 characters
 ```
-   26: ++argument_that_is_really_really_really_really_really_really_really_really_really_long
+   25: ++argument_that_is_really_really_really_really_really_really_really_really_really_long
 ```