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 0000000000..cdc45fc42c --- /dev/null +++ b/crates/ide_assists/src/handlers/convert_comment_block.rs @@ -0,0 +1,419 @@ +use itertools::Itertools; +use std::convert::identity; +use syntax::{ + ast::{ + self, + edit::IndentLevel, + Comment, CommentKind, + CommentPlacement::{Inner, Outer}, + CommentShape::{self, Block, Line}, + 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 target = comment.syntax().text_range(); + + acc.add( + AssistId("block_to_line", AssistKind::RefactorRewrite), + "Replace block comment with line comments", + target, + |edit| { + 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)); + + 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(), + ); + + acc.add( + AssistId("line_to_block", AssistKind::RefactorRewrite), + "Replace line comments with a single block comment", + target, + |edit| { + // 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()); + + 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_without_prefix = comm.text().strip_prefix(comm.prefix()).unwrap(); + let contents = contents_without_prefix.strip_prefix(' ').unwrap_or(contents_without_prefix); + + // Don't add the indentation if the line is empty + if contents.is_empty() { + contents.to_owned() + } else { + indentation.to_string() + &contents + } +} + +fn comment_kind_prefix(ck: ast::CommentKind) -> &'static str { + 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 */ +} +"#, + ); + } +} diff --git a/crates/ide_assists/src/lib.rs b/crates/ide_assists/src/lib.rs index 53542d4334..9c8148462c 100644 --- a/crates/ide_assists/src/lib.rs +++ b/crates/ide_assists/src/lib.rs @@ -115,6 +115,7 @@ mod handlers { mod auto_import; mod change_visibility; mod convert_integer_literal; + mod convert_comment_block; mod early_return; mod expand_glob_import; mod extract_function; @@ -178,6 +179,7 @@ mod handlers { auto_import::auto_import, change_visibility::change_visibility, convert_integer_literal::convert_integer_literal, + convert_comment_block::convert_comment_block, early_return::convert_to_guarded_return, expand_glob_import::expand_glob_import, extract_struct_from_enum_variant::extract_struct_from_enum_variant, diff --git a/crates/syntax/src/ast/edit.rs b/crates/syntax/src/ast/edit.rs index 824ebf41cf..0b3b76d4a7 100644 --- a/crates/syntax/src/ast/edit.rs +++ b/crates/syntax/src/ast/edit.rs @@ -595,11 +595,14 @@ impl ops::Add for IndentLevel { impl IndentLevel { pub fn from_node(node: &SyntaxNode) -> IndentLevel { - let first_token = match node.first_token() { - Some(it) => it, + match node.first_token() { + Some(it) => Self::from_token(&it), None => return IndentLevel(0), - }; - for ws in prev_tokens(first_token).filter_map(ast::Whitespace::cast) { + } + } + + pub fn from_token(token: &SyntaxToken) -> IndentLevel { + for ws in prev_tokens(token.clone()).filter_map(ast::Whitespace::cast) { let text = ws.syntax().text(); if let Some(pos) = text.rfind('\n') { let level = text[pos + 1..].chars().count() / 4; diff --git a/crates/syntax/src/ast/token_ext.rs b/crates/syntax/src/ast/token_ext.rs index 044e3e5e84..977eb81819 100644 --- a/crates/syntax/src/ast/token_ext.rs +++ b/crates/syntax/src/ast/token_ext.rs @@ -85,8 +85,9 @@ pub enum CommentPlacement { } impl CommentKind { - const BY_PREFIX: [(&'static str, CommentKind); 8] = [ + const BY_PREFIX: [(&'static str, CommentKind); 9] = [ ("/**/", CommentKind { shape: CommentShape::Block, doc: None }), + ("/***", CommentKind { shape: CommentShape::Block, doc: None }), ("////", CommentKind { shape: CommentShape::Line, doc: None }), ("///", CommentKind { shape: CommentShape::Line, doc: Some(CommentPlacement::Outer) }), ("//!", CommentKind { shape: CommentShape::Line, doc: Some(CommentPlacement::Inner) }),