Skip to content

Commit de72f8f

Browse files
committed
fix: strip leading asterisk decoration from block doc comments
Block doc comments (`/** ... */`) commonly decorate each line with a leading `*`. rust-analyzer previously left this decoration in the rendered docs, so hover and doc-link resolution saw literal `* foo` lines. Normalize the common `[ \t]*\*` prefix during doc extraction, mirroring rustdoc's `beautify_doc_string`, while keeping the source map offsets accurate for `find_ast_range`. Fixes #1759
1 parent ae631b9 commit de72f8f

2 files changed

Lines changed: 139 additions & 7 deletions

File tree

crates/hir-def/src/attrs/docs.rs

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,13 @@ impl Docs {
179179

180180
fn extend_with_doc_comment(&mut self, comment: ast::Comment, indent: &mut usize) {
181181
let Some((doc, offset)) = comment.doc_comment() else { return };
182-
self.extend_with_doc_str(doc, comment.syntax().text_range().start() + offset, indent);
182+
// Multiline block doc comments are usually decorated with a leading `*` on every line.
183+
let star_trim = match comment.kind().shape {
184+
ast::CommentShape::Block => block_star_prefix(doc),
185+
ast::CommentShape::Line => None,
186+
};
187+
let offset = comment.syntax().text_range().start() + offset;
188+
self.push_doc_lines(doc, Some(offset), indent, star_trim);
183189
}
184190

185191
fn extend_with_doc_attr(&mut self, value: ast::String, indent: &mut usize) {
@@ -196,19 +202,36 @@ impl Docs {
196202
offset_in_ast: TextSize,
197203
indent: &mut usize,
198204
) {
199-
self.push_doc_lines(doc, Some(offset_in_ast), indent);
205+
self.push_doc_lines(doc, Some(offset_in_ast), indent, None);
200206
}
201207

202208
fn extend_with_unmapped_doc_str(&mut self, doc: &str, indent: &mut usize) {
203-
self.push_doc_lines(doc, None, indent);
209+
self.push_doc_lines(doc, None, indent, None);
204210
}
205211

206-
fn push_doc_lines(&mut self, doc: &str, mut ast_offset: Option<TextSize>, indent: &mut usize) {
212+
/// `star_trim` is the whitespace before the `*` decoration of a block doc comment, as computed
213+
/// by [`block_star_prefix()`]; the decoration is stripped from the lines that carry it.
214+
fn push_doc_lines(
215+
&mut self,
216+
doc: &str,
217+
mut ast_offset: Option<TextSize>,
218+
indent: &mut usize,
219+
star_trim: Option<&str>,
220+
) {
207221
for line in doc.split('\n') {
208-
self.docs_source_map
209-
.push(DocsSourceMapLine { string_offset: TextSize::of(&self.docs), ast_offset });
222+
let source_len = TextSize::of(line);
223+
// `*`, `* ` and `**` are decoration, but `*foo` is content.
224+
let line = star_trim
225+
.and_then(|prefix| line.strip_prefix(prefix))
226+
.filter(|&rest| rest == "*" || rest.starts_with("* ") || rest.starts_with("**"))
227+
.map_or(line, |rest| &rest[1..]);
228+
229+
self.docs_source_map.push(DocsSourceMapLine {
230+
string_offset: TextSize::of(&self.docs),
231+
ast_offset: ast_offset.map(|it| it + (source_len - TextSize::of(line))),
232+
});
210233
if let Some(ref mut offset) = ast_offset {
211-
*offset += TextSize::of(line) + TextSize::of("\n");
234+
*offset += source_len + TextSize::of("\n");
212235
}
213236

214237
let line = line.trim_end();
@@ -322,6 +345,17 @@ impl Docs {
322345
}
323346
}
324347

348+
/// The whitespace preceding the `*` decoration on every line of a block doc comment, or `None` if
349+
/// the block is not uniformly decorated (including single-line ones) and must be left alone.
350+
/// Mirrors the `Block` branch of rustdoc's `get_horizontal_trim`.
351+
fn block_star_prefix(doc: &str) -> Option<&str> {
352+
// The first line is skipped, as it follows the `/**`. Blank lines are never decorated.
353+
let mut lines = doc.split('\n').skip(1).filter(|line| !line.trim().is_empty());
354+
let prefix = lines.next()?.split_once('*')?.0;
355+
let decorated = |line: &str| line.strip_prefix(prefix).is_some_and(|it| it.starts_with('*'));
356+
(prefix.bytes().all(|b| matches!(b, b' ' | b'\t')) && lines.all(decorated)).then_some(prefix)
357+
}
358+
325359
struct DocMacroExpander<'db> {
326360
db: &'db dyn SourceDatabase,
327361
krate: Crate,
@@ -555,6 +589,7 @@ pub(crate) fn extract_docs<'a, 'db>(
555589
mod tests {
556590
use expect_test::expect;
557591
use hir_expand::InFile;
592+
use syntax::{AstToken, ast};
558593
use test_fixture::WithFixture;
559594
use tt::{TextRange, TextSize};
560595

@@ -728,4 +763,74 @@ mod tests {
728763
Some((in_file(range(263, 265)), IsInnerDoc::Yes))
729764
);
730765
}
766+
767+
/// Extracts the docs of the first comment in `source`, running the same normalization as
768+
/// [`super::extract_docs`] does for inline docs.
769+
fn comment_docs(source: &str) -> Docs {
770+
let (_db, file_id) = TestDB::with_single_file("");
771+
let comment = syntax::SourceFile::parse(source, span::Edition::CURRENT)
772+
.syntax_node()
773+
.descendants_with_tokens()
774+
.filter_map(|it| it.into_token())
775+
.find_map(ast::Comment::cast)
776+
.expect("no comment in the fixture");
777+
let mut docs = Docs {
778+
docs: String::new(),
779+
docs_source_map: Vec::new(),
780+
outline_mod: None,
781+
inline_file: file_id.into(),
782+
prefix_len: TextSize::new(0),
783+
inline_inner_docs_start: None,
784+
outline_inner_docs_start: None,
785+
};
786+
let mut indent = usize::MAX;
787+
docs.extend_with_doc_comment(comment, &mut indent);
788+
docs.remove_indent(indent, 0);
789+
docs.remove_last_newline();
790+
docs
791+
}
792+
793+
#[test]
794+
fn block_doc_comment_stars() {
795+
#[track_caller]
796+
fn check(source: &str, expect: expect_test::Expect) {
797+
expect.assert_eq(&comment_docs(source).docs);
798+
}
799+
800+
// The decoration is stripped, but markdown bullets and `*foo` are content.
801+
check(
802+
"/**\n * foo\n *\n * * bullet\n *bar\n */",
803+
expect![[r#"
804+
805+
foo
806+
807+
* bullet
808+
*bar
809+
"#]],
810+
);
811+
// Single-line block doc comments are left alone, like rustdoc does.
812+
check("/** * item */", expect!["* item"]);
813+
// So are blocks without a consistent star column.
814+
check(
815+
"/**\n * foo\n * bar\n */",
816+
expect![[r#"
817+
818+
* foo
819+
* bar
820+
"#]],
821+
);
822+
}
823+
824+
#[test]
825+
fn block_doc_comment_source_map() {
826+
let docs = comment_docs("/**\n * foo\n * bar\n */");
827+
assert_eq!(docs.docs, "\nfoo\nbar\n");
828+
829+
let range = |start, end| TextRange::new(TextSize::new(start), TextSize::new(end));
830+
let in_file = |range| InFile::new(docs.inline_file, range);
831+
let mapped = |start, end| docs.find_ast_range(range(start, end));
832+
// Both `foo` and `bar` map back past the stripped ` * ` decoration.
833+
assert_eq!(mapped(1, 4), Some((in_file(range(7, 10)), IsInnerDoc::No)));
834+
assert_eq!(mapped(5, 8), Some((in_file(range(14, 17)), IsInnerDoc::No)));
835+
}
731836
}

crates/ide/src/hover/tests.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5085,6 +5085,33 @@ fn foo$0() {}
50855085
);
50865086
}
50875087

5088+
#[test]
5089+
fn hover_doc_block_style_leading_asterisks() {
5090+
check(
5091+
r#"
5092+
/**
5093+
* Some docs, *not a bullet*.
5094+
*/
5095+
fn foo$0() {}
5096+
"#,
5097+
expect![[r#"
5098+
*foo*
5099+
5100+
```rust
5101+
ra_test_fixture
5102+
```
5103+
5104+
```rust
5105+
fn foo()
5106+
```
5107+
5108+
---
5109+
5110+
Some docs, *not a bullet*.
5111+
"#]],
5112+
);
5113+
}
5114+
50885115
#[test]
50895116
fn hover_comments_dont_highlight_parent() {
50905117
cov_mark::check!(no_highlight_on_comment_hover);

0 commit comments

Comments
 (0)