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 +++++++++++++++++++++ crates/ide_assists/src/lib.rs | 2 + crates/syntax/src/ast/edit.rs | 11 +- crates/syntax/src/ast/token_ext.rs | 3 +- 4 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 crates/ide_assists/src/handlers/convert_comment_block.rs 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 */ +} +"#, + ); + } +} diff --git a/crates/ide_assists/src/lib.rs b/crates/ide_assists/src/lib.rs index 7067cf8b6..b9799b9d5 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; @@ -176,6 +177,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, move_module_to_file::move_module_to_file, diff --git a/crates/syntax/src/ast/edit.rs b/crates/syntax/src/ast/edit.rs index 824ebf41c..0b3b76d4a 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 044e3e5e8..977eb8181 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) }), -- cgit v1.2.3 From f5cde97aaeb00b8422abb0ade0e5c103252c54a0 Mon Sep 17 00:00:00 2001 From: John Renner Date: Mon, 1 Mar 2021 11:41:22 -0800 Subject: Apply edits --- .../src/handlers/convert_comment_block.rs | 105 +++++++++++---------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/crates/ide_assists/src/handlers/convert_comment_block.rs b/crates/ide_assists/src/handlers/convert_comment_block.rs index 7cf5f3643..cdc45fc42 100644 --- a/crates/ide_assists/src/handlers/convert_comment_block.rs +++ b/crates/ide_assists/src/handlers/convert_comment_block.rs @@ -1,8 +1,14 @@ -use ast::{Comment, CommentShape}; use itertools::Itertools; use std::convert::identity; use syntax::{ - ast::{self, edit::IndentLevel, CommentKind, Whitespace}, + ast::{ + self, + edit::IndentLevel, + Comment, CommentKind, + CommentPlacement::{Inner, Outer}, + CommentShape::{self, Block, Line}, + Whitespace, + }, AstToken, Direction, SyntaxElement, TextRange, }; @@ -42,34 +48,37 @@ pub(crate) fn convert_comment_block(acc: &mut Assists, ctx: &AssistContext) -> O } 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), + |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) + }, ) } @@ -83,24 +92,27 @@ fn line_to_block(acc: &mut Assists, comment: ast::Comment) -> Option<()> { 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), + |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) + }, ) } @@ -160,27 +172,18 @@ fn relevant_line_comments(comment: &ast::Comment) -> Vec { // // 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(); + 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 + contents.to_owned() } 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)) => "///", -- cgit v1.2.3