From 9eecba4dbf8e18ddb9f7f906af468e35a11d28a4 Mon Sep 17 00:00:00 2001 From: John Renner Date: Wed, 24 Feb 2021 11:25:10 -0800 Subject: Implement line<->block comment assist --- .../src/handlers/convert_comment_block.rs | 416 +++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 crates/ide_assists/src/handlers/convert_comment_block.rs (limited to 'crates/ide_assists/src/handlers') diff --git a/crates/ide_assists/src/handlers/convert_comment_block.rs b/crates/ide_assists/src/handlers/convert_comment_block.rs new file mode 100644 index 000000000..7cf5f3643 --- /dev/null +++ b/crates/ide_assists/src/handlers/convert_comment_block.rs @@ -0,0 +1,416 @@ +use ast::{Comment, CommentShape}; +use itertools::Itertools; +use std::convert::identity; +use syntax::{ + ast::{self, edit::IndentLevel, CommentKind, Whitespace}, + AstToken, Direction, SyntaxElement, TextRange, +}; + +use crate::{AssistContext, AssistId, AssistKind, Assists}; + +/// Assist: line_to_block +/// +/// Converts comments between block and single-line form +/// +/// ``` +/// // Multi-line +/// // comment +/// ``` +/// -> +/// ``` +/// /** +/// Multi-line +/// comment +/// */ +/// ``` +pub(crate) fn convert_comment_block(acc: &mut Assists, ctx: &AssistContext) -> Option<()> { + if let Some(comment) = ctx.find_token_at_offset::() { + // Only allow comments which are alone on their line + if let Some(prev) = comment.syntax().prev_token() { + if Whitespace::cast(prev).filter(|w| w.text().contains('\n')).is_none() { + return None; + } + } + + return match comment.kind().shape { + ast::CommentShape::Block => block_to_line(acc, comment), + ast::CommentShape::Line => line_to_block(acc, comment), + }; + } + + return None; +} + +fn block_to_line(acc: &mut Assists, comment: ast::Comment) -> Option<()> { + let indentation = IndentLevel::from_token(comment.syntax()); + let line_prefix = + comment_kind_prefix(CommentKind { shape: CommentShape::Line, ..comment.kind() }); + + let text = comment.text(); + let text = &text[comment.prefix().len()..(text.len() - "*/".len())].trim(); + + let lines = text.lines().peekable(); + + let indent_spaces = indentation.to_string(); + let output = lines + .map(|l| l.trim_start_matches(&indent_spaces)) + .map(|l| { + // Don't introduce trailing whitespace + if l.is_empty() { + line_prefix.to_string() + } else { + format!("{} {}", line_prefix, l.trim_start_matches(&indent_spaces)) + } + }) + .join(&format!("\n{}", indent_spaces)); + + let target = comment.syntax().text_range(); + acc.add( + AssistId("block_to_line", AssistKind::RefactorRewrite), + "Replace block comment with line comments", + target, + |edit| edit.replace(target, output), + ) +} + +fn line_to_block(acc: &mut Assists, comment: ast::Comment) -> Option<()> { + // Find all the comments we'll be collapsing into a block + let comments = relevant_line_comments(&comment); + + // Establish the target of our edit based on the comments we found + let target = TextRange::new( + comments[0].syntax().text_range().start(), + comments.last().unwrap().syntax().text_range().end(), + ); + + // We pick a single indentation level for the whole block comment based on the + // comment where the assist was invoked. This will be prepended to the + // contents of each line comment when they're put into the block comment. + let indentation = IndentLevel::from_token(&comment.syntax()); + + let block_comment_body = + comments.into_iter().map(|c| line_comment_text(indentation, c)).join("\n"); + + let block_prefix = + comment_kind_prefix(CommentKind { shape: CommentShape::Block, ..comment.kind() }); + + let output = format!("{}\n{}\n{}*/", block_prefix, block_comment_body, indentation.to_string()); + + acc.add( + AssistId("line_to_block", AssistKind::RefactorRewrite), + "Replace line comments with a single block comment", + target, + |edit| edit.replace(target, output), + ) +} + +/// 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. +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()) + .filter_map(identity) + .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()) + .filter_map(identity); + + let mut comments: Vec<_> = prev_comments.collect(); + comments.reverse(); + comments.extend(next_comments); + comments +} + +// Line comments usually begin with a single space character following the prefix as seen here: +//^ +// But comments can also include indented text: +// > Hello there +// +// We handle this by stripping *AT MOST* one space character from the start of the line +// This has its own problems because it can cause alignment issues: +// +// /* +// a ----> a +//b ----> b +// */ +// +// But since such comments aren't idiomatic we're okay with this. +fn line_comment_text(indentation: IndentLevel, comm: ast::Comment) -> String { + let contents = trim_one(comm.text().strip_prefix(comm.prefix()).unwrap()).to_owned(); + + // Don't add the indentation if the line is empty + if contents.is_empty() { + contents + } else { + indentation.to_string() + &contents + } +} + +fn trim_one(text: &str) -> &str { + if text.starts_with(' ') { + &text[1..] + } else { + text + } +} + +fn comment_kind_prefix(ck: ast::CommentKind) -> &'static str { + use ast::CommentPlacement::{Inner, Outer}; + use ast::CommentShape::{Block, Line}; + match (ck.shape, ck.doc) { + (Line, Some(Inner)) => "//!", + (Line, Some(Outer)) => "///", + (Line, None) => "//", + (Block, Some(Inner)) => "/*!", + (Block, Some(Outer)) => "/**", + (Block, None) => "/*", + } +} + +#[cfg(test)] +mod tests { + use crate::tests::{check_assist, check_assist_not_applicable}; + + use super::*; + + #[test] + fn single_line_to_block() { + check_assist( + convert_comment_block, + r#" +// line$0 comment +fn main() { + foo(); +} +"#, + r#" +/* +line comment +*/ +fn main() { + foo(); +} +"#, + ); + } + + #[test] + fn single_line_to_block_indented() { + check_assist( + convert_comment_block, + r#" +fn main() { + // line$0 comment + foo(); +} +"#, + r#" +fn main() { + /* + line comment + */ + foo(); +} +"#, + ); + } + + #[test] + fn multiline_to_block() { + check_assist( + convert_comment_block, + r#" +fn main() { + // above + // line$0 comment + // + // below + foo(); +} +"#, + r#" +fn main() { + /* + above + line comment + + below + */ + foo(); +} +"#, + ); + } + + #[test] + fn end_of_line_to_block() { + check_assist_not_applicable( + convert_comment_block, + r#" +fn main() { + foo(); // end-of-line$0 comment +} +"#, + ); + } + + #[test] + fn single_line_different_kinds() { + check_assist( + convert_comment_block, + r#" +fn main() { + /// different prefix + // line$0 comment + // below + foo(); +} +"#, + r#" +fn main() { + /// different prefix + /* + line comment + below + */ + foo(); +} +"#, + ); + } + + #[test] + fn single_line_separate_chunks() { + check_assist( + convert_comment_block, + r#" +fn main() { + // different chunk + + // line$0 comment + // below + foo(); +} +"#, + r#" +fn main() { + // different chunk + + /* + line comment + below + */ + foo(); +} +"#, + ); + } + + #[test] + fn doc_block_comment_to_lines() { + check_assist( + convert_comment_block, + r#" +/** + hi$0 there +*/ +"#, + r#" +/// hi there +"#, + ); + } + + #[test] + fn block_comment_to_lines() { + check_assist( + convert_comment_block, + r#" +/* + hi$0 there +*/ +"#, + r#" +// hi there +"#, + ); + } + + #[test] + fn inner_doc_block_to_lines() { + check_assist( + convert_comment_block, + r#" +/*! + hi$0 there +*/ +"#, + r#" +//! hi there +"#, + ); + } + + #[test] + fn block_to_lines_indent() { + check_assist( + convert_comment_block, + r#" +fn main() { + /*! + hi$0 there + + ``` + code_sample + ``` + */ +} +"#, + r#" +fn main() { + //! hi there + //! + //! ``` + //! code_sample + //! ``` +} +"#, + ); + } + + #[test] + fn end_of_line_block_to_line() { + check_assist_not_applicable( + convert_comment_block, + r#" +fn main() { + foo(); /* end-of-line$0 comment */ +} +"#, + ); + } +} -- cgit v1.2.3