use super::*;
use crate::ast::{Param, RoxygenBlock};
pub(crate) fn roxygen_code_action(
text: &str,
uri: &Uri,
range: Range,
) -> Option<CodeActionOrCommand> {
let line_index = LineIndex::new(text);
let offset = line_index.position_to_byte(range.start);
let root = parse(text).cst;
let token = token_at(&root, TextSize::new(offset as u32))?;
let node = token.parent()?;
let func = node.ancestors().find_map(FunctionExpr::cast).or_else(|| {
node.ancestors()
.find_map(AssignmentExpr::cast)
.and_then(|a| a.value_element())
.and_then(|v| v.into_node())
.and_then(FunctionExpr::cast)
})?;
let assign = func.syntax().ancestors().find_map(AssignmentExpr::cast)?;
if assign.value_element().and_then(|v| v.into_node()).as_ref() != Some(func.syntax()) {
return None;
}
assign.target_name()?;
let params = func.params();
let stmt = assign.syntax();
let stmt_start = usize::from(stmt.text_range().start());
let indent = leading_indent(text, stmt_start);
let (edit_start, new_text, verb) = match preceding_roxygen_block(stmt) {
None => (stmt_start, build_skeleton(¶ms, &indent), "Add"),
Some(block) => {
let (offset, content) = build_update(text, &block, ¶ms, &indent)?;
(offset, content, "Update")
}
};
let pos = line_index.byte_to_position(edit_start);
let edit = TextEdit {
range: Range {
start: pos,
end: pos,
},
new_text,
};
let mut changes = HashMap::new();
changes.insert(uri.clone(), vec![edit]);
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("{verb} roxygen documentation"),
kind: Some(CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}))
}
fn token_at(root: &SyntaxNode, offset: TextSize) -> Option<SyntaxToken<RLanguage>> {
match root.token_at_offset(offset) {
TokenAtOffset::None => None,
TokenAtOffset::Single(t) => Some(t),
TokenAtOffset::Between(l, r) => Some(
if matches!(l.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
r
} else {
l
},
),
}
}
fn leading_indent(text: &str, start: usize) -> String {
let line_start = text[..start].rfind('\n').map_or(0, |i| i + 1);
text[line_start..start]
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect()
}
fn preceding_roxygen_block(stmt: &SyntaxNode) -> Option<RoxygenBlock> {
let mut newlines = 0usize;
let mut prev = stmt.prev_sibling_or_token();
while let Some(el) = prev {
match el {
NodeOrToken::Token(t) => match t.kind() {
SyntaxKind::NEWLINE => {
newlines += t.text().matches('\n').count();
if newlines > 1 {
return None;
}
prev = t.prev_sibling_or_token();
}
SyntaxKind::WHITESPACE => prev = t.prev_sibling_or_token(),
_ => return None,
},
NodeOrToken::Node(n) => return RoxygenBlock::cast(n),
}
}
None
}
fn build_skeleton(params: &[Param], indent: &str) -> String {
let mut lines = vec!["#' Title".to_string(), "#'".to_string()];
if !params.is_empty() {
for p in params {
lines.push(format!("#' @param {}", p.name));
}
lines.push("#'".to_string());
}
lines.push("#' @return".to_string());
let mut out = String::new();
for line in &lines {
out.push_str(indent);
out.push_str(line);
out.push('\n');
}
out
}
fn build_update(
text: &str,
block: &RoxygenBlock,
params: &[Param],
indent: &str,
) -> Option<(usize, String)> {
let mut documented = HashSet::new();
for tag in block.tags() {
if tag.name().as_deref() == Some("param") {
for (name, _) in tag.arg_names() {
documented.insert(name.to_string());
}
}
}
let missing: Vec<&Param> = params
.iter()
.filter(|p| !documented.contains(p.name.as_str()))
.collect();
if missing.is_empty() {
return None;
}
let anchor = trim_trailing_ws(text, insertion_offset(block));
let mut content = String::new();
for p in &missing {
content.push('\n');
content.push_str(indent);
content.push_str(&format!("#' @param {}", p.name));
}
Some((anchor, content))
}
fn insertion_offset(block: &RoxygenBlock) -> usize {
let mut last_param_end = None;
let mut intro_end = None;
for section in block.sections() {
let end = usize::from(section.syntax().text_range().end());
match section.tag() {
Some(tag) if tag.name().as_deref() == Some("param") => last_param_end = Some(end),
None => intro_end = Some(end),
_ => {}
}
}
last_param_end
.or(intro_end)
.unwrap_or_else(|| usize::from(block.syntax().text_range().end()))
}
fn trim_trailing_ws(text: &str, mut end: usize) -> usize {
let bytes = text.as_bytes();
while end > 0 && matches!(bytes[end - 1], b' ' | b'\t' | b'\r' | b'\n') {
end -= 1;
}
end
}
#[cfg(test)]
mod tests {
use super::*;
fn action_on_line_0(src: &str) -> Option<CodeAction> {
let range = Range {
start: pos(0, 0),
end: pos(0, 0),
};
let line = src[..src.find("function").unwrap()].matches('\n').count() as u32;
let range = Range {
start: pos(line, 0),
..range
};
match roxygen_code_action(src, &test_uri(), range)? {
CodeActionOrCommand::CodeAction(a) => Some(a),
_ => None,
}
}
fn applied(src: &str, action: &CodeAction) -> (String, String) {
let (range, new_text) = sole_edit(action.edit.as_ref().unwrap(), &test_uri());
let li = LineIndex::new(src);
let start = li.position_to_byte(range.start);
let end = li.position_to_byte(range.end);
let mut out = src.to_string();
out.replace_range(start..end, &new_text);
(new_text, out)
}
fn assert_lossless(src: &str) {
assert_eq!(crate::parser::reconstruct(src), src, "must round-trip");
}
#[test]
fn add_inserts_skeleton_for_undocumented_function() {
let src = "f <- function(x, y) x\n";
let action = action_on_line_0(src).expect("an add action");
assert_eq!(action.title, "Add roxygen documentation");
assert_eq!(action.kind, Some(CodeActionKind::REFACTOR));
let (new_text, out) = applied(src, &action);
assert!(new_text.contains("#' Title"));
assert!(new_text.contains("#' @param x\n"));
assert!(new_text.contains("#' @param y\n"));
assert!(new_text.contains("#' @return\n"));
assert_eq!(
out,
"#' Title\n#'\n#' @param x\n#' @param y\n#'\n#' @return\nf <- function(x, y) x\n"
);
assert_lossless(&out);
}
#[test]
fn add_skeleton_has_no_param_lines_for_nullary_function() {
let src = "f <- function() 1\n";
let action = action_on_line_0(src).expect("an add action");
let (_, out) = applied(src, &action);
assert_eq!(out, "#' Title\n#'\n#' @return\nf <- function() 1\n");
assert_lossless(&out);
}
#[test]
fn update_inserts_only_missing_param() {
let src = "#' Title\n#' @param x foo\nf <- function(x, y) x\n";
let action = action_on_line_0(src).expect("an update action");
assert_eq!(action.title, "Update roxygen documentation");
let (new_text, out) = applied(src, &action);
assert!(!new_text.contains("@param x"), "must not re-add x");
assert!(new_text.contains("#' @param y"));
assert_eq!(
out,
"#' Title\n#' @param x foo\n#' @param y\nf <- function(x, y) x\n"
);
assert_lossless(&out);
}
#[test]
fn update_anchors_params_after_intro_when_none_documented() {
let src = "#' Title\n#' @return bar\nf <- function(x) x\n";
let action = action_on_line_0(src).expect("an update action");
let (_, out) = applied(src, &action);
assert_eq!(
out,
"#' Title\n#' @param x\n#' @return bar\nf <- function(x) x\n"
);
assert_lossless(&out);
}
#[test]
fn no_action_when_all_params_documented() {
let src = "#' Title\n#' @param x foo\nf <- function(x) x\n";
assert!(action_on_line_0(src).is_none());
}
#[test]
fn no_action_for_anonymous_function() {
let src = "lapply(xs, function(v) v + 1)\n";
assert!(action_on_line_0(src).is_none());
}
#[test]
fn no_action_when_function_nested_in_call_on_rhs() {
let src = "y <- purrr::compose(function(v) v)\n";
assert!(action_on_line_0(src).is_none());
}
}