@@ -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+
325359struct DocMacroExpander < ' db > {
326360 db : & ' db dyn SourceDatabase ,
327361 krate : Crate ,
@@ -555,6 +589,7 @@ pub(crate) fn extract_docs<'a, 'db>(
555589mod 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, "\n foo\n bar\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}
0 commit comments