Skip to main content

hir_def/attrs/
docs.rs

1//! Documentation extraction and source mapping.
2//!
3//! This module handles the extraction and processing of doc comments and `#[doc = "..."]`
4//! attributes, including macro expansion for `#[doc = macro!()]` patterns.
5//! It builds a concatenated string of the full docs as well as a source map
6//! to map it back to AST (which is needed for things like resolving links in doc comments
7//! and highlight injection).
8
9use std::{
10    borrow::Cow,
11    convert::Infallible,
12    ops::{ControlFlow, Range},
13};
14
15use base_db::{Crate, SourceDatabase};
16use cfg::CfgOptions;
17use either::Either;
18use hir_expand::{
19    AstId, ExpandTo, HirFileId, InFile, MacroCallId,
20    attrs::{AstPathExt, expand_cfg_attr_with_doc_comments},
21    mod_path::ModPath,
22    span_map::SpanMap,
23};
24use span::AstIdMap;
25use syntax::{
26    AstNode, SyntaxNode,
27    ast::{self, IsString},
28};
29use thin_vec::ThinVec;
30use tt::{TextRange, TextSize};
31
32use crate::{macro_call_as_call_id, nameres::MacroSubNs, resolver::Resolver};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35struct DocsSourceMapLine {
36    /// The offset in [`Docs::docs`].
37    string_offset: TextSize,
38    /// The offset in the AST of the text. `None` for macro-expanded doc strings
39    /// where we cannot provide a faithful source mapping.
40    ast_offset: Option<TextSize>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct Docs {
45    /// The concatenated string of all `#[doc = "..."]` attributes and documentation comments.
46    docs: String,
47    /// A sorted map from an offset in `docs` to an offset in the source code.
48    docs_source_map: Vec<DocsSourceMapLine>,
49    /// If the item is an outlined module (`mod foo;`), `docs_source_map` stores the concatenated
50    /// list of the outline and inline docs (outline first). Then, this field contains the [`HirFileId`]
51    /// of the outline declaration, and the index in `docs` from which the inline docs
52    /// begin.
53    outline_mod: Option<(HirFileId, usize)>,
54    inline_file: HirFileId,
55    /// The size of the prepended prefix, which does not map to real doc comments.
56    prefix_len: TextSize,
57    /// The offset in `docs` from which the docs are inner attributes/comments.
58    inline_inner_docs_start: Option<TextSize>,
59    /// Like `inline_inner_docs_start`, but for `outline_mod`. This can happen only when merging `Docs`
60    /// (as outline modules don't have inner attributes).
61    outline_inner_docs_start: Option<TextSize>,
62    /// All macro calls in `#[doc = ...]` attributes, recursively.
63    macro_calls: ThinVec<(AstId<ast::MacroCall>, MacroCallId)>,
64}
65
66#[derive(Clone, Copy)]
67enum DocCommentKind {
68    /// `///` etc..
69    Sugared(ast::CommentShape),
70    /// `#[doc = ""]`.
71    Desugared,
72}
73
74#[derive(Default)]
75struct Indent {
76    lines: Vec<Option<(usize, DocCommentKind)>>,
77    seen_sugared: bool,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum IsInnerDoc {
82    No,
83    Yes,
84}
85
86impl IsInnerDoc {
87    #[inline]
88    pub fn yes(self) -> bool {
89        self == IsInnerDoc::Yes
90    }
91}
92
93impl Docs {
94    #[inline]
95    pub fn docs(&self) -> &str {
96        &self.docs
97    }
98
99    #[inline]
100    pub fn into_docs(self) -> String {
101        self.docs
102    }
103
104    #[inline]
105    pub fn macro_calls(&self) -> impl Iterator<Item = (AstId<ast::MacroCall>, MacroCallId)> {
106        self.macro_calls.iter().copied()
107    }
108
109    fn is_empty(&self) -> bool {
110        let Self {
111            docs,
112            docs_source_map: _,
113            outline_mod: _,
114            inline_file: _,
115            prefix_len: _,
116            inline_inner_docs_start: _,
117            outline_inner_docs_start: _,
118            macro_calls,
119        } = self;
120        docs.is_empty() && macro_calls.is_empty()
121    }
122
123    pub fn find_ast_range(
124        &self,
125        mut string_range: TextRange,
126    ) -> Option<(InFile<TextRange>, IsInnerDoc)> {
127        if string_range.start() < self.prefix_len {
128            return None;
129        }
130        string_range -= self.prefix_len;
131
132        let mut file = self.inline_file;
133        let mut inner_docs_start = self.inline_inner_docs_start;
134        // Check whether the range is from the outline, the inline, or both.
135        let source_map = if let Some((outline_mod_file, outline_mod_end)) = self.outline_mod {
136            if let Some(first_inline) = self.docs_source_map.get(outline_mod_end) {
137                if string_range.end() <= first_inline.string_offset {
138                    // The range is completely in the outline.
139                    file = outline_mod_file;
140                    inner_docs_start = self.outline_inner_docs_start;
141                    &self.docs_source_map[..outline_mod_end]
142                } else if string_range.start() >= first_inline.string_offset {
143                    // The range is completely in the inline.
144                    &self.docs_source_map[outline_mod_end..]
145                } else {
146                    // The range is combined from the outline and the inline - cannot map it back.
147                    return None;
148                }
149            } else {
150                // There is no inline.
151                file = outline_mod_file;
152                inner_docs_start = self.outline_inner_docs_start;
153                &self.docs_source_map
154            }
155        } else {
156            // There is no outline.
157            &self.docs_source_map
158        };
159
160        let after_range =
161            source_map.partition_point(|line| line.string_offset <= string_range.start()) - 1;
162        let after_range = &source_map[after_range..];
163        let line = after_range.first()?;
164        // Unmapped lines (from macro-expanded docs) cannot be mapped back to AST.
165        let ast_offset = line.ast_offset?;
166        if after_range.get(1).is_some_and(|next_line| next_line.string_offset < string_range.end())
167        {
168            // The range is combined from two lines - cannot map it back.
169            return None;
170        }
171        let ast_range = string_range - line.string_offset + ast_offset;
172        let is_inner = if inner_docs_start
173            .is_some_and(|inner_docs_start| string_range.start() >= inner_docs_start)
174        {
175            IsInnerDoc::Yes
176        } else {
177            IsInnerDoc::No
178        };
179        Some((InFile::new(file, ast_range), is_inner))
180    }
181
182    #[inline]
183    pub fn shift_by(&mut self, offset: TextSize) {
184        self.prefix_len += offset;
185    }
186
187    pub fn prepend_str(&mut self, s: &str) {
188        self.prefix_len += TextSize::of(s);
189        self.docs.insert_str(0, s);
190    }
191
192    pub fn append_str(&mut self, s: &str) {
193        self.docs.push_str(s);
194    }
195
196    pub fn append(&mut self, other: &Docs) {
197        let other_offset = TextSize::of(&self.docs);
198
199        assert!(
200            self.outline_mod.is_none() && other.outline_mod.is_none(),
201            "cannot merge `Docs` that have `outline_mod` set"
202        );
203        self.outline_mod = Some((self.inline_file, self.docs_source_map.len()));
204        self.inline_file = other.inline_file;
205        self.outline_inner_docs_start = self.inline_inner_docs_start;
206        self.inline_inner_docs_start = other.inline_inner_docs_start.map(|it| it + other_offset);
207
208        self.docs.push_str(&other.docs);
209        self.docs_source_map.extend(other.docs_source_map.iter().map(
210            |&DocsSourceMapLine { string_offset, ast_offset }| DocsSourceMapLine {
211                ast_offset,
212                string_offset: string_offset + other_offset,
213            },
214        ));
215    }
216
217    fn extend_with_doc_comment(&mut self, comment: ast::DocComment, indent: &mut Indent) {
218        let doc = comment.text();
219        let offset = comment.syntax().text_range().start() + ast::DocComment::PREFIX_LEN;
220        self.extend_with_doc_str(doc, offset, DocCommentKind::Sugared(comment.shape()), indent);
221    }
222
223    fn extend_with_doc_attr(&mut self, value: ast::String, indent: &mut Indent) {
224        let Some(value_offset) = value.text_range_between_quotes() else { return };
225        let value_offset = value_offset.start();
226        let Ok(unescaped) = value.value() else { return };
227        // `value()` borrows the source text only when the string has no escapes. With escapes,
228        // offsets in the unescaped text don't match offsets in the source, so leave it unmapped.
229        // FIXME: Map the text between escapes.
230        let ast_offset = matches!(unescaped, Cow::Borrowed(_)).then_some(value_offset);
231        self.push_doc_lines(&unescaped, ast_offset, DocCommentKind::Desugared, indent);
232    }
233
234    fn extend_with_doc_str(
235        &mut self,
236        doc: &str,
237        offset_in_ast: TextSize,
238        comment_kind: DocCommentKind,
239        indent: &mut Indent,
240    ) {
241        self.push_doc_lines(doc, Some(offset_in_ast), comment_kind, indent);
242    }
243
244    fn extend_with_unmapped_doc_str(&mut self, doc: &str, indent: &mut Indent) {
245        self.push_doc_lines(doc, None, DocCommentKind::Desugared, indent);
246    }
247
248    /// Beautifies `doc` and appends the result to `self.docs`, one line at a time via
249    /// [`Docs::push_doc_line`]. Mirrors rustc's [`beautify_doc_string`], delegating to
250    /// [`get_vertical_trim`] and [`get_horizontal_trim`] for the multi-line case.
251    ///
252    /// Individual `///` line comments always reach us as a single-line `doc`, so the
253    /// `!doc.contains('\n')` fast path fires and the multi-line logic never runs on them.
254    /// Desugared `#[doc = "..."]` strings and macro-expanded docs also route through here
255    /// with `shape = CommentShape::Line`, matching rustc.
256    ///
257    /// Unlike rustc's version, which joins the beautified lines into a new interned `Symbol`,
258    /// this port pushes each line individually via [`Docs::push_doc_line`] and pairs it with
259    /// its byte offset relative to `doc`'s start so the source-map records accurate per-line
260    /// offsets.
261    ///
262    /// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L37
263    fn push_doc_lines(
264        &mut self,
265        doc: &str,
266        ast_offset: Option<TextSize>,
267        comment_kind: DocCommentKind,
268        indent: &mut Indent,
269    ) {
270        // Note: this is pushed even if there are only empty lines here, because that's what rustdoc does.
271        let shape = match comment_kind {
272            DocCommentKind::Sugared(shape) => {
273                indent.seen_sugared = true;
274                shape
275            }
276            // rustc uses `Line` for desugared comments.
277            DocCommentKind::Desugared => ast::CommentShape::Line,
278        };
279
280        if !doc.contains('\n') {
281            self.push_doc_line(doc, ast_offset, comment_kind, indent);
282            return;
283        }
284
285        let mut lines: Vec<(&str, TextSize)> = doc
286            .lines()
287            .map(|line| {
288                let offset = TextSize::new(doc.substr_range(line).unwrap().start as u32);
289                (line, offset)
290            })
291            .collect();
292
293        let raw_lines: Vec<&str> = lines.iter().map(|(l, _)| *l).collect();
294        let lines = match get_vertical_trim(&raw_lines) {
295            Some((i, j)) => &mut lines[i..j],
296            None => &mut lines[..],
297        };
298
299        let raw_lines: Vec<&str> = lines.iter().map(|(l, _)| *l).collect();
300        if let Some(horizontal) = get_horizontal_trim(&raw_lines, shape) {
301            let horizontal_len = TextSize::of(horizontal.as_str());
302            // Strip `"[ \t]*\*"` from each line where present, exactly like rustc.
303            for (line, line_offset) in lines.iter_mut() {
304                if let Some(rest) = line.strip_prefix(horizontal.as_str()) {
305                    *line = rest;
306                    *line_offset += horizontal_len;
307                    if shape == ast::CommentShape::Block
308                        && (*line == "*" || line.starts_with("* ") || line.starts_with("**"))
309                    {
310                        *line = &line[1..];
311                        *line_offset += TextSize::of("*");
312                    }
313                }
314            }
315        }
316
317        for (line, line_offset) in lines.iter().copied() {
318            self.push_doc_line(line, ast_offset.map(|it| it + line_offset), comment_kind, indent);
319        }
320    }
321
322    /// Appends a single beautified line to `self.docs` and records its source-map row.
323    fn push_doc_line(
324        &mut self,
325        line: &str,
326        ast_offset: Option<TextSize>,
327        comment_kind: DocCommentKind,
328        indent: &mut Indent,
329    ) {
330        self.docs_source_map
331            .push(DocsSourceMapLine { string_offset: TextSize::of(&self.docs), ast_offset });
332
333        let line = line.trim_end();
334        let line_indent = if line.chars().any(|ch| !ch.is_whitespace()) {
335            // Empty lines are handled because `any()` returns `false` for them.
336            let line_indent = line.bytes().take_while(|c| *c == b' ' || *c == b'\t').count();
337            Some((line_indent, comment_kind))
338        } else {
339            None
340        };
341        indent.lines.push(line_indent);
342        self.docs.push_str(line);
343        self.docs.push('\n');
344    }
345
346    fn remove_indent(&mut self, indent: &Indent) {
347        /// In case of panics, we want to avoid corrupted UTF-8 in `self.docs`, so we clear it.
348        struct Guard<'a>(&'a mut Docs);
349        impl Drop for Guard<'_> {
350            fn drop(&mut self) {
351                let Docs {
352                    docs,
353                    docs_source_map,
354                    outline_mod,
355                    inline_file: _,
356                    prefix_len: _,
357                    inline_inner_docs_start: _,
358                    outline_inner_docs_start: _,
359                    macro_calls: _,
360                } = self.0;
361                // Don't use `String::clear()` here because it's not guaranteed to not do UTF-8-dependent things,
362                // and we may have temporarily broken the string's encoding.
363                unsafe { docs.as_mut_vec() }.clear();
364                // This is just to avoid panics down the road.
365                docs_source_map.clear();
366                *outline_mod = None;
367            }
368        }
369
370        if self.docs.is_empty() {
371            return;
372        }
373
374        // `add` is used in case the most common sugared doc syntax is used ("/// "). The other
375        // fragments kind's lines are never starting with a whitespace unless they are using some
376        // markdown formatting requiring it. Therefore, if the doc block have a mix between the two,
377        // we need to take into account the fact that the minimum indent minus one (to take this
378        // whitespace into account).
379        //
380        // For example:
381        //
382        // /// hello!
383        // #[doc = "another"]
384        //
385        // In this case, you want "hello! another" and not "hello!  another".
386        let add_indent = if indent.seen_sugared { 1 } else { 0 };
387
388        let Some(min_indent) = indent
389            .lines
390            .iter()
391            .filter_map(|it| *it)
392            .map(|(line_indent, line_kind)| {
393                line_indent
394                    + match line_kind {
395                        DocCommentKind::Sugared(_) => 0,
396                        DocCommentKind::Desugared => add_indent,
397                    }
398            })
399            .min()
400        else {
401            return;
402        };
403
404        let guard = Guard(self);
405        let source_map = guard.0.docs_source_map.as_mut_slice();
406        let Some(&DocsSourceMapLine { string_offset: mut copy_into, .. }) = source_map.first()
407        else {
408            return;
409        };
410        // We basically want to remove multiple ranges from a string. Doing this efficiently (without O(N^2)
411        // or allocations) requires unsafe. Basically, for each line, we copy the line minus the indent into
412        // consecutive to the previous line (which may have moved). Then at the end we truncate.
413        let mut accumulated_offset = TextSize::new(0);
414        for idx in 0..source_map.len() {
415            let string_end_offset = source_map
416                .get(idx + 1)
417                .map_or_else(|| TextSize::of(&guard.0.docs), |next_attr| next_attr.string_offset);
418            let line_source = &mut source_map[idx];
419            let line_docs =
420                &guard.0.docs[TextRange::new(line_source.string_offset, string_end_offset)];
421            let line_docs_len = TextSize::of(line_docs);
422            let indent_size = if let Some((_, DocCommentKind::Desugared)) = indent.lines[idx]
423                && min_indent > 0
424            {
425                min_indent - add_indent
426            } else {
427                min_indent
428            };
429            let indent_size = line_docs.char_indices().nth(indent_size).map_or_else(
430                || TextSize::of(line_docs) - TextSize::of("\n"),
431                |(offset, _)| TextSize::new(offset as u32),
432            );
433            unsafe { guard.0.docs.as_bytes_mut() }.copy_within(
434                Range::<usize>::from(TextRange::new(
435                    line_source.string_offset + indent_size,
436                    string_end_offset,
437                )),
438                copy_into.into(),
439            );
440            copy_into += line_docs_len - indent_size;
441
442            if let Some(inner_attrs_start) = &mut guard.0.inline_inner_docs_start
443                && *inner_attrs_start == line_source.string_offset
444            {
445                *inner_attrs_start -= accumulated_offset;
446            }
447            // The removals in the string accumulate, but in the AST not, because it already points
448            // to the beginning of each attribute.
449            // Also, we need to shift the AST offset of every line, but the string offset of the first
450            // line should not get shifted (in general, the shift for the string offset is by the
451            // number of lines until the current one, excluding the current one).
452            line_source.string_offset -= accumulated_offset;
453            if let Some(ref mut ast_offset) = line_source.ast_offset {
454                *ast_offset += indent_size;
455            }
456
457            accumulated_offset += indent_size;
458        }
459        // Don't use `String::truncate()` here because it's not guaranteed to not do UTF-8-dependent things,
460        // and we may have temporarily broken the string's encoding.
461        unsafe { guard.0.docs.as_mut_vec() }.truncate(copy_into.into());
462
463        std::mem::forget(guard);
464    }
465
466    fn remove_last_newline(&mut self) {
467        self.docs.truncate(self.docs.len().saturating_sub(1));
468    }
469
470    fn shrink_to_fit(&mut self) {
471        let Docs {
472            docs,
473            docs_source_map,
474            outline_mod: _,
475            inline_file: _,
476            prefix_len: _,
477            inline_inner_docs_start: _,
478            outline_inner_docs_start: _,
479            macro_calls,
480        } = self;
481        docs.shrink_to_fit();
482        docs_source_map.shrink_to_fit();
483        macro_calls.shrink_to_fit();
484    }
485}
486
487/// Copied verbatim from rustc's [`beautify_doc_string`], modulo `CommentKind`/`CommentShape`
488/// renaming.
489///
490/// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L38
491fn get_vertical_trim(lines: &[&str]) -> Option<(usize, usize)> {
492    let mut i = 0;
493    let mut j = lines.len();
494    // first line of all-stars should be omitted
495    if lines.first().is_some_and(|line| line.chars().all(|c| c == '*')) {
496        i += 1;
497    }
498
499    // like the first, a last line of all stars should be omitted
500    if j > i && !lines[j - 1].is_empty() && lines[j - 1].chars().all(|c| c == '*') {
501        j -= 1;
502    }
503
504    if i != 0 || j != lines.len() { Some((i, j)) } else { None }
505}
506
507/// Copied verbatim from rustc's [`beautify_doc_string`], modulo `CommentKind`/`CommentShape`
508/// renaming and returning `String` rather than interning to `Symbol`.
509///
510/// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L54
511fn get_horizontal_trim(lines: &[&str], kind: ast::CommentShape) -> Option<String> {
512    let mut i = usize::MAX;
513    let mut first = true;
514
515    // In case we have doc comments like `/**` or `/*!`, we want to remove stars if they are
516    // present. However, we first need to strip the empty lines so they don't get in the middle
517    // when we try to compute the "horizontal trim".
518    let lines = match kind {
519        ast::CommentShape::Block => {
520            // Whatever happens, we skip the first line.
521            let mut i = lines
522                .first()
523                .map(|l| if l.trim_start().starts_with('*') { 0 } else { 1 })
524                .unwrap_or(0);
525            let mut j = lines.len();
526
527            while i < j && lines[i].trim().is_empty() {
528                i += 1;
529            }
530            while j > i && lines[j - 1].trim().is_empty() {
531                j -= 1;
532            }
533            &lines[i..j]
534        }
535        ast::CommentShape::Line => lines,
536    };
537
538    for line in lines {
539        for (j, c) in line.chars().enumerate() {
540            if j > i || !"* \t".contains(c) {
541                return None;
542            }
543            if c == '*' {
544                if first {
545                    i = j;
546                    first = false;
547                } else if i != j {
548                    return None;
549                }
550                break;
551            }
552        }
553        if i >= line.len() {
554            return None;
555        }
556    }
557    Some(lines.first()?[..i].to_string())
558}
559
560struct DocMacroExpander<'db> {
561    db: &'db dyn SourceDatabase,
562    krate: Crate,
563    macro_depth: u32,
564    recursion_limit: u32,
565    resolver: Resolver<'db>,
566    file_id: HirFileId,
567    ast_id_map: &'db AstIdMap,
568    span_map: SpanMap<'db>,
569}
570
571fn expand_doc_expr_via_macro_pipeline<'db>(
572    expander: &mut DocMacroExpander<'db>,
573    macro_calls: &mut ThinVec<(AstId<ast::MacroCall>, MacroCallId)>,
574    expr: ast::Expr,
575) -> Option<String> {
576    match expr {
577        ast::Expr::ParenExpr(paren_expr) => {
578            expand_doc_expr_via_macro_pipeline(expander, macro_calls, paren_expr.expr()?)
579        }
580        ast::Expr::Literal(literal) => match literal.kind() {
581            ast::LiteralKind::String(string) => string.value().ok().map(Into::into),
582            _ => None,
583        },
584        ast::Expr::MacroExpr(macro_expr) => {
585            let macro_call = macro_expr.macro_call()?;
586            expand_doc_macro_call(expander, macro_calls, macro_call)
587        }
588        _ => None,
589    }
590}
591
592fn expand_doc_macro_call<'db>(
593    expander: &mut DocMacroExpander<'db>,
594    macro_calls: &mut ThinVec<(AstId<ast::MacroCall>, MacroCallId)>,
595    macro_call: ast::MacroCall,
596) -> Option<String> {
597    if expander.macro_depth >= expander.recursion_limit {
598        return None;
599    }
600
601    let path = macro_call.path()?;
602    let mod_path = ModPath::from_src(expander.db, path, &mut |range| {
603        expander.span_map.span_for_range(range).ctx
604    })?;
605    let call_site = expander.span_map.span_for_range(macro_call.syntax().text_range());
606    let ast_id = AstId::new(expander.file_id, expander.ast_id_map.ast_id(&macro_call));
607    let call_id = macro_call_as_call_id(
608        expander.db,
609        ast_id,
610        &mod_path,
611        call_site.ctx,
612        ExpandTo::Expr,
613        expander.krate,
614        expander.macro_depth + 1,
615        expander.recursion_limit,
616        |path| {
617            expander.resolver.resolve_path_as_macro_def(expander.db, path, Some(MacroSubNs::Bang))
618        },
619        &mut |_, _| (),
620    )
621    .ok()?
622    .value?;
623    macro_calls.push((ast_id, call_id));
624
625    let (parse, span_map) = &call_id.parse_macro_expansion(expander.db).value;
626    let expr = parse.clone().cast::<ast::Expr>().map(|parse| parse.tree())?;
627
628    // Build a new source context for the expansion file so that any further
629    // recursive expansion (e.g. a user macro expanding to `concat!(...)`)
630    // correctly resolves AstIds and spans in the expansion.
631    let expansion_file_id: HirFileId = call_id.into();
632    let old_file_id = std::mem::replace(&mut expander.file_id, expansion_file_id);
633    let old_span_map =
634        std::mem::replace(&mut expander.span_map, SpanMap::ExpansionSpanMap(span_map));
635    let old_ast_id_map =
636        std::mem::replace(&mut expander.ast_id_map, expansion_file_id.ast_id_map(expander.db));
637    expander.macro_depth += 1;
638
639    let expansion = expand_doc_expr_via_macro_pipeline(expander, macro_calls, expr);
640
641    expander.file_id = old_file_id;
642    expander.span_map = old_span_map;
643    expander.ast_id_map = old_ast_id_map;
644    expander.macro_depth -= 1;
645
646    expansion
647}
648
649fn extend_with_attrs<'a, 'db>(
650    result: &mut Docs,
651    db: &'db dyn SourceDatabase,
652    krate: Crate,
653    node: &SyntaxNode,
654    file_id: HirFileId,
655    expect_inner_attrs: bool,
656    indent: &mut Indent,
657    get_cfg_options: &dyn Fn() -> &'a CfgOptions,
658    cfg_options: &mut Option<&'a CfgOptions>,
659    make_resolver: &dyn Fn() -> Resolver<'db>,
660) {
661    // Lazily initialised when we first encounter a `#[doc = macro!()]`.
662    let mut expander = None;
663
664    expand_cfg_attr_with_doc_comments::<_, Infallible>(
665        node.children()
666            .filter_map(ast::AnyAttr::cast)
667            .filter(|attr| attr.kind().is_inner() == expect_inner_attrs)
668            .map(|attr| match attr {
669                ast::AnyAttr::Attr(it) => Either::Left(it),
670                ast::AnyAttr::DocComment(it) => Either::Right(it),
671            }),
672        || *cfg_options.get_or_insert_with(get_cfg_options),
673        |attr| {
674            match attr {
675                Either::Right(doc_comment) => result.extend_with_doc_comment(doc_comment, indent),
676                Either::Left((attr, _)) => match attr {
677                    ast::Meta::KeyValueMeta(attr) if attr.path().is1("doc") => {
678                        if let Some(value) = attr.expr() {
679                            if let ast::Expr::Literal(value) = &value
680                                && let ast::LiteralKind::String(value) = value.kind()
681                            {
682                                result.extend_with_doc_attr(value, indent);
683                            } else {
684                                let exp = expander.get_or_insert_with(|| {
685                                    let resolver = make_resolver();
686                                    let def_map = resolver.top_level_def_map();
687                                    let recursion_limit = def_map.recursion_limit();
688                                    DocMacroExpander {
689                                        db,
690                                        krate,
691                                        macro_depth: file_id.macro_expansion_depth(db),
692                                        recursion_limit,
693                                        resolver,
694                                        file_id,
695                                        ast_id_map: file_id.ast_id_map(db),
696                                        span_map: file_id.span_map(db),
697                                    }
698                                });
699                                if let Some(expanded) = expand_doc_expr_via_macro_pipeline(
700                                    exp,
701                                    &mut result.macro_calls,
702                                    value,
703                                ) {
704                                    result.extend_with_unmapped_doc_str(&expanded, indent);
705                                }
706                            }
707                        }
708                    }
709                    _ => {}
710                },
711            }
712            ControlFlow::Continue(())
713        },
714    );
715}
716
717pub(crate) fn extract_docs<'a, 'db>(
718    db: &'db dyn SourceDatabase,
719    krate: Crate,
720    resolver: &dyn Fn() -> Resolver<'db>,
721    get_cfg_options: &dyn Fn() -> &'a CfgOptions,
722    source: InFile<ast::AnyHasAttrs>,
723    outer_mod_decl: Option<InFile<ast::Module>>,
724    inner_attrs_node: Option<SyntaxNode>,
725) -> Option<Box<Docs>> {
726    let mut result = Docs {
727        docs: String::new(),
728        docs_source_map: Vec::new(),
729        outline_mod: None,
730        inline_file: source.file_id,
731        prefix_len: TextSize::new(0),
732        inline_inner_docs_start: None,
733        outline_inner_docs_start: None,
734        macro_calls: ThinVec::new(),
735    };
736
737    let mut cfg_options = None;
738
739    let mut indent = Indent::default();
740    if let Some(outer_mod_decl) = outer_mod_decl {
741        // For outer docs (the `mod foo;` declaration), use the module's own resolver.
742        extend_with_attrs(
743            &mut result,
744            db,
745            krate,
746            outer_mod_decl.value.syntax(),
747            outer_mod_decl.file_id,
748            false,
749            &mut indent,
750            get_cfg_options,
751            &mut cfg_options,
752            resolver,
753        );
754        result.outline_mod = Some((outer_mod_decl.file_id, result.docs_source_map.len()));
755    }
756
757    // For inline docs, use the item's own resolver.
758    extend_with_attrs(
759        &mut result,
760        db,
761        krate,
762        source.value.syntax(),
763        source.file_id,
764        false,
765        &mut indent,
766        get_cfg_options,
767        &mut cfg_options,
768        resolver,
769    );
770    if let Some(inner_attrs_node) = &inner_attrs_node {
771        result.inline_inner_docs_start = Some(TextSize::of(&result.docs));
772        extend_with_attrs(
773            &mut result,
774            db,
775            krate,
776            inner_attrs_node,
777            source.file_id,
778            true,
779            &mut indent,
780            get_cfg_options,
781            &mut cfg_options,
782            resolver,
783        );
784    }
785    result.remove_indent(&indent);
786
787    result.remove_last_newline();
788
789    result.shrink_to_fit();
790
791    if result.is_empty() { None } else { Some(Box::new(result)) }
792}
793
794#[cfg(test)]
795mod tests {
796    use expect_test::expect;
797    use hir_expand::InFile;
798    use syntax::{AstNode, AstToken, ast};
799    use test_fixture::WithFixture;
800    use thin_vec::ThinVec;
801    use tt::{TextRange, TextSize};
802
803    use crate::test_db::TestDB;
804
805    use super::{DocCommentKind, Docs, Indent, IsInnerDoc};
806
807    #[test]
808    fn docs() {
809        let (_db, file_id) = TestDB::with_single_file("");
810        let mut docs = Docs {
811            docs: String::new(),
812            docs_source_map: Vec::new(),
813            outline_mod: None,
814            inline_file: file_id.into(),
815            prefix_len: TextSize::new(0),
816            inline_inner_docs_start: None,
817            outline_inner_docs_start: None,
818            macro_calls: ThinVec::new(),
819        };
820        let mut indent = Indent::default();
821
822        let outer = " foo\n\tbar  baz";
823        let mut ast_offset = TextSize::new(123);
824        for line in outer.split('\n') {
825            docs.extend_with_doc_str(
826                line,
827                ast_offset,
828                DocCommentKind::Sugared(ast::CommentShape::Line),
829                &mut indent,
830            );
831            ast_offset += TextSize::of(line) + TextSize::of("\n");
832        }
833
834        docs.inline_inner_docs_start = Some(TextSize::of(&docs.docs));
835        ast_offset += TextSize::new(123);
836        let inner = " bar \n baz";
837        for line in inner.split('\n') {
838            docs.extend_with_doc_str(
839                line,
840                ast_offset,
841                DocCommentKind::Sugared(ast::CommentShape::Line),
842                &mut indent,
843            );
844            ast_offset += TextSize::of(line) + TextSize::of("\n");
845        }
846
847        expect![[r#"
848            [
849                DocsSourceMapLine {
850                    string_offset: 0,
851                    ast_offset: Some(
852                        123,
853                    ),
854                },
855                DocsSourceMapLine {
856                    string_offset: 5,
857                    ast_offset: Some(
858                        128,
859                    ),
860                },
861                DocsSourceMapLine {
862                    string_offset: 15,
863                    ast_offset: Some(
864                        261,
865                    ),
866                },
867                DocsSourceMapLine {
868                    string_offset: 20,
869                    ast_offset: Some(
870                        267,
871                    ),
872                },
873            ]
874        "#]]
875        .assert_debug_eq(&docs.docs_source_map);
876
877        docs.remove_indent(&indent);
878
879        assert_eq!(docs.inline_inner_docs_start, Some(TextSize::new(13)));
880
881        assert_eq!(docs.docs, "foo\nbar  baz\nbar\nbaz\n");
882        expect![[r#"
883            [
884                DocsSourceMapLine {
885                    string_offset: 0,
886                    ast_offset: Some(
887                        124,
888                    ),
889                },
890                DocsSourceMapLine {
891                    string_offset: 4,
892                    ast_offset: Some(
893                        129,
894                    ),
895                },
896                DocsSourceMapLine {
897                    string_offset: 13,
898                    ast_offset: Some(
899                        262,
900                    ),
901                },
902                DocsSourceMapLine {
903                    string_offset: 17,
904                    ast_offset: Some(
905                        268,
906                    ),
907                },
908            ]
909        "#]]
910        .assert_debug_eq(&docs.docs_source_map);
911
912        docs.append(&docs.clone());
913        docs.prepend_str("prefix---");
914        assert_eq!(docs.docs, "prefix---foo\nbar  baz\nbar\nbaz\nfoo\nbar  baz\nbar\nbaz\n");
915        expect![[r#"
916            [
917                DocsSourceMapLine {
918                    string_offset: 0,
919                    ast_offset: Some(
920                        124,
921                    ),
922                },
923                DocsSourceMapLine {
924                    string_offset: 4,
925                    ast_offset: Some(
926                        129,
927                    ),
928                },
929                DocsSourceMapLine {
930                    string_offset: 13,
931                    ast_offset: Some(
932                        262,
933                    ),
934                },
935                DocsSourceMapLine {
936                    string_offset: 17,
937                    ast_offset: Some(
938                        268,
939                    ),
940                },
941                DocsSourceMapLine {
942                    string_offset: 21,
943                    ast_offset: Some(
944                        124,
945                    ),
946                },
947                DocsSourceMapLine {
948                    string_offset: 25,
949                    ast_offset: Some(
950                        129,
951                    ),
952                },
953                DocsSourceMapLine {
954                    string_offset: 34,
955                    ast_offset: Some(
956                        262,
957                    ),
958                },
959                DocsSourceMapLine {
960                    string_offset: 38,
961                    ast_offset: Some(
962                        268,
963                    ),
964                },
965            ]
966        "#]]
967        .assert_debug_eq(&docs.docs_source_map);
968
969        let range = |start, end| TextRange::new(TextSize::new(start), TextSize::new(end));
970        let in_file = |range| InFile::new(file_id.into(), range);
971        assert_eq!(docs.find_ast_range(range(0, 2)), None);
972        assert_eq!(docs.find_ast_range(range(8, 10)), None);
973        assert_eq!(
974            docs.find_ast_range(range(9, 10)),
975            Some((in_file(range(124, 125)), IsInnerDoc::No))
976        );
977        assert_eq!(docs.find_ast_range(range(20, 23)), None);
978        assert_eq!(
979            docs.find_ast_range(range(23, 25)),
980            Some((in_file(range(263, 265)), IsInnerDoc::Yes))
981        );
982    }
983
984    #[test]
985    fn sugared_desugared_mix() {
986        let (_db, file_id) = TestDB::with_single_file("");
987        let mut docs = Docs {
988            docs: String::new(),
989            docs_source_map: Vec::new(),
990            outline_mod: None,
991            inline_file: file_id.into(),
992            prefix_len: TextSize::new(0),
993            inline_inner_docs_start: None,
994            outline_inner_docs_start: None,
995            macro_calls: ThinVec::new(),
996        };
997        let mut indent = Indent::default();
998
999        docs.push_doc_lines(
1000            " hello!",
1001            None,
1002            DocCommentKind::Sugared(ast::CommentShape::Line),
1003            &mut indent,
1004        );
1005        docs.push_doc_lines("another", None, DocCommentKind::Desugared, &mut indent);
1006        docs.remove_indent(&indent);
1007        docs.remove_last_newline();
1008
1009        assert_eq!(docs.docs(), "hello!\nanother");
1010    }
1011
1012    /// Extracts the docs of the first comment in `source`, running the same normalization as
1013    /// [`super::extract_docs`] does for inline docs.
1014    fn comment_docs(source: &str) -> Docs {
1015        let (_db, file_id) = TestDB::with_single_file("");
1016        let comment = syntax::SourceFile::parse(source, span::Edition::CURRENT)
1017            .syntax_node()
1018            .descendants_with_tokens()
1019            .filter_map(|it| it.into_node())
1020            .find_map(ast::DocComment::cast)
1021            .expect("no comment in the fixture");
1022        let mut docs = Docs {
1023            docs: String::new(),
1024            docs_source_map: Vec::new(),
1025            outline_mod: None,
1026            inline_file: file_id.into(),
1027            prefix_len: TextSize::new(0),
1028            inline_inner_docs_start: None,
1029            outline_inner_docs_start: None,
1030            macro_calls: ThinVec::new(),
1031        };
1032        let mut indent = Indent::default();
1033        docs.extend_with_doc_comment(comment, &mut indent);
1034        docs.remove_indent(&indent);
1035        docs.remove_last_newline();
1036        docs
1037    }
1038
1039    #[test]
1040    fn block_doc_comment_stars() {
1041        #[track_caller]
1042        fn check(source: &str, expect: expect_test::Expect) {
1043            expect.assert_eq(&comment_docs(source).docs);
1044        }
1045
1046        // The decoration is stripped, but markdown bullets and `*foo` are content.
1047        // `*bar` doesn't start with `* ` / `**`, so rustc's beautifier only strips the
1048        // horizontal `[ \t]*` prefix (here a single space) and leaves the leading `*` in
1049        // place. That in turn pins the block's minimum indent at 0, so surrounding lines
1050        // aren't re-indented.
1051        check(
1052            "/**\n * foo\n *\n *   * bullet\n *bar\n */",
1053            expect![[r#"
1054                 foo
1055
1056                   * bullet
1057                *bar
1058            "#]],
1059        );
1060        // Single-line block doc comments are left alone, like rustdoc does.
1061        check("/** * item */", expect!["* item"]);
1062        // So are blocks without a consistent star column.
1063        check(
1064            "/**\n * foo\n   * bar\n */",
1065            expect![[r#"
1066                * foo
1067                  * bar
1068            "#]],
1069        );
1070    }
1071
1072    #[test]
1073    fn block_doc_comment_source_map() {
1074        let docs = comment_docs("/**\n * foo\n * bar\n */");
1075        // `.lines()` (matching rustc) doesn't emit a leading empty entry for the newline
1076        // right after `/**`, so the docs body starts at `foo`, not with a blank line.
1077        assert_eq!(docs.docs, "foo\nbar\n");
1078
1079        let range = |start, end| TextRange::new(TextSize::new(start), TextSize::new(end));
1080        let in_file = |range| InFile::new(docs.inline_file, range);
1081        let mapped = |start, end| docs.find_ast_range(range(start, end));
1082        // Both `foo` and `bar` map back past the stripped ` * ` decoration.
1083        assert_eq!(mapped(0, 3), Some((in_file(range(7, 10)), IsInnerDoc::No)));
1084        assert_eq!(mapped(4, 7), Some((in_file(range(14, 17)), IsInnerDoc::No)));
1085    }
1086
1087    /// Extracts the docs of the first string in `source`, as if it were a `#[doc = "..."]` value.
1088    fn doc_attr_docs(source: &str) -> Docs {
1089        let (_db, file_id) = TestDB::with_single_file("");
1090        let value = syntax::SourceFile::parse(source, span::Edition::CURRENT)
1091            .syntax_node()
1092            .descendants_with_tokens()
1093            .filter_map(|it| it.into_token())
1094            .find_map(ast::String::cast)
1095            .expect("no string in the fixture");
1096        let mut docs = Docs {
1097            docs: String::new(),
1098            docs_source_map: Vec::new(),
1099            outline_mod: None,
1100            inline_file: file_id.into(),
1101            prefix_len: TextSize::new(0),
1102            inline_inner_docs_start: None,
1103            outline_inner_docs_start: None,
1104            macro_calls: ThinVec::new(),
1105        };
1106        let mut indent = Indent::default();
1107        docs.extend_with_doc_attr(value, &mut indent);
1108        docs.remove_indent(&indent);
1109        docs.remove_last_newline();
1110        docs
1111    }
1112
1113    #[test]
1114    fn doc_attr_source_map() {
1115        let range = |start, end| TextRange::new(TextSize::new(start), TextSize::new(end));
1116        let mapped = |docs: &Docs, start, end| {
1117            docs.find_ast_range(range(start, end)).map(|(range, _)| range.value)
1118        };
1119
1120        // No escapes: `bar` maps back to the source.
1121        let docs = doc_attr_docs(r#"#[doc = "foo bar"] fn f() {}"#);
1122        assert_eq!(docs.docs, "foo bar");
1123        assert_eq!(mapped(&docs, 4, 7), Some(range(13, 16)));
1124
1125        // Raw strings have no escapes either.
1126        let docs = doc_attr_docs(r##"#[doc = r"a\b"] fn f() {}"##);
1127        assert_eq!(docs.docs, r"a\b");
1128        assert_eq!(mapped(&docs, 2, 3), Some(range(12, 13)));
1129
1130        // With an escape, `b` is at offset 2 in the docs but at offset 3 in the source,
1131        // so the docs can't be mapped back.
1132        let docs = doc_attr_docs(r#"#[doc = "a\"b"] fn f() {}"#);
1133        assert_eq!(docs.docs, "a\"b");
1134        assert_eq!(mapped(&docs, 2, 3), None);
1135    }
1136}