Repository: posit-dev/air (rust)

Issue:
Trailing `;` at the end of the file causes a parser panic
Minimal reprex

```r
1;
```

This is the cause of the styler panic

This is related to us not _actually_ handling semicolons and just having them magically accidentally fall out of the parse tree

Pull request:
Skip semicolons as whitespace when not between two tokens
Closes #238 

And adds a whole bunch of extra tests, we didn't have any before

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86a3cc7..56a4417 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 # Development version
 
+- 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).
 
 - Hugging calls like:
diff --git a/crates/air_r_formatter/src/r/auxiliary/call_arguments.rs b/crates/air_r_formatter/src/r/auxiliary/call_arguments.rs
index 14b3194..417652e 100644
--- a/crates/air_r_formatter/src/r/auxiliary/call_arguments.rs
+++ b/crates/air_r_formatter/src/r/auxiliary/call_arguments.rs
@@ -991,8 +991,15 @@ fn is_hugging_call(
         return Ok(false);
     }
 
-    // Unwrap the value to get the `AnyRExpression`
-    let Some(arg) = item.element().node()?.value() else {
+    let arg = item.element().node()?;
+
+    // We only consider hugging for an unnamed argument
+    // (Particularly meaningful for `mutate(key = value)` where you don't expect hugging)
+    if arg.name_clause().is_some() {
+        return Ok(false);
+    }
+
+    let Some(arg) = arg.value() else {
         return Ok(false);
     };
 
diff --git a/crates/air_r_parser/src/parse.rs b/crates/air_r_parser/src/parse.rs
index 75ce8c8..2822f8d 100644
--- a/crates/air_r_parser/src/parse.rs
+++ b/crates/air_r_parser/src/parse.rs
@@ -1117,6 +1117,12 @@ impl RParse {
     ///
     /// Derive the implied stream of trivia that exists in that gap
     ///
+    /// TODO(semicolon): tree-sitter-r does not emit semicolons in the parse tree, so they
+    /// technically also appear here in `text`. We hackily patch over this for now by
+    /// consuming semicolons as whitespace, which ends up getting ignored by the
+    /// formatter. It would be nice to have official semicolon handling, and then in the
+    /// formatter we'd just use `format_removed()` to officially remove semicolons.
+    ///
     /// SAFETY: `last_end <= next_start`
     /// SAFETY: `last_end` and `next_start` must define a range within `text`
     fn derive_trivia(&mut self, text: &str, mut start: TextSize, between_two_tokens: bool) {
@@ -1153,6 +1159,8 @@ impl RParse {
                 let _ = iter.next();
             }
 
+            // Push the range of whitespace
+            // TODO(semicolon): This currently includes semicolons as whitespace
             if start != end {
                 let range = TextRange::new(start, end);
                 self.push_trivia(Trivia::new(TriviaPieceKind::Whitespace, range, trailing));
@@ -1215,6 +1223,8 @@ impl RParse {
         // `WHS` maps newlines as "whitespace" but we handle that specially
         match biome_unicode_table::lookup_byte(byte) {
             Dispatch::WHS => byte != b'\r' && byte != b'\n',
+            // TODO(semicolon): Mapping semicolons as whitespace until we handle them officially
+            Dispatch::SEM => true,
             _ => false,
         }
     }


Test patch:
diff --git a/crates/air_r_formatter/tests/specs/r/call.R b/crates/air_r_formatter/tests/specs/r/call.R
index da4d040..8b215a4 100644
--- a/crates/air_r_formatter/tests/specs/r/call.R
+++ b/crates/air_r_formatter/tests/specs/r/call.R
@@ -672,6 +672,19 @@ c(list(foobar(
   2
 )))
 
+# Named arguments prevent hugging
+fn(name = foobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbazzzzzzzzzfoobarbaz(1, 2))
+
+# Named arguments prevent hugging - motivating example
+# - With the `mutate()`, we expect multiple key/value pairs so hugging
+#   after the first one doesn't feel quite right, and actually makes it
+#   a little difficult to add additional key/value pairs
+# - With the `filter()`, we accept hugging here and justify it by saying
+#   that you end up reading this as `filterany`, which is kind of nice
+storms <- storms %>%
+  mutate(name = if_else(str_sub(name, 1, 3) %in% c("AL0", "AL1"), name, str_to_title(name))) %>%
+  filter(any(status %in% c("hurricane", "tropical storm", "tropical depression")))
+
 # Sanity checks for comments
 
 c(
diff --git a/crates/air_r_formatter/tests/specs/r/call.R.snap b/crates/air_r_formatter/tests/specs/r/call.R.snap
index 286250c..4534918 100644
--- a/crates/air_r_formatter/tests/specs/r/call.R.snap
+++ b/crates/air_r_formatter/tests/specs/r/call.R.snap
@@ -679,6 +679,19 @@ c(list(foobar(
   2
 )))
 
+# Named arguments prevent hugging
+fn(name = foobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbazzzzzzzzzfoobarbaz(1, 2))
+
+# Named arguments prevent hugging - motivating example
+# - With the `mutate()`, we expect multiple key/value pairs so hugging
+#   after the first one doesn't feel quite right, and actually makes it
+#   a little difficult to add additional key/value pairs
+# - With the `filter()`, we accept hugging here and justify it by saying
+#   that you end up reading this as `filterany`, which is kind of nice
+storms <- storms %>%
+  mutate(name = if_else(str_sub(name, 1, 3) %in% c("AL0", "AL1"), name, str_to_title(name))) %>%
+  filter(any(status %in% c("hurricane", "tropical storm", "tropical depression")))
+
 # Sanity checks for comments
 
 c(
@@ -1524,6 +1537,32 @@ c(list(foobar(
   2
 )))
 
+# Named arguments prevent hugging
+fn(
+  name = foobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbazzzzzzzzzfoobarbaz(
+    1,
+    2
+  )
+)
+
+# Named arguments prevent hugging - motivating example
+# - With the `mutate()`, we expect multiple key/value pairs so hugging
+#   after the first one doesn't feel quite right, and actually makes it
+#   a little difficult to add additional key/value pairs
+# - With the `filter()`, we accept hugging here and justify it by saying
+#   that you end up reading this as `filterany`, which is kind of nice
+storms <- storms %>%
+  mutate(
+    name = if_else(
+      str_sub(name, 1, 3) %in% c("AL0", "AL1"),
+      name,
+      str_to_title(name)
+    )
+  ) %>%
+  filter(any(
+    status %in% c("hurricane", "tropical storm", "tropical depression")
+  ))
+
 # Sanity checks for comments
 
 c(
@@ -1652,4 +1691,5 @@ foo(bar[
   701:   foobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbazzzzzzzzzfoobarbaz
   705: c(list(foobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbazzzzzzzzzfoobarbaz()))
   708: c(list(foobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbazzzzzzzzzfoobarbaz(
+  721:   name = foobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbafoobarbazzzzzzzzzfoobarbaz(
 ```
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-01.R b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-01.R
new file mode 100644
index 0000000..106a966
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-01.R
@@ -0,0 +1,2 @@
+# https://github.com/posit-dev/air/issues/238
+1;
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-01.R.snap b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-01.R.snap
new file mode 100644
index 0000000..7e28411
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-01.R.snap
@@ -0,0 +1,38 @@
+---
+source: crates/air_r_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```R
+# https://github.com/posit-dev/air/issues/238
+1;
+
+```
+
+
+## AST
+
+```
+RRoot {
+    bom_token: missing (optional),
+    expressions: RExpressionList [
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@0..47 "1" [Comments("# https://github.com/ ..."), Newline("\n")] [],
+        },
+    ],
+    eof_token: EOF@47..49 "" [Whitespace(";"), Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: R_ROOT@0..49
+  0: (empty)
+  1: R_EXPRESSION_LIST@0..47
+    0: R_DOUBLE_VALUE@0..47
+      0: R_DOUBLE_LITERAL@0..47 "1" [Comments("# https://github.com/ ..."), Newline("\n")] []
+  2: EOF@47..49 "" [Whitespace(";"), Newline("\n")] []
+
+```
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-02.R b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-02.R
new file mode 100644
index 0000000..bdf8121
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-02.R
@@ -0,0 +1,1 @@
+1;;
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-02.R.snap b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-02.R.snap
new file mode 100644
index 0000000..eef807c
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-02.R.snap
@@ -0,0 +1,37 @@
+---
+source: crates/air_r_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```R
+1;;
+
+```
+
+
+## AST
+
+```
+RRoot {
+    bom_token: missing (optional),
+    expressions: RExpressionList [
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@0..1 "1" [] [],
+        },
+    ],
+    eof_token: EOF@1..4 "" [Whitespace(";;"), Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: R_ROOT@0..4
+  0: (empty)
+  1: R_EXPRESSION_LIST@0..1
+    0: R_DOUBLE_VALUE@0..1
+      0: R_DOUBLE_LITERAL@0..1 "1" [] []
+  2: EOF@1..4 "" [Whitespace(";;"), Newline("\n")] []
+
+```
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-03.R b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-03.R
new file mode 100644
index 0000000..5c099ac
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-03.R
@@ -0,0 +1,3 @@
+1
+
+;
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-03.R.snap b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-03.R.snap
new file mode 100644
index 0000000..7adaaef
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-end-of-file-03.R.snap
@@ -0,0 +1,39 @@
+---
+source: crates/air_r_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```R
+1
+
+;
+
+```
+
+
+## AST
+
+```
+RRoot {
+    bom_token: missing (optional),
+    expressions: RExpressionList [
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@0..1 "1" [] [],
+        },
+    ],
+    eof_token: EOF@1..5 "" [Newline("\n"), Newline("\n"), Whitespace(";"), Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: R_ROOT@0..5
+  0: (empty)
+  1: R_EXPRESSION_LIST@0..1
+    0: R_DOUBLE_VALUE@0..1
+      0: R_DOUBLE_LITERAL@0..1 "1" [] []
+  2: EOF@1..5 "" [Newline("\n"), Newline("\n"), Whitespace(";"), Newline("\n")] []
+
+```
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-01.R b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-01.R
new file mode 100644
index 0000000..c203e43
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-01.R
@@ -0,0 +1,2 @@
+;
+1
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-01.R.snap b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-01.R.snap
new file mode 100644
index 0000000..2788a43
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-01.R.snap
@@ -0,0 +1,38 @@
+---
+source: crates/air_r_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```R
+;
+1
+
+```
+
+
+## AST
+
+```
+RRoot {
+    bom_token: missing (optional),
+    expressions: RExpressionList [
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@0..3 "1" [Whitespace(";"), Newline("\n")] [],
+        },
+    ],
+    eof_token: EOF@3..4 "" [Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: R_ROOT@0..4
+  0: (empty)
+  1: R_EXPRESSION_LIST@0..3
+    0: R_DOUBLE_VALUE@0..3
+      0: R_DOUBLE_LITERAL@0..3 "1" [Whitespace(";"), Newline("\n")] []
+  2: EOF@3..4 "" [Newline("\n")] []
+
+```
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-02.R b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-02.R
new file mode 100644
index 0000000..17d762e
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-02.R
@@ -0,0 +1,1 @@
+;1
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-02.R.snap b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-02.R.snap
new file mode 100644
index 0000000..dd4cc3f
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolon-start-of-file-02.R.snap
@@ -0,0 +1,37 @@
+---
+source: crates/air_r_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```R
+;1
+
+```
+
+
+## AST
+
+```
+RRoot {
+    bom_token: missing (optional),
+    expressions: RExpressionList [
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@0..2 "1" [Whitespace(";")] [],
+        },
+    ],
+    eof_token: EOF@2..3 "" [Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: R_ROOT@0..3
+  0: (empty)
+  1: R_EXPRESSION_LIST@0..2
+    0: R_DOUBLE_VALUE@0..2
+      0: R_DOUBLE_LITERAL@0..2 "1" [Whitespace(";")] []
+  2: EOF@2..3 "" [Newline("\n")] []
+
+```
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolons.R b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolons.R
new file mode 100644
index 0000000..9acbe43
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolons.R
@@ -0,0 +1,16 @@
+1;2
+
+1 ; 2
+
+1;;2
+
+{
+    1;
+}
+
+{
+    1; 2
+}
+
+1;# comment
+1; # comment
diff --git a/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolons.R.snap b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolons.R.snap
new file mode 100644
index 0000000..695621c
--- /dev/null
+++ b/crates/air_r_parser/tests/snapshots/ok/semicolons/semicolons.R.snap
@@ -0,0 +1,122 @@
+---
+source: crates/air_r_parser/tests/spec_test.rs
+expression: snapshot
+---
+## Input
+
+```R
+1;2
+
+1 ; 2
+
+1;;2
+
+{
+    1;
+}
+
+{
+    1; 2
+}
+
+1;# comment
+1; # comment
+
+```
+
+
+## AST
+
+```
+RRoot {
+    bom_token: missing (optional),
+    expressions: RExpressionList [
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@0..1 "1" [] [],
+        },
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@1..3 "2" [Whitespace(";")] [],
+        },
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@3..6 "1" [Newline("\n"), Newline("\n")] [],
+        },
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@6..10 "2" [Whitespace(" ; ")] [],
+        },
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@10..13 "1" [Newline("\n"), Newline("\n")] [],
+        },
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@13..16 "2" [Whitespace(";;")] [],
+        },
+        RBracedExpressions {
+            l_curly_token: L_CURLY@16..19 "{" [Newline("\n"), Newline("\n")] [],
+            expressions: RExpressionList [
+                RDoubleValue {
+                    value_token: R_DOUBLE_LITERAL@19..26 "1" [Newline("\n"), Whitespace("    ")] [Whitespace(";")],
+                },
+            ],
+            r_curly_token: R_CURLY@26..28 "}" [Newline("\n")] [],
+        },
+        RBracedExpressions {
+            l_curly_token: L_CURLY@28..31 "{" [Newline("\n"), Newline("\n")] [],
+            expressions: RExpressionList [
+                RDoubleValue {
+                    value_token: R_DOUBLE_LITERAL@31..37 "1" [Newline("\n"), Whitespace("    ")] [],
+                },
+                RDoubleValue {
+                    value_token: R_DOUBLE_LITERAL@37..40 "2" [Whitespace("; ")] [],
+                },
+            ],
+            r_curly_token: R_CURLY@40..42 "}" [Newline("\n")] [],
+        },
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@42..55 "1" [Newline("\n"), Newline("\n")] [Whitespace(";"), Comments("# comment")],
+        },
+        RDoubleValue {
+            value_token: R_DOUBLE_LITERAL@55..68 "1" [Newline("\n")] [Whitespace("; "), Comments("# comment")],
+        },
+    ],
+    eof_token: EOF@68..69 "" [Newline("\n")] [],
+}
+```
+
+## CST
+
+```
+0: R_ROOT@0..69
+  0: (empty)
+  1: R_EXPRESSION_LIST@0..68
+    0: R_DOUBLE_VALUE@0..1
+      0: R_DOUBLE_LITERAL@0..1 "1" [] []
+    1: R_DOUBLE_VALUE@1..3
+      0: R_DOUBLE_LITERAL@1..3 "2" [Whitespace(";")] []
+    2: R_DOUBLE_VALUE@3..6
+      0: R_DOUBLE_LITERAL@3..6 "1" [Newline("\n"), Newline("\n")] []
+    3: R_DOUBLE_VALUE@6..10
+      0: R_DOUBLE_LITERAL@6..10 "2" [Whitespace(" ; ")] []
+    4: R_DOUBLE_VALUE@10..13
+      0: R_DOUBLE_LITERAL@10..13 "1" [Newline("\n"), Newline("\n")] []
+    5: R_DOUBLE_VALUE@13..16
+      0: R_DOUBLE_LITERAL@13..16 "2" [Whitespace(";;")] []
+    6: R_BRACED_EXPRESSIONS@16..28
+      0: L_CURLY@16..19 "{" [Newline("\n"), Newline("\n")] []
+      1: R_EXPRESSION_LIST@19..26
+        0: R_DOUBLE_VALUE@19..26
+          0: R_DOUBLE_LITERAL@19..26 "1" [Newline("\n"), Whitespace("    ")] [Whitespace(";")]
+      2: R_CURLY@26..28 "}" [Newline("\n")] []
+    7: R_BRACED_EXPRESSIONS@28..42
+      0: L_CURLY@28..31 "{" [Newline("\n"), Newline("\n")] []
+      1: R_EXPRESSION_LIST@31..40
+        0: R_DOUBLE_VALUE@31..37
+          0: R_DOUBLE_LITERAL@31..37 "1" [Newline("\n"), Whitespace("    ")] []
+        1: R_DOUBLE_VALUE@37..40
+          0: R_DOUBLE_LITERAL@37..40 "2" [Whitespace("; ")] []
+      2: R_CURLY@40..42 "}" [Newline("\n")] []
+    8: R_DOUBLE_VALUE@42..55
+      0: R_DOUBLE_LITERAL@42..55 "1" [Newline("\n"), Newline("\n")] [Whitespace(";"), Comments("# comment")]
+    9: R_DOUBLE_VALUE@55..68
+      0: R_DOUBLE_LITERAL@55..68 "1" [Newline("\n")] [Whitespace("; "), Comments("# comment")]
+  2: EOF@68..69 "" [Newline("\n")] []
+
+```