Implement roxygen2 @examples support - #516
Conversation
| // Something failed, like a parse error. Totally fine and expected. Fall back to | ||
| // verbatim formatter. | ||
| FormatRoxygenVerbatim::new(self.section.comments(), self.prefix).fmt(f) |
There was a problem hiding this comment.
Important design decision:
We don't format @examples blocks with:
- parse errors
\dontrun{}(or similar), which cause a parse error
We silently swallow these and fall back to verbatim formatting.
| // This | ||
| // | ||
| // ``` | ||
| // #' @examples 1 + 1 | ||
| // #' 2 + 2 | ||
| // ``` | ||
| // | ||
| // is normalized to | ||
| // | ||
| // ``` | ||
| // #' @examples | ||
| // #' 1 + 1 | ||
| // #' 2 + 2 | ||
| // ``` |
There was a problem hiding this comment.
Important design decision:
I think this makes sense. Should be rare, but I think it should be moved down.
| // It's somewhat common to interleave multiple `@examples` and `@examplesIf` | ||
| // sections into a single roxygen block. When this happens, you often need to put | ||
| // a blank line after `@examplesIf` to ensure the final result is readable with or | ||
| // without the `@examplesIf` body (i.e., you can't put the blank line before the | ||
| // `@examplesIf` to achieve the same result). We allow that by writing a single | ||
| // empty roxygen line if the first line of the body was empty. Air's formatter | ||
| // will otherwise strip all leading blank lines out. | ||
| // | ||
| // ```r | ||
| // #' @examples | ||
| // #' fn(1) | ||
| // #' fn(2) | ||
| // #' @examplesIf has_pkg() | ||
| // #' | ||
| // #' # `fn(2)` works with pkg | ||
| // #' pkg::this(fn(2)) | ||
| // #' @examples | ||
| // #' | ||
| // #' # Another feature | ||
| // #' another_demo(fn(3)) | ||
| // fn <- function(x) { x } | ||
| // ``` | ||
| let needs_leading_empty_line = lines.first().is_some_and(|line| line.trim().is_empty()); | ||
|
|
||
| // It's also common to have an empty roxygen comment between sections for | ||
| // readability. We preserve that by writing a single empty roxygen line if the | ||
| // last body line of this section was empty. | ||
| // | ||
| // ```r | ||
| // #' @param x A number. | ||
| // #' | ||
| // #' @examples | ||
| // #' fn(1) | ||
| // #' fn(2) | ||
| // #' | ||
| // #' @returns | ||
| // #' Something | ||
| // fn <- function(x) { x } | ||
| // ``` |
There was a problem hiding this comment.
Important design decision:
Unlike a normal .R file, in an @examples block there are valid cases for wanting to keep at most 1 leading or trailing empty line. If the user had one in the original source, we keep it.
I think the trailing empty line is particularly compelling, and I tend to do that quite a bit.
| // To compute the actual line width used for the roxygen examples: | ||
| // - Start with user requested line width | ||
| // - Subtract leading indent before the `#` | ||
| // - Subtract prefix width, i.e. the `#'` or `##'` size | ||
| // - Subtract 1, for a space following the prefix | ||
| let line_width = (options.line_width().value()) | ||
| .saturating_sub(self.indent.into()) | ||
| .saturating_sub(self.prefix.len().into()) | ||
| .saturating_sub(1) | ||
| .max(1); | ||
|
|
||
| let Ok(line_width) = LineWidth::try_from(line_width) else { | ||
| // Should never happen | ||
| return Ok(false); | ||
| }; | ||
|
|
||
| // Start with user's formatting options and apply overrides | ||
| // | ||
| // The reconstructed lines are emitted via `dynamic_text()`, so the text must | ||
| // contain `\n` line endings. The printer will rewrite them if required. | ||
| // | ||
| // Example sections always follow `#'`, i.e. they aren't the first thing on a | ||
| // line. This means they should always be indented with spaces, even if the user | ||
| // requests tabs for the surrounding file. But we do respect the user's | ||
| // `IndentWidth`. | ||
| let options = options | ||
| .clone() | ||
| .with_line_width(line_width) | ||
| .with_line_ending(LineEnding::Lf) | ||
| .with_indent_style(IndentStyle::Space) | ||
| .with_roxygen_examples(RoxygenExamples::Disabled); |
There was a problem hiding this comment.
When we format the @examples subdocument, we start with the user's options and override a few things:
- The
line_widthis reduced to account for#'and any leading indents (think, nested R6 docs). This isn't an exact science, more on this later. - We must use spaces for any indents. We aren't at the beginning of the line, we are past a
#', so emitting tabs at this point in time would be a bit crazy, and IDEs don't respect your "visible indent width" when you don't have tabs at the beginning of the line. We do still respect your indent-width size though. - We force
\nline breaks, which helps with splitting by\nlater on.
| // TODO: Can we make `InsertFinalNewline` an air option for library style usage? | ||
| // This isn't the first time we've had to strip it back off. | ||
| // | ||
| // Air currently strips off all user trailing line breaks and then unconditionally | ||
| // appends a single final `hard_line_break()` in `FormatRRoot`. We want to be in | ||
| // charge of trailing line breaks, so we strip that off here. | ||
| let text = text.strip_suffix('\n').unwrap_or(&text); |
There was a problem hiding this comment.
This isn't the first time I've had to strip off this unconditional hard_line_break()
We also do it here
air/crates/lsp/src/handlers_format.rs
Lines 155 to 156 in 3f0d006
I think that it might be useful to make InsertFinalNewline an element of FormatOptions that isn't actually exposed to users, but can be used on the library side when embedding Air like we do here.
| /// Measures the leading indentation of a roxygen comment from the original source | ||
| /// | ||
| /// This value is used to compute the adjusted line width that R code in `@examples` | ||
| /// and `@examplesIf` sections are wrapped at. | ||
| /// | ||
| /// This adjusted line width is computed on a best effort basis, and should be correct for | ||
| /// nearly all real world usage. However, it relies on the pre-format indent width of the | ||
| /// node that the comment is attached to, because the post-format indent width is a print | ||
| /// time decision, and is unknowable at this time. | ||
| /// | ||
| /// Consider the following snippet from an R6 class: | ||
| /// | ||
| /// ```r | ||
| /// public = list( | ||
| /// #' @examples | ||
| /// #' fn(something_really_long_here) | ||
| /// fn = function() {} | ||
| /// ) | ||
| /// ``` | ||
| /// | ||
| /// Here we'd compute the pre-format leading indentation as 0, so the adjusted line width | ||
| /// would be 80 - `#' `.len() - 0 = 77. If `fn(something_really_long_here)` was 76 | ||
| /// characters wide, then it would be left as is. But post-format, we'd indent `fn` and | ||
| /// get this: | ||
| /// | ||
| /// ```r | ||
| /// public = list( | ||
| /// #' @examples | ||
| /// #' fn(something_really_long_here) | ||
| /// fn = function() {} | ||
| /// ) | ||
| /// ``` | ||
| /// | ||
| /// Recomputing the adjusted line width now gives `80 - `#' `.len() - 2 = 75`, and on a | ||
| /// second pass that would cause a 76 character wide `fn(something_really_long_here)` to | ||
| /// break. This goes against idempotence, but is such a rare case that we don't worry | ||
| /// about it. |
There was a problem hiding this comment.
Important design decision:
To format the @examples section, we compute an adjusted line width based on the user's preferred line width, minus the #' prefix, minus any leading indent in the original source (think, an R6 class with docs that are indented).
99.9% of the time this is totally fine, but there are extremely rare cases where this can result in idempotence issues for 1 pass, and then it stabilizes. I think this section documents this limitation quite well, and we have explicit tests for it. I'm cool with it.
| /// Formatter for one section of a roxygen block | ||
| pub(crate) enum FormatRoxygenSection<'a> { | ||
| /// Leading description before any roxygen tags | ||
| Introduction(verbatim::FormatRoxygenVerbatim<'a>), | ||
|
|
||
| /// `@examples` and `@examplesIf` | ||
| Examples(examples::FormatRoxygenExamples<'a>), | ||
|
|
||
| /// An unhandled roxygen tag | ||
| Unknown(verbatim::FormatRoxygenVerbatim<'a>), | ||
| } |
There was a problem hiding this comment.
We parse the entire roxygen block into sections by tag. Each section is printed independently with its own rules. Unknown sections are printed verbatim. This sets us up to add prose formatting in the future as well.
This:
#' The introduction
#'
#' Some more introduction lines
#'
#' @param x Some stuff
#'
#' @param y Some stuff that is really long
#' and overflows onto the next line
#'
#' @examples
#' 1 + 1
#' 2 + 2breaks into these sections:
#' The introduction
#'
#' Some more introduction lines
#' #' @param x Some stuff
#'#' @param y Some stuff that is really long
#' and overflows onto the next line
#' #' @examples
#' 1 + 1
#' 2 + 2| /// Air's replacement for [biome_formatter::trivia::format_leading_comments()] | ||
| /// | ||
| /// Splits a `node`'s leading comments into runs of: | ||
| /// - [crate::trivia::roxygen::FormatRoxygenComments] | ||
| /// - [biome_formatter::trivia::FormatLeadingComments] | ||
| pub(crate) fn format_leading_comments(node: &RSyntaxNode) -> FormatLeadingComments<'_> { | ||
| FormatLeadingComments { node } | ||
| } |
There was a problem hiding this comment.
Our main entry point that replaces biome_formatter::trivia::format_leading_comments(). This formats both standard leading comments and recognized roxygen comments.
| fn fmt_leading_comments(&self, node: &N, f: &mut RFormatter) -> FormatResult<()> { | ||
| format_leading_comments(node.syntax()).fmt(f) | ||
| // Our method, not `biome_formatter::trivia::format_leading_comments()`! | ||
| crate::trivia::format_leading_comments(node.syntax()).fmt(f) | ||
| } |
There was a problem hiding this comment.
Here's where we override the default handling of all leading comments that Air sees
| /// # Whether or not to format roxygen2 examples | ||
| /// | ||
| /// Air can format the R code inside roxygen2's `@examples` and `@examplesIf` blocks. | ||
| /// | ||
| /// The overall `line-width` is respected, meaning that the code is formatted with an | ||
| /// adjusted line width equal to `line-width` minus the leading indentation of the | ||
| /// roxygen2 block and the leading `#'` comment characters. | ||
| /// | ||
| /// Parse failures within an example section are silent and do not affect the parse | ||
| /// status of the containing file. | ||
| /// | ||
| /// Rd markup, like `\dontrun{}` and `\donttest{}`, are not supported and will result | ||
| /// in the entire `@examples` or `@examplesIf` section being left unformatted. | ||
| /// | ||
| /// This option is disabled by default. | ||
| pub roxygen_examples: Option<bool>, |
There was a problem hiding this comment.
Important design decision:
This option is currently called roxygen-examples and it is opt in.
Given that:
- we already do some basic normalization to non-
@examplessections in this PR - we probably want to attempt to format the prose at some point
I'm thinking that a better name for this option is probably
roxygen = true / false
Which is probabllllly still false for now as we shake out any bugs (?), and you can opt in with true.
And in the future if we add prose formatting then we may want to expand the option's value set to
roxygen = false # completely off
roxygen = true # opt in to examples and prose formatting
roxygen = "examples" # only examples, no prose
# this doesn't make any sense to me. if you are already using Air and want us to touch
# your roxygen in any way, then you almost certainly want us to touch your examples
roxygen = "prose"
Part of #241
The way this generally works is by taking over the default
fmt_leading_comments()hook, replacing the defaultbiome_formatter::trivia::format_leading_comments()helper that is used there with our own that can split leading comments into chunks of:biome_formatter::trivia::format_leading_comments()Lots of design decisions for this one. I'll call them out one at a time below so we can have threaded conversations about each one. We should "resolve" each thread marked with Important design decision: before merging.
I've run this on dplyr, vctrs, rlang, purrr, and recipes and have refined based on the results and am pretty happy with the results.
Screen.Recording.2026-07-15.at.2.21.33.PM.mov
Hitting the user supplied line width, adjusted to account for
#':Screen.Recording.2026-07-15.at.5.03.06.PM.mov