ide_assists/handlers/
convert_comment_block.rs

1use itertools::Itertools;
2use syntax::{
3    AstToken, Direction, SyntaxElement, TextRange,
4    ast::{self, Comment, CommentKind, CommentShape, Whitespace, edit::IndentLevel},
5};
6
7use crate::{AssistContext, AssistId, Assists};
8
9// Assist: line_to_block
10//
11// Converts comments between block and single-line form.
12//
13// ```
14//    // Multi-line$0
15//    // comment
16// ```
17// ->
18// ```
19//   /*
20//   Multi-line
21//   comment
22//   */
23// ```
24pub(crate) fn convert_comment_block(acc: &mut Assists, ctx: &AssistContext<'_>) -> Option<()> {
25    let comment = ctx.find_token_at_offset::<ast::Comment>()?;
26    // Only allow comments which are alone on their line
27    if let Some(prev) = comment.syntax().prev_token() {
28        Whitespace::cast(prev).filter(|w| w.text().contains('\n'))?;
29    }
30
31    match comment.kind().shape {
32        ast::CommentShape::Block => block_to_line(acc, comment),
33        ast::CommentShape::Line => line_to_block(acc, comment),
34    }
35}
36
37fn block_to_line(acc: &mut Assists, comment: ast::Comment) -> Option<()> {
38    let target = comment.syntax().text_range();
39
40    acc.add(
41        AssistId::refactor_rewrite("block_to_line"),
42        "Replace block comment with line comments",
43        target,
44        |edit| {
45            let indentation = IndentLevel::from_token(comment.syntax());
46            let line_prefix = CommentKind { shape: CommentShape::Line, ..comment.kind() }.prefix();
47
48            let text = comment.text();
49            let text = &text[comment.prefix().len()..(text.len() - "*/".len())].trim();
50
51            let lines = text.lines().peekable();
52
53            let indent_spaces = indentation.to_string();
54            let output = lines
55                .map(|line| {
56                    let line = line.trim_start_matches(&indent_spaces);
57
58                    // Don't introduce trailing whitespace
59                    if line.is_empty() {
60                        line_prefix.to_owned()
61                    } else {
62                        format!("{line_prefix} {line}")
63                    }
64                })
65                .join(&format!("\n{indent_spaces}"));
66
67            edit.replace(target, output)
68        },
69    )
70}
71
72fn line_to_block(acc: &mut Assists, comment: ast::Comment) -> Option<()> {
73    // Find all the comments we'll be collapsing into a block
74    let comments = relevant_line_comments(&comment);
75
76    // Establish the target of our edit based on the comments we found
77    let target = TextRange::new(
78        comments[0].syntax().text_range().start(),
79        comments.last()?.syntax().text_range().end(),
80    );
81
82    acc.add(
83        AssistId::refactor_rewrite("line_to_block"),
84        "Replace line comments with a single block comment",
85        target,
86        |edit| {
87            // We pick a single indentation level for the whole block comment based on the
88            // comment where the assist was invoked. This will be prepended to the
89            // contents of each line comment when they're put into the block comment.
90            let indentation = IndentLevel::from_token(comment.syntax());
91
92            let block_comment_body = comments
93                .into_iter()
94                .map(|c| line_comment_text(indentation, c))
95                .collect::<Vec<String>>()
96                .into_iter()
97                .join("\n");
98
99            let block_prefix =
100                CommentKind { shape: CommentShape::Block, ..comment.kind() }.prefix();
101
102            let output = format!("{block_prefix}\n{block_comment_body}\n{indentation}*/");
103
104            edit.replace(target, output)
105        },
106    )
107}
108
109/// The line -> block assist can  be invoked from anywhere within a sequence of line comments.
110/// relevant_line_comments crawls backwards and forwards finding the complete sequence of comments that will
111/// be joined.
112pub(crate) fn relevant_line_comments(comment: &ast::Comment) -> Vec<Comment> {
113    // The prefix identifies the kind of comment we're dealing with
114    let prefix = comment.prefix();
115    let same_prefix = |c: &ast::Comment| c.prefix() == prefix;
116
117    // These tokens are allowed to exist between comments
118    let skippable = |not: &SyntaxElement| {
119        not.clone()
120            .into_token()
121            .and_then(Whitespace::cast)
122            .map(|w| !w.spans_multiple_lines())
123            .unwrap_or(false)
124    };
125
126    // Find all preceding comments (in reverse order) that have the same prefix
127    let prev_comments = comment
128        .syntax()
129        .siblings_with_tokens(Direction::Prev)
130        .filter(|s| !skippable(s))
131        .map(|not| not.into_token().and_then(Comment::cast).filter(same_prefix))
132        .take_while(|opt_com| opt_com.is_some())
133        .flatten()
134        .skip(1); // skip the first element so we don't duplicate it in next_comments
135
136    let next_comments = comment
137        .syntax()
138        .siblings_with_tokens(Direction::Next)
139        .filter(|s| !skippable(s))
140        .map(|not| not.into_token().and_then(Comment::cast).filter(same_prefix))
141        .take_while(|opt_com| opt_com.is_some())
142        .flatten();
143
144    let mut comments: Vec<_> = prev_comments.collect();
145    comments.reverse();
146    comments.extend(next_comments);
147    comments
148}
149
150// Line comments usually begin with a single space character following the prefix as seen here:
151//^
152// But comments can also include indented text:
153//    > Hello there
154//
155// We handle this by stripping *AT MOST* one space character from the start of the line
156// This has its own problems because it can cause alignment issues:
157//
158//              /*
159// a      ----> a
160//b       ----> b
161//              */
162//
163// But since such comments aren't idiomatic we're okay with this.
164pub(crate) fn line_comment_text(indentation: IndentLevel, comm: ast::Comment) -> String {
165    let text = comm.text();
166    let contents_without_prefix = text.strip_prefix(comm.prefix()).unwrap_or(text);
167    let contents = contents_without_prefix.strip_prefix(' ').unwrap_or(contents_without_prefix);
168
169    // Don't add the indentation if the line is empty
170    if contents.is_empty() { contents.to_owned() } else { indentation.to_string() + contents }
171}
172
173#[cfg(test)]
174mod tests {
175    use crate::tests::{check_assist, check_assist_not_applicable};
176
177    use super::*;
178
179    #[test]
180    fn single_line_to_block() {
181        check_assist(
182            convert_comment_block,
183            r#"
184// line$0 comment
185fn main() {
186    foo();
187}
188"#,
189            r#"
190/*
191line comment
192*/
193fn main() {
194    foo();
195}
196"#,
197        );
198    }
199
200    #[test]
201    fn single_line_to_block_indented() {
202        check_assist(
203            convert_comment_block,
204            r#"
205fn main() {
206    // line$0 comment
207    foo();
208}
209"#,
210            r#"
211fn main() {
212    /*
213    line comment
214    */
215    foo();
216}
217"#,
218        );
219    }
220
221    #[test]
222    fn multiline_to_block() {
223        check_assist(
224            convert_comment_block,
225            r#"
226fn main() {
227    // above
228    // line$0 comment
229    //
230    // below
231    foo();
232}
233"#,
234            r#"
235fn main() {
236    /*
237    above
238    line comment
239
240    below
241    */
242    foo();
243}
244"#,
245        );
246    }
247
248    #[test]
249    fn end_of_line_to_block() {
250        check_assist_not_applicable(
251            convert_comment_block,
252            r#"
253fn main() {
254    foo(); // end-of-line$0 comment
255}
256"#,
257        );
258    }
259
260    #[test]
261    fn single_line_different_kinds() {
262        check_assist(
263            convert_comment_block,
264            r#"
265fn main() {
266    /// different prefix
267    // line$0 comment
268    // below
269    foo();
270}
271"#,
272            r#"
273fn main() {
274    /// different prefix
275    /*
276    line comment
277    below
278    */
279    foo();
280}
281"#,
282        );
283    }
284
285    #[test]
286    fn single_line_separate_chunks() {
287        check_assist(
288            convert_comment_block,
289            r#"
290fn main() {
291    // different chunk
292
293    // line$0 comment
294    // below
295    foo();
296}
297"#,
298            r#"
299fn main() {
300    // different chunk
301
302    /*
303    line comment
304    below
305    */
306    foo();
307}
308"#,
309        );
310    }
311
312    #[test]
313    fn doc_block_comment_to_lines() {
314        check_assist(
315            convert_comment_block,
316            r#"
317/**
318 hi$0 there
319*/
320"#,
321            r#"
322/// hi there
323"#,
324        );
325    }
326
327    #[test]
328    fn block_comment_to_lines() {
329        check_assist(
330            convert_comment_block,
331            r#"
332/*
333 hi$0 there
334*/
335"#,
336            r#"
337// hi there
338"#,
339        );
340    }
341
342    #[test]
343    fn inner_doc_block_to_lines() {
344        check_assist(
345            convert_comment_block,
346            r#"
347/*!
348 hi$0 there
349*/
350"#,
351            r#"
352//! hi there
353"#,
354        );
355    }
356
357    #[test]
358    fn block_to_lines_indent() {
359        check_assist(
360            convert_comment_block,
361            r#"
362fn main() {
363    /*!
364    hi$0 there
365
366    ```
367      code_sample
368    ```
369    */
370}
371"#,
372            r#"
373fn main() {
374    //! hi there
375    //!
376    //! ```
377    //!   code_sample
378    //! ```
379}
380"#,
381        );
382    }
383
384    #[test]
385    fn end_of_line_block_to_line() {
386        check_assist_not_applicable(
387            convert_comment_block,
388            r#"
389fn main() {
390    foo(); /* end-of-line$0 comment */
391}
392"#,
393        );
394    }
395}