Repository: posit-dev/air (rust)

Issue:
Ignore auto generated files and an option to include auto generated files
> I think there is a general consensus to ignore files with keywords like `@generated`.
> 
> So that the Cargo.lock files also contain it.
> 
> https://github.com/posit-dev/air/blob/16d3b53a7258106ab28a40d2e47573913d445dad/Cargo.lock#L1 

 _Originally posted by @eitsupi in [#128](https://github.com/posit-dev/air/issues/128#issuecomment-2610076285)_

For example
- rustfmt: <https://rust-lang.github.io/rustfmt/?version=v1.6.0&search=#format_generated_files>
- Go: <https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source>

Any file that contains a `@generated` comment marker within the first 5 lines of the file is considered auto-generated and excluded, but can optionally include these auto-generated files.

In the absence of such a thing, we would need to maintain a list like the one included in #213, which I believe is not sustainable.

Pull request:
Add support for skipping a file
Branched from #223.

Closes #219.

Patch:
diff --git a/crates/air_r_formatter/src/lib.rs b/crates/air_r_formatter/src/lib.rs
index 3471c84..1c38c9e 100644
--- a/crates/air_r_formatter/src/lib.rs
+++ b/crates/air_r_formatter/src/lib.rs
@@ -194,7 +194,7 @@ where
     // This is the method that actually starts the formatting
     fn fmt(&self, node: &N, f: &mut RFormatter) -> FormatResult<()> {
         if self.is_suppressed(node, f) {
-            return write!(f, [format_suppressed_node(node.syntax())]);
+            return self.fmt_suppressed(node, f);
         }
 
         self.fmt_leading_comments(node, f)?;
@@ -210,6 +210,14 @@ where
         f.context().comments().is_suppressed(node.syntax())
     }
 
+    /// Formats a suppressed node
+    ///
+    /// You may want to override this method if you need to manually handle
+    /// formatting of a suppressed node. This should be extremely rare.
+    fn fmt_suppressed(&self, node: &N, f: &mut RFormatter) -> FormatResult<()> {
+        write!(f, [format_suppressed_node(node.syntax())])
+    }
+
     /// Formats the [leading comments](biome_formatter::comments#leading-comments) of the node.
     ///
     /// You may want to override this method if you want to manually handle the formatting of comments
diff --git a/crates/air_r_formatter/src/r/auxiliary/root.rs b/crates/air_r_formatter/src/r/auxiliary/root.rs
index ad1154d..bb6c630 100644
--- a/crates/air_r_formatter/src/r/auxiliary/root.rs
+++ b/crates/air_r_formatter/src/r/auxiliary/root.rs
@@ -1,10 +1,25 @@
 use crate::prelude::*;
 use air_r_syntax::RRoot;
 use air_r_syntax::RRootFields;
+use air_r_syntax::RSyntaxNode;
 use biome_formatter::write;
+use biome_formatter::CstFormatContext;
+use biome_rowan::Direction;
+use biome_rowan::SyntaxElement;
+use comments::{parse_comment_directive, parse_special_skip_file, Directive, FormatDirective};
 
 #[derive(Debug, Clone, Default)]
 pub(crate) struct FormatRRoot;
+
+impl FormatRRoot {
+    fn is_suppression_directive(directive: Option<Directive>) -> bool {
+        matches!(
+            directive,
+            Some(Directive::Format(FormatDirective::SkipFile))
+        )
+    }
+}
+
 impl FormatNodeRule<RRoot> for FormatRRoot {
     fn fmt_fields(&self, node: &RRoot, f: &mut RFormatter) -> FormatResult<()> {
         let RRootFields {
@@ -23,4 +38,74 @@ impl FormatNodeRule<RRoot> for FormatRRoot {
             ]
         )
     }
+
+    fn is_suppressed(&self, node: &RRoot, f: &RFormatter) -> bool {
+        let comments = f.context().comments();
+        comments.mark_suppression_checked(node.syntax());
+
+        let Some(child) = node.expressions().first() else {
+            return false;
+        };
+
+        // Only consider leading comments of the first node in the file
+        let mut comments = comments
+            .leading_comments(child.syntax())
+            .iter()
+            .map(|cmt| cmt.piece().text());
+
+        let Some(first_line) = comments.next() else {
+            return false;
+        };
+
+        // The very first line is allowed to be `Generated by:`
+        if Self::is_suppression_directive(
+            parse_special_skip_file(first_line).or_else(|| parse_comment_directive(first_line)),
+        ) {
+            return true;
+        }
+
+        for comment in comments {
+            if Self::is_suppression_directive(parse_comment_directive(comment)) {
+                return true;
+            }
+        }
+
+        false
+    }
+
+    // The default handling of suppressed nodes in `FormatNodeRule::fmt()`
+    // does not work out well for `RRoot` because it doesn't format
+    // trivia verbatim. So we use a custom implementation instead.
+    fn fmt_suppressed(&self, node: &RRoot, f: &mut RFormatter) -> FormatResult<()> {
+        // Mark everything as visited so the formatter doesn't panic
+        // thinking we've missed elements. We're going to print each element
+        // manually with `dynamic_text()`.
+        mark_visited(node.syntax(), f);
+
+        // `node.text()` trims trivia so we get the raw text on the syntax
+        // node instead
+        let text: String = node.syntax().text().into();
+
+        // Formatting with `format_suppressed_node()` does not work well
+        // here because it only formats the node verbatim, not the leading
+        // and trailing trivia
+        dynamic_text(&text, node.syntax().text_range().start()).fmt(f)
+    }
+}
+
+// Copied from `biome_formatter::FormatVerbatimNode.fmt()`
+fn mark_visited(node: &RSyntaxNode, f: &mut RFormatter) {
+    for element in node.descendants_with_tokens(Direction::Next) {
+        match element {
+            SyntaxElement::Token(token) => f.state_mut().track_token(&token),
+            SyntaxElement::Node(node) => {
+                let comments = f.context().comments();
+                comments.mark_suppression_checked(&node);
+
+                for comment in comments.leading_dangling_trailing_comments(&node) {
+                    comment.mark_formatted();
+                }
+            }
+        }
+    }
 }
diff --git a/crates/comments/src/directive.rs b/crates/comments/src/directive.rs
index 43b836d..748c42c 100644
--- a/crates/comments/src/directive.rs
+++ b/crates/comments/src/directive.rs
@@ -29,18 +29,31 @@ pub enum FormatDirective {
 /// ```
 ///
 /// Note that directives are applied to the node they are attached to.
+///
+/// `text` should be single line but we don't check for this. A potential usage
+/// of this function is to iterate over a document line by line to scan for a
+/// directive.
 pub fn parse_comment_directive(text: &str) -> Option<Directive> {
-    let text = text.strip_prefix('#')?;
-    let text = text.trim_start();
+    let text = trim_comment(text)?;
+
     let (category, text) = text.split_once(':')?;
     let text = text.trim();
 
+    #[allow(clippy::single_match)]
     match category {
         "fmt" => parse_format_directive(text),
         _ => None,
     }
 }
 
+// This returns `None` if `text` is not a comment
+fn trim_comment(text: &str) -> Option<&str> {
+    let text = text.strip_prefix('#')?;
+    let text = text.trim_start_matches('#');
+    let text = text.trim_start();
+    Some(text)
+}
+
 #[inline]
 fn parse_format_directive(text: &str) -> Option<Directive> {
     match text {
@@ -50,9 +63,23 @@ fn parse_format_directive(text: &str) -> Option<Directive> {
     }
 }
 
+// https://github.com/posit-dev/air/issues/219
+// Should be called only on the first line in a block of comments.
+pub fn parse_special_skip_file(text: &str) -> Option<Directive> {
+    let text = trim_comment(text)?;
+
+    // Convention used in the R community (Roxygen, Rcpp, etc)
+    if text.starts_with("Generated by") {
+        return Some(Directive::Format(FormatDirective::SkipFile));
+    }
+
+    None
+}
+
 #[cfg(test)]
 mod test {
     use crate::parse_comment_directive;
+    use crate::parse_special_skip_file;
     use crate::Directive;
 
     #[test]
@@ -83,6 +110,9 @@ mod test {
         assert_eq!(parse_comment_directive("#fmt:skip"), format_skip);
         assert_eq!(parse_comment_directive("#  fmt:  skip  "), format_skip);
 
+        // Can have multiple `#`
+        assert_eq!(parse_comment_directive("## fmt: skip"), format_skip);
+
         assert_eq!(
             parse_comment_directive("# fmt: skip file"),
             format_skip_file
@@ -92,5 +122,8 @@ mod test {
             parse_comment_directive("#  fmt:  skip file"),
             format_skip_file
         );
+
+        assert!(parse_comment_directive("# Generated by:").is_none());
+        assert_eq!(parse_special_skip_file("# Generated by:"), format_skip_file);
     }
 }
diff --git a/crates/comments/src/lib.rs b/crates/comments/src/lib.rs
index a26a670..bd5cf38 100644
--- a/crates/comments/src/lib.rs
+++ b/crates/comments/src/lib.rs
@@ -1,5 +1,6 @@
 mod directive;
 
 pub use directive::parse_comment_directive;
+pub use directive::parse_special_skip_file;
 pub use directive::Directive;
 pub use directive::FormatDirective;
diff --git a/docs/formatter.qmd b/docs/formatter.qmd
index ee594d1..56ff8a7 100644
--- a/docs/formatter.qmd
+++ b/docs/formatter.qmd
@@ -202,7 +202,7 @@ This must be placed at the top of the file before the first non-comment R code,
 This is useful for generated files, or as an alternative to `exclude` for individual files that you know you'd like to exclude from formatting.
 
 ``` r
-# Generated file - Don't modify by hand!
+# This is a generated file. Don't modify by hand!
 # fmt: skip file
 
 # This won't be formatted
@@ -216,6 +216,12 @@ another_generated_function_signature <- function(which, might, overflow, the, li
 }
 ```
 
+For generated files we also support the convention of a comment starting with `Generated by` at the top of the file:
+
+```
+# Generated by myPackage: don't modify by hand
+```
+
 # When does a formatter run?
 
 The most convenient way to run a formatter is when you save a file.


Test patch:
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file-not.R b/crates/air_r_formatter/tests/specs/r/directives/skip-file-not.R
new file mode 100644
index 0000000..ec2e415
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file-not.R
@@ -0,0 +1,19 @@
+# foo
+
+NULL
+
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file-not.R.snap b/crates/air_r_formatter/tests/specs/r/directives/skip-file-not.R.snap
new file mode 100644
index 0000000..af5ab4a
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file-not.R.snap
@@ -0,0 +1,59 @@
+---
+source: crates/air_formatter_test/src/snapshot_builder.rs
+info: r/directives/skip-file-not.R
+---
+# Input
+
+```R
+# foo
+
+NULL
+
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+```
+
+
+=============================
+
+# Outputs
+
+## Output 1
+
+-----
+Indent style: Space
+Indent width: 2
+Line ending: LF
+Line width: 80
+Persistent line breaks: Respect
+-----
+
+```R
+# foo
+
+NULL
+
+# fmt: skip file
+
+1 + 1
+
+# bar
+
+2
+
+#foo
+```
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing-separated.R b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing-separated.R
new file mode 100644
index 0000000..b950144
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing-separated.R
@@ -0,0 +1,23 @@
+
+
+
+# Some leading comment
+
+
+# This is in a second comment block but we allow it for convenience.
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing-separated.R.snap b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing-separated.R.snap
new file mode 100644
index 0000000..c4145b1
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing-separated.R.snap
@@ -0,0 +1,73 @@
+---
+source: crates/air_formatter_test/src/snapshot_builder.rs
+info: r/directives/skip-file-trailing-separated.R
+---
+# Input
+
+```R
+
+
+
+# Some leading comment
+
+
+# This is in a second comment block but we allow it for convenience.
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
+```
+
+
+=============================
+
+# Outputs
+
+## Output 1
+
+-----
+Indent style: Space
+Indent width: 2
+Line ending: LF
+Line width: 80
+Persistent line breaks: Respect
+-----
+
+```R
+
+
+
+# Some leading comment
+
+
+# This is in a second comment block but we allow it for convenience.
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+```
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing.R b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing.R
new file mode 100644
index 0000000..862b7bc
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing.R
@@ -0,0 +1,20 @@
+
+
+# Some leading comment
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing.R.snap b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing.R.snap
new file mode 100644
index 0000000..41f7af9
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file-trailing.R.snap
@@ -0,0 +1,67 @@
+---
+source: crates/air_formatter_test/src/snapshot_builder.rs
+info: r/directives/skip-file-trailing.R
+---
+# Input
+
+```R
+
+
+# Some leading comment
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
+
+```
+
+
+=============================
+
+# Outputs
+
+## Output 1
+
+-----
+Indent style: Space
+Indent width: 2
+Line ending: LF
+Line width: 80
+Persistent line breaks: Respect
+-----
+
+```R
+
+
+# Some leading comment
+# fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
+```
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file.R b/crates/air_r_formatter/tests/specs/r/directives/skip-file.R
new file mode 100644
index 0000000..884018a
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file.R
@@ -0,0 +1,17 @@
+## fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-file.R.snap b/crates/air_r_formatter/tests/specs/r/directives/skip-file.R.snap
new file mode 100644
index 0000000..8f6a0be
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-file.R.snap
@@ -0,0 +1,61 @@
+---
+source: crates/air_formatter_test/src/snapshot_builder.rs
+info: r/directives/skip-file.R
+---
+# Input
+
+```R
+## fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
+
+```
+
+
+=============================
+
+# Outputs
+
+## Output 1
+
+-----
+Indent style: Space
+Indent width: 2
+Line ending: LF
+Line width: 80
+Persistent line breaks: Respect
+-----
+
+```R
+## fmt: skip file
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
+```
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-generated-by.R b/crates/air_r_formatter/tests/specs/r/directives/skip-generated-by.R
new file mode 100644
index 0000000..4ac330f
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-generated-by.R
@@ -0,0 +1,17 @@
+# Generated by foo; do not edit
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
diff --git a/crates/air_r_formatter/tests/specs/r/directives/skip-generated-by.R.snap b/crates/air_r_formatter/tests/specs/r/directives/skip-generated-by.R.snap
new file mode 100644
index 0000000..2a25fef
--- /dev/null
+++ b/crates/air_r_formatter/tests/specs/r/directives/skip-generated-by.R.snap
@@ -0,0 +1,61 @@
+---
+source: crates/air_formatter_test/src/snapshot_builder.rs
+info: r/directives/skip-generated-by.R
+---
+# Input
+
+```R
+# Generated by foo; do not edit
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
+
+```
+
+
+=============================
+
+# Outputs
+
+## Output 1
+
+-----
+Indent style: Space
+Indent width: 2
+Line ending: LF
+Line width: 80
+Persistent line breaks: Respect
+-----
+
+```R
+# Generated by foo; do not edit
+
+1 + 1
+
+
+
+# bar
+
+
+
+2
+
+
+
+#foo
+
+
+```