From 537b4c63053ade616dcf080830a4ccca97f77e02 Mon Sep 17 00:00:00 2001 From: Luuk Wester Date: Fri, 17 May 2024 21:54:35 +0200 Subject: [PATCH] implement assist to switch between doc and normal comments --- .../convert_comment_from_or_to_doc.rs | 645 ++++++++++++++++++ crates/ide-assists/src/lib.rs | 2 + crates/ide-assists/src/tests/generated.rs | 15 + 3 files changed, 662 insertions(+) create mode 100644 crates/ide-assists/src/handlers/convert_comment_from_or_to_doc.rs diff --git a/crates/ide-assists/src/handlers/convert_comment_from_or_to_doc.rs b/crates/ide-assists/src/handlers/convert_comment_from_or_to_doc.rs new file mode 100644 index 0000000000..1ac30d71f2 --- /dev/null +++ b/crates/ide-assists/src/handlers/convert_comment_from_or_to_doc.rs @@ -0,0 +1,645 @@ +use itertools::Itertools; +use syntax::{ + ast::{self, edit::IndentLevel, Comment, CommentPlacement, Whitespace}, + AstToken, Direction, SyntaxElement, TextRange, +}; + +use crate::{AssistContext, AssistId, AssistKind, Assists}; + +// Assist: comment_to_doc +// +// Converts comments to documentation. +// +// ``` +// // Wow what $0a nice function +// // I sure hope this shows up when I hover over it +// ``` +// -> +// ``` +// //! Wow what a nice function +// //! I sure hope this shows up when I hover over it +// ``` +pub(crate) fn convert_comment_from_or_to_doc( + acc: &mut Assists, + ctx: &AssistContext<'_>, +) -> Option<()> { + let comment = ctx.find_token_at_offset::()?; + + match comment.kind().doc { + Some(_) => doc_to_comment(acc, comment), + None => match can_be_doc_comment(&comment) { + Some(doc_comment_style) => comment_to_doc(acc, comment, doc_comment_style), + None => None, + }, + } +} + +fn doc_to_comment(acc: &mut Assists, comment: ast::Comment) -> Option<()> { + let target = if comment.kind().shape.is_line() { + line_comments_text_range(&comment)? + } else { + comment.syntax().text_range() + }; + + acc.add( + AssistId("doc_to_comment", AssistKind::RefactorRewrite), + "Replace a comment with doc comment", + target, + |edit| { + // We need to either replace the first occurrence of /* with /***, or we need to replace + // the occurrences // at the start of each line with /// + let output = match comment.kind().shape { + ast::CommentShape::Line => { + let indentation = IndentLevel::from_token(comment.syntax()); + let line_start = comment.prefix(); + relevant_line_comments(&comment) + .iter() + .map(|comment| comment.text()) + .flat_map(|text| text.lines()) + .map(|line| indentation.to_string() + &line.replacen(line_start, "//", 1)) + .join("\n") + } + ast::CommentShape::Block => { + let block_start = comment.prefix(); + comment + .text() + .lines() + .enumerate() + .map(|(idx, line)| { + if idx == 0 { + line.replacen(block_start, "/*", 1) + } else { + line.replacen("* ", "* ", 1) + } + }) + .join("\n") + } + }; + edit.replace(target, output) + }, + ) +} + +fn comment_to_doc(acc: &mut Assists, comment: ast::Comment, style: CommentPlacement) -> Option<()> { + let target = if comment.kind().shape.is_line() { + line_comments_text_range(&comment)? + } else { + comment.syntax().text_range() + }; + + acc.add( + AssistId("comment_to_doc", AssistKind::RefactorRewrite), + "Replace a doc comment with comment", + target, + |edit| { + // We need to either replace the first occurrence of /* with /***, or we need to replace + // the occurrences // at the start of each line with /// + let output = match comment.kind().shape { + ast::CommentShape::Line => { + let line_start = match style { + CommentPlacement::Inner => "//!", + CommentPlacement::Outer => "///", + }; + let indentation = IndentLevel::from_token(comment.syntax()); + relevant_line_comments(&comment) + .iter() + .map(|comment| comment.text()) + .flat_map(|text| text.lines()) + .map(|line| indentation.to_string() + &line.replacen("//", line_start, 1)) + .join("\n") + } + ast::CommentShape::Block => { + let block_start = match style { + CommentPlacement::Inner => "/*!", + CommentPlacement::Outer => "/**", + }; + comment + .text() + .lines() + .enumerate() + .map(|(idx, line)| { + if idx == 0 { + // On the first line we replace the comment start with a doc comment + // start. + line.replacen("/*", block_start, 1) + } else { + // put one extra space after each * since we moved the first line to + // the right by one column as well. + line.replacen("* ", "* ", 1) + } + }) + .join("\n") + } + }; + edit.replace(target, output) + }, + ) +} + +/// Not all comments are valid candidates for conversion into doc comments. For example, the +/// comments in the code: +/// ```rust +/// // foos the bar +/// fn foo_bar(foo: Foo) -> Bar { +/// // Bar the foo +/// foo.into_bar() +/// } +/// +/// trait A { +/// // The A trait +/// } +/// ``` +/// can be converted to doc comments. However, the comments in this example: +/// ```rust +/// fn foo_bar(foo: Foo /* not bar yet */) -> Bar { +/// foo.into_bar() +/// // Nicely done +/// } +/// // end of function +/// +/// struct S { +/// // The S struct +/// } +/// ``` +/// are not allowed to become doc comments. +fn can_be_doc_comment(comment: &ast::Comment) -> Option { + use syntax::SyntaxKind::*; + + // if the comment is not on its own line, then we do not propose anything. + match comment.syntax().prev_token() { + Some(prev) => { + // There was a previous token, now check if it was a newline + Whitespace::cast(prev).filter(|w| w.text().contains('\n'))?; + } + // There is no previous token, this is the start of the file. + None => return Some(CommentPlacement::Inner), + } + + // check if comment is followed by: `struct`, `trait`, `mod`, `fn`, `type`, `extern crate`, `use`, `const` + let parent = comment.syntax().parent(); + let parent_kind = parent.as_ref().map(|parent| parent.kind()); + if matches!( + parent_kind, + Some(STRUCT | TRAIT | MODULE | FN | TYPE_KW | EXTERN_CRATE | USE | CONST) + ) { + return Some(CommentPlacement::Outer); + } + + // check if comment is preceded by: `fn f() {`, `trait T {`, `mod M {`: + let third_parent_kind = comment + .syntax() + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(|parent| parent.kind()); + let is_first_item_in_parent = comment + .syntax() + .siblings_with_tokens(Direction::Prev) + .filter_map(|not| not.into_node()) + .next() + .is_none(); + + if matches!(parent_kind, Some(STMT_LIST)) + && is_first_item_in_parent + && matches!(third_parent_kind, Some(FN | TRAIT | MODULE)) + { + return Some(CommentPlacement::Inner); + } + + None +} + +/// The line -> block assist can be invoked from anywhere within a sequence of line comments. +/// relevant_line_comments crawls backwards and forwards finding the complete sequence of comments that will +/// be joined. +pub(crate) fn relevant_line_comments(comment: &ast::Comment) -> Vec { + // The prefix identifies the kind of comment we're dealing with + let prefix = comment.prefix(); + let same_prefix = |c: &ast::Comment| c.prefix() == prefix; + + // These tokens are allowed to exist between comments + let skippable = |not: &SyntaxElement| { + not.clone() + .into_token() + .and_then(Whitespace::cast) + .map(|w| !w.spans_multiple_lines()) + .unwrap_or(false) + }; + + // Find all preceding comments (in reverse order) that have the same prefix + let prev_comments = comment + .syntax() + .siblings_with_tokens(Direction::Prev) + .filter(|s| !skippable(s)) + .map(|not| not.into_token().and_then(Comment::cast).filter(same_prefix)) + .take_while(|opt_com| opt_com.is_some()) + .flatten() + .skip(1); // skip the first element so we don't duplicate it in next_comments + + let next_comments = comment + .syntax() + .siblings_with_tokens(Direction::Next) + .filter(|s| !skippable(s)) + .map(|not| not.into_token().and_then(Comment::cast).filter(same_prefix)) + .take_while(|opt_com| opt_com.is_some()) + .flatten(); + + let mut comments: Vec<_> = prev_comments.collect(); + comments.reverse(); + comments.extend(next_comments); + comments +} + +fn line_comments_text_range(comment: &ast::Comment) -> Option { + let comments = relevant_line_comments(comment); + let first = comments.first()?; + let indentation = IndentLevel::from_token(first.syntax()); + let start = + first.syntax().text_range().start().checked_sub((indentation.0 as u32 * 4).into())?; + let end = comments.last()?.syntax().text_range().end(); + Some(TextRange::new(start, end)) +} + +#[cfg(test)] +mod tests { + use crate::tests::{check_assist, check_assist_not_applicable}; + + use super::*; + + #[test] + fn module_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + // such a nice module$0 + fn main() { + foo(); + } + "#, + r#" + //! such a nice module + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn single_line_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + // unseen$0 docs + fn main() { + foo(); + } + "#, + r#" + + /// unseen docs + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn multi_line_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + // unseen$0 docs + // make me seen! + fn main() { + foo(); + } + "#, + r#" + + /// unseen docs + /// make me seen! + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn single_line_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + /// visible$0 docs + fn main() { + foo(); + } + "#, + r#" + + // visible docs + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn multi_line_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + /// visible$0 docs + /// Hide me! + fn main() { + foo(); + } + "#, + r#" + + // visible docs + // Hide me! + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn single_line_block_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + /* unseen$0 docs */ + fn main() { + foo(); + } + "#, + r#" + + /** unseen docs */ + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn multi_line_block_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + /* unseen$0 docs + * make me seen! + */ + fn main() { + foo(); + } + "#, + r#" + + /** unseen docs + * make me seen! + */ + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn single_line_block_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + /** visible$0 docs */ + fn main() { + foo(); + } + "#, + r#" + + /* visible docs */ + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn multi_line_block_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + + /** visible$0 docs + * Hide me! + */ + fn main() { + foo(); + } + "#, + r#" + + /* visible docs + * Hide me! + */ + fn main() { + foo(); + } + "#, + ); + } + + #[test] + fn single_inner_line_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + // unseen$0 docs + foo(); + } + "#, + r#" + fn main() { + //! unseen docs + foo(); + } + "#, + ); + } + + #[test] + fn multi_inner_line_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + // unseen$0 docs + // make me seen! + foo(); + } + "#, + r#" + fn main() { + //! unseen docs + //! make me seen! + foo(); + } + "#, + ); + } + + #[test] + fn single_inner_line_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + //! visible$0 docs + foo(); + } + "#, + r#" + fn main() { + // visible docs + foo(); + } + "#, + ); + } + + #[test] + fn multi_inner_line_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + //! visible$0 docs + //! Hide me! + foo(); + } + "#, + r#" + fn main() { + // visible docs + // Hide me! + foo(); + } + "#, + ); + } + + #[test] + fn single_inner_line_block_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + /* unseen$0 docs */ + foo(); + } + "#, + r#" + fn main() { + /*! unseen docs */ + foo(); + } + "#, + ); + } + + #[test] + fn multi_inner_line_block_comment_to_doc() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + /* unseen$0 docs + * make me seen! + */ + foo(); + } + "#, + r#" + fn main() { + /*! unseen docs + * make me seen! + */ + foo(); + } + "#, + ); + } + + #[test] + fn single_inner_line_block_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + /*! visible$0 docs */ + foo(); + } + "#, + r#" + fn main() { + /* visible docs */ + foo(); + } + "#, + ); + } + + #[test] + fn multi_inner_line_block_doc_to_comment() { + check_assist( + convert_comment_from_or_to_doc, + r#" + fn main() { + /*! visible$0 docs + * Hide me! + */ + foo(); + } + "#, + r#" + fn main() { + /* visible docs + * Hide me! + */ + foo(); + } + "#, + ); + } + + #[test] + fn not_overeager() { + check_assist_not_applicable( + convert_comment_from_or_to_doc, + r#" + fn main() { + foo(); + // $0well that settles main + } + // $1 nicely done + "#, + ); + } +} diff --git a/crates/ide-assists/src/lib.rs b/crates/ide-assists/src/lib.rs index 0df5e913a5..cbaf03e4d1 100644 --- a/crates/ide-assists/src/lib.rs +++ b/crates/ide-assists/src/lib.rs @@ -116,6 +116,7 @@ mod handlers { mod change_visibility; mod convert_bool_then; mod convert_comment_block; + mod convert_comment_from_or_to_doc; mod convert_from_to_tryfrom; mod convert_integer_literal; mod convert_into_to_from; @@ -239,6 +240,7 @@ mod handlers { convert_bool_then::convert_bool_then_to_if, convert_bool_then::convert_if_to_bool_then, convert_comment_block::convert_comment_block, + convert_comment_from_or_to_doc::convert_comment_from_or_to_doc, convert_from_to_tryfrom::convert_from_to_tryfrom, convert_integer_literal::convert_integer_literal, convert_into_to_from::convert_into_to_from, diff --git a/crates/ide-assists/src/tests/generated.rs b/crates/ide-assists/src/tests/generated.rs index 937e78f8d7..bd841d72e6 100644 --- a/crates/ide-assists/src/tests/generated.rs +++ b/crates/ide-assists/src/tests/generated.rs @@ -345,6 +345,21 @@ pub(crate) fn frobnicate() {} ) } +#[test] +fn doctest_comment_to_doc() { + check_doc_test( + "comment_to_doc", + r#####" +// Wow what $0a nice function +// I sure hope this shows up when I hover over it +"#####, + r#####" +//! Wow what a nice function +//! I sure hope this shows up when I hover over it +"#####, + ) +} + #[test] fn doctest_convert_bool_then_to_if() { check_doc_test(