Skip to content

Implement roxygen2 @examples support - #516

Open
DavisVaughan wants to merge 2 commits into
mainfrom
feature/roxygen-examples
Open

Implement roxygen2 @examples support#516
DavisVaughan wants to merge 2 commits into
mainfrom
feature/roxygen-examples

Conversation

@DavisVaughan

@DavisVaughan DavisVaughan commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Part of #241

The way this generally works is by taking over the default fmt_leading_comments() hook, replacing the default biome_formatter::trivia::format_leading_comments() helper that is used there with our own that can split leading comments into chunks of:

  • standard comments handled by biome_formatter::trivia::format_leading_comments()
  • roxygen2 blocks handled by our own custom formatter

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

Comment on lines +36 to +38
// Something failed, like a parse error. Totally fine and expected. Fall back to
// verbatim formatter.
FormatRoxygenVerbatim::new(self.section.comments(), self.prefix).fmt(f)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +60 to +73
// This
//
// ```
// #' @examples 1 + 1
// #' 2 + 2
// ```
//
// is normalized to
//
// ```
// #' @examples
// #' 1 + 1
// #' 2 + 2
// ```

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important design decision:

I think this makes sense. Should be rare, but I think it should be moved down.

Comment on lines +89 to +127
// 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 }
// ```

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +144 to +174
// 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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we format the @examples subdocument, we start with the user's options and override a few things:

  • The line_width is 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 \n line breaks, which helps with splitting by \n later on.

Comment on lines +192 to +198
// 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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't the first time I've had to strip off this unconditional hard_line_break()

We also do it here

// Remove last hard break line from our artifical expression list
format_text.pop();

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.

Comment on lines +52 to +88
/// 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +18 to +28
/// 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>),
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + 2

breaks 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

Comment on lines +8 to +15
/// 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 }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our main entry point that replaces biome_formatter::trivia::format_leading_comments(). This formats both standard leading comments and recognized roxygen comments.

Comment on lines 238 to 241
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)
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's where we override the default handling of all leading comments that Air sees

Comment on lines +127 to +142
/// # 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>,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-@examples sections 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"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant