From e2c73c7db4ba38170016a4cfd467578a6bf9c5f5 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 19 Nov 2025 10:43:14 -0500 Subject: [PATCH 1/5] Allow for customizing the `cursor` token --- crates/ark/src/fixtures/utils.rs | 20 +++++++++++--------- crates/ark/src/lsp/code_action/roxygen.rs | 16 ++++++++++------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/crates/ark/src/fixtures/utils.rs b/crates/ark/src/fixtures/utils.rs index db800e3c9..33ae84fda 100644 --- a/crates/ark/src/fixtures/utils.rs +++ b/crates/ark/src/fixtures/utils.rs @@ -37,23 +37,25 @@ pub(crate) fn r_test_init() { } pub fn point_from_cursor(x: &str) -> (String, Point) { - let (text, point, _offset) = point_and_offset_from_cursor(x); + // i.e. looking for `@` in something like `fn(x = @1, y = 2)`, and it treats the + // `@` as the cursor position + let (text, point, _offset) = point_and_offset_from_cursor(x, b'@'); (text, point) } -pub fn point_and_offset_from_cursor(x: &str) -> (String, Point, usize) { +/// Looks for `cursor` in the text and interprets it as the user's cursor position +pub fn point_and_offset_from_cursor(x: &str, cursor: u8) -> (String, Point, usize) { let lines = x.split("\n").collect::>(); - // i.e. looking for `@` in something like `fn(x = @1, y = 2)`, and it treats the - // `@` as the cursor position - let cursor = b'@'; - let mut offset = 0; + let cursor_for_replace = [cursor]; + let cursor_for_replace = str::from_utf8(&cursor_for_replace).unwrap(); + for (line_row, line) in lines.into_iter().enumerate() { for (char_column, char) in line.as_bytes().into_iter().enumerate() { if char == &cursor { - let x = x.replace("@", ""); + let x = x.replace(cursor_for_replace, ""); let point = Point { row: line_row, column: char_column, @@ -122,7 +124,7 @@ mod tests { #[test] #[rustfmt::skip] fn test_point_and_offset_from_cursor() { - let (text, point, offset) = point_and_offset_from_cursor("1@ + 2"); + let (text, point, offset) = point_and_offset_from_cursor("1@ + 2", b'@'); assert_eq!(text, "1 + 2".to_string()); assert_eq!(point, Point::new(0, 1)); assert_eq!(offset, 1); @@ -135,7 +137,7 @@ mod tests { "fn( arg = 3 )"; - let (text, point, offset) = point_and_offset_from_cursor(text); + let (text, point, offset) = point_and_offset_from_cursor(text, b'@'); assert_eq!(text, expect); assert_eq!(point, Point::new(1, 7)); assert_eq!(offset, 11); diff --git a/crates/ark/src/lsp/code_action/roxygen.rs b/crates/ark/src/lsp/code_action/roxygen.rs index 5986a9087..9f2b07f55 100644 --- a/crates/ark/src/lsp/code_action/roxygen.rs +++ b/crates/ark/src/lsp/code_action/roxygen.rs @@ -186,6 +186,10 @@ mod tests { } } + fn roxygen_point_and_offset_from_cursor(text: &str) -> (String, Point, usize) { + point_and_offset_from_cursor(text, b'@') + } + fn roxygen_documentation_test(text: &str, position: Position) -> String { let mut actions = CodeActions::new(); @@ -195,7 +199,7 @@ mod tests { .with_code_action_literal_support(true) .with_workspace_edit_document_changes(true); - let (text, point, offset) = point_and_offset_from_cursor(text); + let (text, point, offset) = roxygen_point_and_offset_from_cursor(text); let document = Document::new(&text, None); roxygen_documentation( @@ -301,7 +305,7 @@ outer <- function(a, b = 2) { } "; - let (text, point, offset) = point_and_offset_from_cursor(text); + let (text, point, offset) = roxygen_point_and_offset_from_cursor(text); let document = Document::new(&text, None); roxygen_documentation( @@ -330,7 +334,7 @@ outer <- function(a, b = 2) { f@n <- function(a, b) {} "; - let (text, point, offset) = point_and_offset_from_cursor(text); + let (text, point, offset) = roxygen_point_and_offset_from_cursor(text); let document = Document::new(&text, None); roxygen_documentation( @@ -359,7 +363,7 @@ f@n <- function(a, b) {} fn@ <- function(a, b) {} "; - let (text, point, offset) = point_and_offset_from_cursor(text); + let (text, point, offset) = roxygen_point_and_offset_from_cursor(text); let document = Document::new(&text, None); roxygen_documentation( @@ -388,7 +392,7 @@ fn@ <- function(a, b) {} f@n <- function(a, b) {} "; - let (text, point, offset) = point_and_offset_from_cursor(text); + let (text, point, offset) = roxygen_point_and_offset_from_cursor(text); let document = Document::new(&text, None); roxygen_documentation( @@ -417,7 +421,7 @@ f@n <- function(a, b) {} f@n <- function(a, b) {} "; - let (text, point, offset) = point_and_offset_from_cursor(text); + let (text, point, offset) = roxygen_point_and_offset_from_cursor(text); let document = Document::new(&text, None); roxygen_documentation( From 0c443ab6839d573c08bfa1b6ea567563deac9d0f Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 19 Nov 2025 10:52:15 -0500 Subject: [PATCH 2/5] Add full statement range support to roxygen examples --- crates/ark/src/lsp/handlers.rs | 4 +- crates/ark/src/lsp/statement_range.rs | 752 +++++++++++++++++++++----- 2 files changed, 603 insertions(+), 153 deletions(-) diff --git a/crates/ark/src/lsp/handlers.rs b/crates/ark/src/lsp/handlers.rs index 9cc2f90e2..9afbf7d8a 100644 --- a/crates/ark/src/lsp/handlers.rs +++ b/crates/ark/src/lsp/handlers.rs @@ -361,9 +361,7 @@ pub(crate) fn handle_statement_range( let position = params.position; let point = convert_position_to_point(contents, position); - let row = point.row; - - statement_range(root, contents, point, row) + statement_range(root, contents, point) } #[tracing::instrument(level = "info", skip_all)] diff --git a/crates/ark/src/lsp/statement_range.rs b/crates/ark/src/lsp/statement_range.rs index b89ed8bd5..ae44375a5 100644 --- a/crates/ark/src/lsp/statement_range.rs +++ b/crates/ark/src/lsp/statement_range.rs @@ -19,6 +19,7 @@ use tower_lsp::lsp_types::VersionedTextDocumentIdentifier; use tree_sitter::Node; use tree_sitter::Point; +use crate::lsp::documents::Document; use crate::lsp::encoding::convert_point_to_position; use crate::lsp::traits::cursor::TreeCursorExt; use crate::lsp::traits::rope::RopeExt; @@ -55,19 +56,16 @@ pub(crate) fn statement_range( root: tree_sitter::Node, contents: &ropey::Rope, point: Point, - row: usize, ) -> anyhow::Result> { - // Initial check to see if we are in a roxygen2 comment, in which case - // we exit immediately, returning that line as the `range` and possibly - // with `code` stripped of the leading `#' ` if we detect that we are in - // `@examples`. - if let Some((node, code)) = find_roxygen_comment_at_point(&root, contents, point) { - let range = node.range(); + // Initial check to see if we are in a roxygen2 comment, in which case we parse a + // subdocument containing the `@examples` or `@examplesIf` section and locate a + // statement range within that to execute. The returned `code` represents the + // statement range's code stripped of `#'` tokens so it is runnable. + if let Some((range, code)) = find_roxygen_statement_range(&root, contents, point) { return Ok(Some(new_statement_range_response(range, contents, code))); } - if let Some(node) = find_statement_range_node(&root, row) { - let range = expand_range_across_semicolons(node); + if let Some(range) = find_statement_range(&root, point.row) { return Ok(Some(new_statement_range_response(range, contents, None))); }; @@ -92,105 +90,246 @@ fn new_statement_range_response( StatementRangeResponse { range, code } } -fn find_roxygen_comment_at_point<'tree>( - root: &'tree Node, +fn find_roxygen_statement_range( + root: &Node, contents: &Rope, point: Point, -) -> Option<(Node<'tree>, Option)> { +) -> Option<(tree_sitter::Range, Option)> { // Refuse to look for roxygen comments in the face of parse errors // (posit-dev/positron#5023) if node_has_error_or_missing(root) { return None; } + // Find first node that is at or extends past the `point` let mut cursor = root.walk(); - - // Move cursor to first node that is at or extends past the `point` if !cursor.goto_first_child_for_point_patched(point) { return None; } - let node = cursor.node(); + // If we are within `@examples` or `@examplesIf`, first find the range that spans the + // full examples section + if let Some(range) = find_roxygen_examples_section(node, contents) { + // Then narrow in on the exact range of code that the user's cursor covers + if let Some((range, code)) = find_roxygen_examples_range(root, range, contents, point) { + return Some((range, Some(code))); + }; + } + + // If we aren't in an `@examples` or `@examplesIf` section (or we were, but it is + // somehow invalid), we still check to see if we are in a roxygen2 comment of any + // kind. If so, we send just the current comment line to prevent "jumping" past the + // entire roxygen block if you misplace your cursor and `Cmd + Enter`. + if as_roxygen_comment_text(&node, contents).is_some() { + return Some((node.range(), None)); + } + + // Otherwise we let someone else handle the statement range + None +} + +fn as_roxygen_comment_text(node: &Node, contents: &Rope) -> Option { // Tree sitter doesn't know about the special `#'` marker, // but does tell us if we are in a `#` comment if !node.is_comment() { return None; } - let text = contents.node_slice(&node).unwrap().to_string(); - let text = text.as_str(); + let text = contents.node_slice(node).unwrap().to_string(); // Does the roxygen2 prefix exist? - if !RE_ROXYGEN2_COMMENT.is_match(text) { + if !RE_ROXYGEN2_COMMENT.is_match(&text) { return None; } - let text = RE_ROXYGEN2_COMMENT.replace(text, "").into_owned(); + Some(text) +} - // It is likely that we have at least 1 leading whitespace character, - // so we try and remove that if it exists - let text = match text.strip_prefix(" ") { - Some(text) => text, - None => &text, +fn find_roxygen_examples_section(node: Node, contents: &Rope) -> Option { + // Check that the `node` we start on is a valid roxygen comment line. + // We check this `node` specially because the loops below start on the previous/next + // sibling, and this one would go unchecked. + let Some(text) = as_roxygen_comment_text(&node, contents) else { + return None; }; - // At this point we know we are in a roxygen2 comment block so we are at - // least going to return this `node` because we run roxygen comments one - // line at a time (rather than finding the next non-comment node). + // Drop `#'` from the comment's text + let text = RE_ROXYGEN2_COMMENT.replace(&text, ""); - let mut code = None; + // Trim leading whitespace after the `#'` + let text = text.trim_start(); // Do we happen to be on an `@` tag line already? - // We have to check this specially because the while loop starts with the - // previous sibling. if text.starts_with("@") { - return Some((node, code)); + return None; } - // Now look upward to see if we are in an `@examples` section. If we are, - // then we also return the `code`, which has been stripped of `#' `, so - // that line can be sent to the console to be executed. This effectively - // runs roxygen examples in a "dumb" way, 1 line at a time. + let mut last_sibling = node; + let mut start = None; + + // Walk "up" the page + // + // Goal is to find the `@examples` or `@examplesIf` section above us. The line + // right after that is the `start` node. + // // Note: Cleaner to use `cursor.goto_prev_sibling()` but that seems to have // a bug in it (it gets the `kind()` right, but `utf8_text()` returns off by // one results). - let mut last_sibling = node; - while let Some(sibling) = last_sibling.prev_sibling() { - last_sibling = sibling; - - // Have we exited comments in general? - if !sibling.is_comment() { + // Have we exited roxygen comments? + let Some(sibling_text) = as_roxygen_comment_text(&sibling, contents) else { break; - } + }; + + // Drop `#'` from the comment's text + let sibling_text = RE_ROXYGEN2_COMMENT.replace(&sibling_text, ""); - let sibling = contents.node_slice(&sibling).unwrap().to_string(); - let sibling = sibling.as_str(); + // Trim leading whitespace after the `#'` + let sibling_text = sibling_text.trim_start(); + + // Did we discover a new tag? + if sibling_text.starts_with("@") { + // If that new tag is `@examples` or `@examplesIf`, save the `last_sibling` + // right before we found `@examples` or `@examplesIf`. That's the start of + // our node range. + if sibling_text.starts_with("@examples") || sibling_text.starts_with("@examplesIf") { + start = Some(last_sibling); + } - // Have we exited roxygen comments specifically? - if !RE_ROXYGEN2_COMMENT.is_match(sibling) { - return None; + break; } - let sibling = RE_ROXYGEN2_COMMENT.replace(sibling, "").into_owned(); + last_sibling = sibling; + } + + let Some(start) = start else { + // No `@examples` or `@examplesIf` found + return None; + }; - // Trim off any leading whitespace - let sibling = sibling.trim_start(); + last_sibling = node; - // Did we discover that the `node` was indeed in `@examples`? - if sibling.starts_with("@examples") { - code = Some(text.to_string()); + // Walk "down" the page + // + // Goal is to find the last line in this `@examples` or `@examplesIf` section + while let Some(sibling) = last_sibling.next_sibling() { + // Have we exited roxygen comments? + let Some(sibling_text) = as_roxygen_comment_text(&sibling, contents) else { break; - } + }; + + // Drop `#'` from the comment's text + let sibling_text = RE_ROXYGEN2_COMMENT.replace(&sibling_text, ""); - // Otherwise, did we discover the `node` was in a different tag? - if sibling.starts_with("@") { + // Trim leading whitespace after the `#'` + let sibling_text = sibling_text.trim_start(); + + // Did we discover a new tag? + if sibling_text.starts_with("@") { break; } + + last_sibling = sibling; } - return Some((node, code)); + let end = last_sibling; + + let range = tree_sitter::Range { + start_byte: start.start_byte(), + end_byte: end.end_byte(), + start_point: start.start_position(), + end_point: end.end_position(), + }; + + return Some(range); +} + +fn find_roxygen_examples_range( + root: &Node, + range: tree_sitter::Range, + contents: &Rope, + point: Point, +) -> Option<(tree_sitter::Range, String)> { + // Anchor row that we adjust relative to + let row_adjustment = range.start_point.row; + + // Slice out the `@examples` or `@examplesIf` code block (with leading roxygen comments) + let Some(slice) = contents.get_byte_slice(range.start_byte..range.end_byte) else { + return None; + }; + + // Trim out leading roxygen comments so we are left with a subdocument of actual code + let subcontents = slice.to_string(); + let subcontents: Vec = subcontents + .lines() + .map(|line| { + // Trim `#'` and at most 1 leading whitespace character. Don't trim more + // whitespace because that would trim intentional indentation too. + let line = RE_ROXYGEN2_COMMENT.replace(line, ""); + line.strip_prefix(" ") + .map(str::to_string) + .unwrap_or_else(|| line.to_string()) + }) + .collect(); + let subcontents = subcontents.join("\n"); + + // Parse the subdocument + let subdocument = Document::new(&subcontents, None); + let subdocument_root = subdocument.ast.root_node(); + + // Adjust original document row to point to the subdocument row so we know where to + // start our search from within the subdocument + let subdocument_row = point.row - row_adjustment; + + let Some(subdocument_range) = find_statement_range(&subdocument_root, subdocument_row) else { + // Subdocument could have parse errors or we could just not have anything to execute + return None; + }; + + // Slice out code to execute from the subdocument + let Some(slice) = subdocument + .contents + .get_byte_slice(subdocument_range.start_byte..subdocument_range.end_byte) + else { + return None; + }; + let code = slice.to_string(); + + // Map the `subdocument_range` that covers the executable code back to a `range` + // in the original document. This is a rough translation, not an exact one. + // - Find the comment node that corresponds to the starting row. The start of this + // is the start of the range. + // - Find the comment node that corresponds to the ending row. The end of this is + // the end of the range. + let start_point = tree_sitter::Point { + row: subdocument_range.start_point.row + row_adjustment, + column: 0, + }; + let mut cursor = root.walk(); + if !cursor.goto_first_child_for_point_patched(start_point) { + return None; + } + let start_node = cursor.node(); + + let end_point = tree_sitter::Point { + row: subdocument_range.end_point.row + row_adjustment, + column: 0, + }; + let mut cursor = root.walk(); + if !cursor.goto_first_child_for_point_patched(end_point) { + return None; + } + let end_node = cursor.node(); + + let range = tree_sitter::Range { + start_byte: start_node.start_byte(), + end_byte: end_node.end_byte(), + start_point: start_node.start_position(), + end_point: end_node.end_position(), + }; + + Some((range, code)) } /// Assuming `node` is the first node on a line, `expand_across_semicolons()` @@ -236,7 +375,7 @@ fn expand_range_across_semicolons(mut node: Node) -> tree_sitter::Range { } } -fn find_statement_range_node<'tree>(root: &'tree Node, row: usize) -> Option> { +fn find_statement_range(root: &Node, row: usize) -> Option { // Refuse to provide a statement range in the face of parse errors, we are // unlikely to be able to provide anything useful, and are more likely to provide // something confusing. Instead, return `None` so that the frontend sends code to @@ -249,7 +388,7 @@ fn find_statement_range_node<'tree>(root: &'tree Node, row: usize) -> Option child.end_position().row { @@ -265,8 +404,8 @@ fn find_statement_range_node<'tree>(root: &'tree Node, row: usize) -> Option { - out = node; + Ok(candidate) => { + node = candidate; }, Err(error) => { log::error!("Failed to find statement range node due to: {error}."); @@ -276,7 +415,12 @@ fn find_statement_range_node<'tree>(root: &'tree Node, row: usize) -> Option Result> { @@ -557,14 +701,13 @@ fn contains_row_at_different_start_position(node: Node, row: usize) -> Option String { + contents + .byte_slice(range.start_byte..range.end_byte) + .to_string() + } + + // Roxygen comments use the typical `@` cursor token, so we look for `^` instead + fn statement_range_point_from_cursor(x: &str) -> (String, Point) { + let (text, point, _offset) = point_and_offset_from_cursor(x, b'^'); + (text, point) } #[test] - fn test_statement_range_roxygen() { - use crate::lsp::documents::Document; + fn test_statement_range_roxygen_outside_examples() { + // Sends just this line's range, we want Positron to "execute" just this line and + // step forward one line to avoid "jumpiness" if you accidentally send a + // non-example line to the console + let text = " +#' ^Hi +#' @param x foo +#' @examples +#' 1 + 1 +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' Hi")); + assert!(code.is_none()); + } + #[test] + fn test_statement_range_roxygen_on_examples() { let text = " #' Hi #' @param x foo -#' @examples +#' ^@examples #' 1 + 1 -#' -#' fn <- function() { -#' -#' } -#' # Comment -#'2 + 2 -#' @returns "; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' @examples")); + assert!(code.is_none()); + } - let document = Document::new(text, None); + #[test] + fn test_statement_range_roxygen_on_examplesif() { + let text = " +#' Hi +#' @param x foo +#' ^@examplesIf +#' 1 + 1 +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); let root = document.ast.root_node(); let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' @examplesIf")); + assert!(code.is_none()); + } - fn get_text(node: &Node, contents: &Rope) -> String { - contents.node_slice(node).unwrap().to_string() - } + #[test] + fn test_statement_range_roxygen_single_line() { + let text = " +#' Hi +#' @param x foo +#' @examples +#'^ 1 + 1 +#' 2 + 2 +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' 1 + 1")); + assert_eq!(code.unwrap(), String::from("1 + 1")); + } - // Outside of `@examples`, sends whole line as a comment - let point = Point { row: 1, column: 2 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, contents), String::from("#' Hi")); + #[test] + fn test_statement_range_roxygen_no_examples() { + let text = " +#' Hi +#' @param x foo +#' @examples +#'^ +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#'")); assert!(code.is_none()); + } - // On `@examples` line, sends whole line as a comment - let point = Point { row: 3, column: 2 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, contents), String::from("#' @examples")); + #[test] + fn test_statement_range_roxygen_no_examples_followed_by_another_tag() { + let text = " +#' Hi +#' @param x foo +#' @examples +#' ^@returns +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' @returns")); assert!(code.is_none()); + } - // At `1 + 1` - let point = Point { row: 4, column: 2 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, contents), String::from("#' 1 + 1")); - assert_eq!(code.unwrap(), String::from("1 + 1")); + #[test] + fn test_statement_range_roxygen_on_multiline_function() { + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + 1 +#' +#' fn <- function() {^ +#' +#' } +#' +#' 2 + 2 +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!( + get_text(range, contents), + String::from( + " +#' fn <- function() { +#' +#' } +" + .trim() + ) + ); + assert_eq!( + code.unwrap(), + String::from( + " +fn <- function() { - // At empty string line after `1 + 1` - // (we want Positron to trust us and execute this as is) - let point = Point { row: 5, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, contents), String::from("#'")); - assert_eq!(code.unwrap(), String::from("")); +} +" + .trim() + ) + ); + } + + #[test] + fn test_statement_range_roxygen_before_multiline_function() { + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + 1 +#'^ +#' fn <- function() { +#' +#' } +#' +#' 2 + 2 +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!( + get_text(range, contents), + String::from( + " +#' fn <- function() { +#' +#' } +" + .trim() + ) + ); + assert_eq!( + code.unwrap(), + String::from( + " +fn <- function() { + +} +" + .trim() + ) + ); + } - // At `fn <-` line, note we only return that line - let point = Point { row: 6, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); + #[test] + fn test_statement_range_roxygen_on_multiline_pipe_chain() { + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + 1 +#' +#' x %>%^ +#' this() %>% +#' that() +NULL +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!( + get_text(range, contents), + String::from( + " +#' x %>% +#' this() %>% +#' that() +" + .trim() + ) + ); assert_eq!( - get_text(&node, contents), - String::from("#' fn <- function() {") + code.unwrap(), + String::from( + " +x %>% + this() %>% + that() +" + .trim() + ) ); - assert_eq!(code.unwrap(), String::from("fn <- function() {")); + } - // At comment line - let point = Point { row: 9, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, contents), String::from("#' # Comment")); - assert_eq!(code.unwrap(), String::from("# Comment")); + #[test] + fn test_statement_range_roxygen_on_comment() { + // Skips comment, runs next expression + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + 1 +#' # ^Comment +#' 2 + 2 +#' @returns +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' 2 + 2")); + assert_eq!(code.unwrap(), String::from("2 + 2")); + } - // Missing the typical leading space - let point = Point { row: 10, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, contents), String::from("#'2 + 2")); + #[test] + fn test_statement_range_roxygen_without_leading_space() { + // Notice `2 + 2` doesn't have the typical leading whitespace + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + 1 +#'2 + ^2 +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#'2 + 2")); assert_eq!(code.unwrap(), String::from("2 + 2")); + } - // At next roxygen tag - let point = Point { row: 11, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, contents), String::from("#' @returns")); + #[test] + fn test_statement_range_roxygen_after_examples_section() { + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + 1 +#' 2 + 2 +#' ^@returns +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' @returns")); assert!(code.is_none()); + } + #[test] + fn test_statement_range_roxygen_multiple_leading_hash_signs() { let text = " ##' Hi ##' @param x foo ##' @examples -##' 1 + 1 +##' 1 + 1^ ###' 2 + 2 ###' @returns "; - - let document = Document::new(text, None); + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); let root = document.ast.root_node(); let contents = &document.contents; - - // With multiple leading `#` followed by code - let point = Point { row: 4, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, &contents), String::from("##' 1 + 1")); + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("##' 1 + 1")); assert_eq!(code.unwrap(), String::from("1 + 1")); + } - let point = Point { row: 5, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, &contents), String::from("###' 2 + 2")); - assert_eq!(code.unwrap(), String::from("2 + 2")); + #[test] + fn test_statement_range_roxygen_multiple_leading_hash_signs_and_multi_line_expression() { + let text = " +##' Hi +##' @param x foo +##' @examples +##' 1 +^ +###' 2 + +##' 3 +###' @returns +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!( + get_text(range, contents), + String::from("##' 1 +\n###' 2 +\n##' 3") + ); + assert_eq!(code.unwrap(), String::from("1 +\n2 +\n3")); + } - // With multiple leading `#` followed by non-code - let point = Point { row: 3, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, &contents), String::from("##' @examples")); + #[test] + fn test_statement_range_roxygen_multiple_leading_hash_signs_and_non_examples() { + let text = " +##' Hi +##' ^@param x foo +##' @examples +##' 1 + +###' 2 + +##' 3 +###' @returns +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("##' @param x foo")); assert!(code.is_none()); + } - let point = Point { row: 6, column: 1 }; - let (node, code) = find_roxygen_comment_at_point(&root, contents, point).unwrap(); - assert_eq!(get_text(&node, &contents), String::from("###' @returns")); + #[test] + fn test_statement_range_roxygen_parse_errors_in_examples() { + // Still sends "just that line" to avoid jumping around + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + / 1^ +#' @returns +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' 1 + / 1")); assert!(code.is_none()); + } + #[test] + fn test_statement_range_roxygen_parse_errors_in_document() { + // Not contributing at all let text = " +1 + / 1 #' Hi #' @param x foo #' @examples -#' 1 + 1 -#' 2 + 2 +#' 1 + 1^ #' @returns -1 + +2 + 2 "; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + assert!(find_roxygen_statement_range(&root, contents, point).is_none()) + } - let document = Document::new(text, None); + #[test] + fn test_statement_range_roxygen_examplesif_single_line() { + let text = " +#' Hi +#' @param x foo +#' @examplesIf rlang::is_interactive() +#' 1 ^+ 1 +NULL +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); let root = document.ast.root_node(); let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' 1 + 1")); + assert_eq!(code.unwrap(), String::from("1 + 1")); + } - // With parse errors in the file, return `None` - let point = Point { row: 4, column: 1 }; - assert_eq!(find_roxygen_comment_at_point(&root, contents, point), None); + #[test] + fn test_statement_range_roxygen_examplesif_multi_line() { + let text = " +#' Hi +#' @param x foo +#' @examplesIf rlang::is_interactive() +#' 1 ^+ +#' 1 +NULL +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' 1 +\n#' 1")); + assert_eq!(code.unwrap(), String::from("1 +\n 1")); } } From 36df5a487cbe014d911555ef3ead4d434233b4fc Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 26 Nov 2025 12:17:32 -0500 Subject: [PATCH 3/5] Mention that this is a bit hand wavy --- crates/ark/src/lsp/statement_range.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ark/src/lsp/statement_range.rs b/crates/ark/src/lsp/statement_range.rs index ae44375a5..b16dc6a4f 100644 --- a/crates/ark/src/lsp/statement_range.rs +++ b/crates/ark/src/lsp/statement_range.rs @@ -161,6 +161,8 @@ fn find_roxygen_examples_section(node: Node, contents: &Rope) -> Option Date: Wed, 26 Nov 2025 12:19:50 -0500 Subject: [PATCH 4/5] Add note about multiline strings --- crates/ark/src/lsp/statement_range.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ark/src/lsp/statement_range.rs b/crates/ark/src/lsp/statement_range.rs index b16dc6a4f..dd0c476bd 100644 --- a/crates/ark/src/lsp/statement_range.rs +++ b/crates/ark/src/lsp/statement_range.rs @@ -267,7 +267,8 @@ fn find_roxygen_examples_range( .lines() .map(|line| { // Trim `#'` and at most 1 leading whitespace character. Don't trim more - // whitespace because that would trim intentional indentation too. + // whitespace because that would trim intentional indentation and whitespace + // in multiline strings. let line = RE_ROXYGEN2_COMMENT.replace(line, ""); line.strip_prefix(" ") .map(str::to_string) From 8e781bfa908d9455a14a739730924e177257f7e0 Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Wed, 26 Nov 2025 12:22:17 -0500 Subject: [PATCH 5/5] Add test related to multiple spaces before next tag --- crates/ark/src/lsp/statement_range.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/ark/src/lsp/statement_range.rs b/crates/ark/src/lsp/statement_range.rs index dd0c476bd..366d73163 100644 --- a/crates/ark/src/lsp/statement_range.rs +++ b/crates/ark/src/lsp/statement_range.rs @@ -2087,6 +2087,25 @@ x %>% assert!(code.is_none()); } + #[test] + fn test_statement_range_roxygen_multiple_spaces_before_the_next_tag() { + let text = " +#' Hi +#' @param x foo +#' @examples +#' 1 + 1^ +#' @returns +2 + 2 +"; + let (text, point) = statement_range_point_from_cursor(text); + let document = Document::new(&text, None); + let root = document.ast.root_node(); + let contents = &document.contents; + let (range, code) = find_roxygen_statement_range(&root, contents, point).unwrap(); + assert_eq!(get_text(range, contents), String::from("#' 1 + 1")); + assert_eq!(code.unwrap(), String::from("1 + 1")); + } + #[test] fn test_statement_range_roxygen_parse_errors_in_examples() { // Still sends "just that line" to avoid jumping around