| //! Documentation extraction and source mapping. |
| //! |
| //! This module handles the extraction and processing of doc comments and `#[doc = "..."]` |
| //! attributes, including macro expansion for `#[doc = macro!()]` patterns. |
| //! It builds a concatenated string of the full docs as well as a source map |
| //! to map it back to AST (which is needed for things like resolving links in doc comments |
| //! and highlight injection). |
| |
| use std::{ |
| convert::Infallible, |
| ops::{ControlFlow, Range}, |
| }; |
| |
| use base_db::{Crate, SourceDatabase}; |
| use cfg::CfgOptions; |
| use either::Either; |
| use hir_expand::{ |
| AstId, ExpandTo, HirFileId, InFile, MacroCallId, |
| attrs::{AstPathExt, expand_cfg_attr_with_doc_comments}, |
| mod_path::ModPath, |
| span_map::SpanMap, |
| }; |
| use span::AstIdMap; |
| use syntax::{ |
| AstNode, AstToken, SyntaxNode, |
| ast::{self, AttrDocCommentIter, IsString}, |
| }; |
| use thin_vec::ThinVec; |
| use tt::{TextRange, TextSize}; |
| |
| use crate::{macro_call_as_call_id, nameres::MacroSubNs, resolver::Resolver}; |
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| struct DocsSourceMapLine { |
| /// The offset in [`Docs::docs`]. |
| string_offset: TextSize, |
| /// The offset in the AST of the text. `None` for macro-expanded doc strings |
| /// where we cannot provide a faithful source mapping. |
| ast_offset: Option<TextSize>, |
| } |
| |
| #[derive(Debug, Clone, PartialEq, Eq, Hash)] |
| pub struct Docs { |
| /// The concatenated string of all `#[doc = "..."]` attributes and documentation comments. |
| docs: String, |
| /// A sorted map from an offset in `docs` to an offset in the source code. |
| docs_source_map: Vec<DocsSourceMapLine>, |
| /// If the item is an outlined module (`mod foo;`), `docs_source_map` stores the concatenated |
| /// list of the outline and inline docs (outline first). Then, this field contains the [`HirFileId`] |
| /// of the outline declaration, and the index in `docs` from which the inline docs |
| /// begin. |
| outline_mod: Option<(HirFileId, usize)>, |
| inline_file: HirFileId, |
| /// The size of the prepended prefix, which does not map to real doc comments. |
| prefix_len: TextSize, |
| /// The offset in `docs` from which the docs are inner attributes/comments. |
| inline_inner_docs_start: Option<TextSize>, |
| /// Like `inline_inner_docs_start`, but for `outline_mod`. This can happen only when merging `Docs` |
| /// (as outline modules don't have inner attributes). |
| outline_inner_docs_start: Option<TextSize>, |
| /// All macro calls in `#[doc = ...]` attributes, recursively. |
| macro_calls: ThinVec<(AstId<ast::MacroCall>, MacroCallId)>, |
| } |
| |
| #[derive(Clone, Copy)] |
| enum DocCommentKind { |
| /// `///` etc.. |
| Sugared(ast::CommentShape), |
| /// `#[doc = ""]`. |
| Desugared, |
| } |
| |
| #[derive(Default)] |
| struct Indent { |
| lines: Vec<Option<(usize, DocCommentKind)>>, |
| seen_sugared: bool, |
| } |
| |
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| pub enum IsInnerDoc { |
| No, |
| Yes, |
| } |
| |
| impl IsInnerDoc { |
| #[inline] |
| pub fn yes(self) -> bool { |
| self == IsInnerDoc::Yes |
| } |
| } |
| |
| impl Docs { |
| #[inline] |
| pub fn docs(&self) -> &str { |
| &self.docs |
| } |
| |
| #[inline] |
| pub fn into_docs(self) -> String { |
| self.docs |
| } |
| |
| #[inline] |
| pub fn macro_calls(&self) -> impl Iterator<Item = (AstId<ast::MacroCall>, MacroCallId)> { |
| self.macro_calls.iter().copied() |
| } |
| |
| fn is_empty(&self) -> bool { |
| let Self { |
| docs, |
| docs_source_map: _, |
| outline_mod: _, |
| inline_file: _, |
| prefix_len: _, |
| inline_inner_docs_start: _, |
| outline_inner_docs_start: _, |
| macro_calls, |
| } = self; |
| docs.is_empty() && macro_calls.is_empty() |
| } |
| |
| pub fn find_ast_range( |
| &self, |
| mut string_range: TextRange, |
| ) -> Option<(InFile<TextRange>, IsInnerDoc)> { |
| if string_range.start() < self.prefix_len { |
| return None; |
| } |
| string_range -= self.prefix_len; |
| |
| let mut file = self.inline_file; |
| let mut inner_docs_start = self.inline_inner_docs_start; |
| // Check whether the range is from the outline, the inline, or both. |
| let source_map = if let Some((outline_mod_file, outline_mod_end)) = self.outline_mod { |
| if let Some(first_inline) = self.docs_source_map.get(outline_mod_end) { |
| if string_range.end() <= first_inline.string_offset { |
| // The range is completely in the outline. |
| file = outline_mod_file; |
| inner_docs_start = self.outline_inner_docs_start; |
| &self.docs_source_map[..outline_mod_end] |
| } else if string_range.start() >= first_inline.string_offset { |
| // The range is completely in the inline. |
| &self.docs_source_map[outline_mod_end..] |
| } else { |
| // The range is combined from the outline and the inline - cannot map it back. |
| return None; |
| } |
| } else { |
| // There is no inline. |
| file = outline_mod_file; |
| inner_docs_start = self.outline_inner_docs_start; |
| &self.docs_source_map |
| } |
| } else { |
| // There is no outline. |
| &self.docs_source_map |
| }; |
| |
| let after_range = |
| source_map.partition_point(|line| line.string_offset <= string_range.start()) - 1; |
| let after_range = &source_map[after_range..]; |
| let line = after_range.first()?; |
| // Unmapped lines (from macro-expanded docs) cannot be mapped back to AST. |
| let ast_offset = line.ast_offset?; |
| if after_range.get(1).is_some_and(|next_line| next_line.string_offset < string_range.end()) |
| { |
| // The range is combined from two lines - cannot map it back. |
| return None; |
| } |
| let ast_range = string_range - line.string_offset + ast_offset; |
| let is_inner = if inner_docs_start |
| .is_some_and(|inner_docs_start| string_range.start() >= inner_docs_start) |
| { |
| IsInnerDoc::Yes |
| } else { |
| IsInnerDoc::No |
| }; |
| Some((InFile::new(file, ast_range), is_inner)) |
| } |
| |
| #[inline] |
| pub fn shift_by(&mut self, offset: TextSize) { |
| self.prefix_len += offset; |
| } |
| |
| pub fn prepend_str(&mut self, s: &str) { |
| self.prefix_len += TextSize::of(s); |
| self.docs.insert_str(0, s); |
| } |
| |
| pub fn append_str(&mut self, s: &str) { |
| self.docs.push_str(s); |
| } |
| |
| pub fn append(&mut self, other: &Docs) { |
| let other_offset = TextSize::of(&self.docs); |
| |
| assert!( |
| self.outline_mod.is_none() && other.outline_mod.is_none(), |
| "cannot merge `Docs` that have `outline_mod` set" |
| ); |
| self.outline_mod = Some((self.inline_file, self.docs_source_map.len())); |
| self.inline_file = other.inline_file; |
| self.outline_inner_docs_start = self.inline_inner_docs_start; |
| self.inline_inner_docs_start = other.inline_inner_docs_start.map(|it| it + other_offset); |
| |
| self.docs.push_str(&other.docs); |
| self.docs_source_map.extend(other.docs_source_map.iter().map( |
| |&DocsSourceMapLine { string_offset, ast_offset }| DocsSourceMapLine { |
| ast_offset, |
| string_offset: string_offset + other_offset, |
| }, |
| )); |
| } |
| |
| fn extend_with_doc_comment(&mut self, comment: ast::Comment, indent: &mut Indent) { |
| let Some((doc, offset)) = comment.doc_comment() else { return }; |
| let offset = comment.syntax().text_range().start() + offset; |
| self.extend_with_doc_str( |
| doc, |
| offset, |
| DocCommentKind::Sugared(comment.kind().shape), |
| indent, |
| ); |
| } |
| |
| fn extend_with_doc_attr(&mut self, value: ast::String, indent: &mut Indent) { |
| let Some(value_offset) = value.text_range_between_quotes() else { return }; |
| let value_offset = value_offset.start(); |
| let Ok(value) = value.value() else { return }; |
| // FIXME: Handle source maps for escaped text. |
| self.extend_with_doc_str(&value, value_offset, DocCommentKind::Desugared, indent); |
| } |
| |
| fn extend_with_doc_str( |
| &mut self, |
| doc: &str, |
| offset_in_ast: TextSize, |
| comment_kind: DocCommentKind, |
| indent: &mut Indent, |
| ) { |
| self.push_doc_lines(doc, Some(offset_in_ast), comment_kind, indent); |
| } |
| |
| fn extend_with_unmapped_doc_str(&mut self, doc: &str, indent: &mut Indent) { |
| self.push_doc_lines(doc, None, DocCommentKind::Desugared, indent); |
| } |
| |
| /// Beautifies `doc` and appends the result to `self.docs`, one line at a time via |
| /// [`Docs::push_doc_line`]. Mirrors rustc's [`beautify_doc_string`], delegating to |
| /// [`get_vertical_trim`] and [`get_horizontal_trim`] for the multi-line case. |
| /// |
| /// Individual `///` line comments always reach us as a single-line `doc`, so the |
| /// `!doc.contains('\n')` fast path fires and the multi-line logic never runs on them. |
| /// Desugared `#[doc = "..."]` strings and macro-expanded docs also route through here |
| /// with `shape = CommentShape::Line`, matching rustc. |
| /// |
| /// Unlike rustc's version, which joins the beautified lines into a new interned `Symbol`, |
| /// this port pushes each line individually via [`Docs::push_doc_line`] and pairs it with |
| /// its byte offset relative to `doc`'s start so the source-map records accurate per-line |
| /// offsets. |
| /// |
| /// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L37 |
| fn push_doc_lines( |
| &mut self, |
| doc: &str, |
| ast_offset: Option<TextSize>, |
| comment_kind: DocCommentKind, |
| indent: &mut Indent, |
| ) { |
| // Note: this is pushed even if there are only empty lines here, because that's what rustdoc does. |
| let shape = match comment_kind { |
| DocCommentKind::Sugared(shape) => { |
| indent.seen_sugared = true; |
| shape |
| } |
| // rustc uses `Line` for desugared comments. |
| DocCommentKind::Desugared => ast::CommentShape::Line, |
| }; |
| |
| if !doc.contains('\n') { |
| self.push_doc_line(doc, ast_offset, comment_kind, indent); |
| return; |
| } |
| |
| let mut lines: Vec<(&str, TextSize)> = doc |
| .lines() |
| .map(|line| { |
| let offset = TextSize::new(doc.substr_range(line).unwrap().start as u32); |
| (line, offset) |
| }) |
| .collect(); |
| |
| let raw_lines: Vec<&str> = lines.iter().map(|(l, _)| *l).collect(); |
| let lines = match get_vertical_trim(&raw_lines) { |
| Some((i, j)) => &mut lines[i..j], |
| None => &mut lines[..], |
| }; |
| |
| let raw_lines: Vec<&str> = lines.iter().map(|(l, _)| *l).collect(); |
| if let Some(horizontal) = get_horizontal_trim(&raw_lines, shape) { |
| let horizontal_len = TextSize::of(horizontal.as_str()); |
| // Strip `"[ \t]*\*"` from each line where present, exactly like rustc. |
| for (line, line_offset) in lines.iter_mut() { |
| if let Some(rest) = line.strip_prefix(horizontal.as_str()) { |
| *line = rest; |
| *line_offset += horizontal_len; |
| if shape == ast::CommentShape::Block |
| && (*line == "*" || line.starts_with("* ") || line.starts_with("**")) |
| { |
| *line = &line[1..]; |
| *line_offset += TextSize::of("*"); |
| } |
| } |
| } |
| } |
| |
| for (line, line_offset) in lines.iter().copied() { |
| self.push_doc_line(line, ast_offset.map(|it| it + line_offset), comment_kind, indent); |
| } |
| } |
| |
| /// Appends a single beautified line to `self.docs` and records its source-map row. |
| fn push_doc_line( |
| &mut self, |
| line: &str, |
| ast_offset: Option<TextSize>, |
| comment_kind: DocCommentKind, |
| indent: &mut Indent, |
| ) { |
| self.docs_source_map |
| .push(DocsSourceMapLine { string_offset: TextSize::of(&self.docs), ast_offset }); |
| |
| let line = line.trim_end(); |
| let line_indent = if line.chars().any(|ch| !ch.is_whitespace()) { |
| // Empty lines are handled because `any()` returns `false` for them. |
| let line_indent = line.bytes().take_while(|c| *c == b' ' || *c == b'\t').count(); |
| Some((line_indent, comment_kind)) |
| } else { |
| None |
| }; |
| indent.lines.push(line_indent); |
| self.docs.push_str(line); |
| self.docs.push('\n'); |
| } |
| |
| fn remove_indent(&mut self, indent: &Indent) { |
| /// In case of panics, we want to avoid corrupted UTF-8 in `self.docs`, so we clear it. |
| struct Guard<'a>(&'a mut Docs); |
| impl Drop for Guard<'_> { |
| fn drop(&mut self) { |
| let Docs { |
| docs, |
| docs_source_map, |
| outline_mod, |
| inline_file: _, |
| prefix_len: _, |
| inline_inner_docs_start: _, |
| outline_inner_docs_start: _, |
| macro_calls: _, |
| } = self.0; |
| // Don't use `String::clear()` here because it's not guaranteed to not do UTF-8-dependent things, |
| // and we may have temporarily broken the string's encoding. |
| unsafe { docs.as_mut_vec() }.clear(); |
| // This is just to avoid panics down the road. |
| docs_source_map.clear(); |
| *outline_mod = None; |
| } |
| } |
| |
| if self.docs.is_empty() { |
| return; |
| } |
| |
| // `add` is used in case the most common sugared doc syntax is used ("/// "). The other |
| // fragments kind's lines are never starting with a whitespace unless they are using some |
| // markdown formatting requiring it. Therefore, if the doc block have a mix between the two, |
| // we need to take into account the fact that the minimum indent minus one (to take this |
| // whitespace into account). |
| // |
| // For example: |
| // |
| // /// hello! |
| // #[doc = "another"] |
| // |
| // In this case, you want "hello! another" and not "hello! another". |
| let add_indent = if indent.seen_sugared { 1 } else { 0 }; |
| |
| let Some(min_indent) = indent |
| .lines |
| .iter() |
| .filter_map(|it| *it) |
| .map(|(line_indent, line_kind)| { |
| line_indent |
| + match line_kind { |
| DocCommentKind::Sugared(_) => 0, |
| DocCommentKind::Desugared => add_indent, |
| } |
| }) |
| .min() |
| else { |
| return; |
| }; |
| |
| let guard = Guard(self); |
| let source_map = guard.0.docs_source_map.as_mut_slice(); |
| let Some(&DocsSourceMapLine { string_offset: mut copy_into, .. }) = source_map.first() |
| else { |
| return; |
| }; |
| // We basically want to remove multiple ranges from a string. Doing this efficiently (without O(N^2) |
| // or allocations) requires unsafe. Basically, for each line, we copy the line minus the indent into |
| // consecutive to the previous line (which may have moved). Then at the end we truncate. |
| let mut accumulated_offset = TextSize::new(0); |
| for idx in 0..source_map.len() { |
| let string_end_offset = source_map |
| .get(idx + 1) |
| .map_or_else(|| TextSize::of(&guard.0.docs), |next_attr| next_attr.string_offset); |
| let line_source = &mut source_map[idx]; |
| let line_docs = |
| &guard.0.docs[TextRange::new(line_source.string_offset, string_end_offset)]; |
| let line_docs_len = TextSize::of(line_docs); |
| let indent_size = if let Some((_, DocCommentKind::Desugared)) = indent.lines[idx] |
| && min_indent > 0 |
| { |
| min_indent - add_indent |
| } else { |
| min_indent |
| }; |
| let indent_size = line_docs.char_indices().nth(indent_size).map_or_else( |
| || TextSize::of(line_docs) - TextSize::of("\n"), |
| |(offset, _)| TextSize::new(offset as u32), |
| ); |
| unsafe { guard.0.docs.as_bytes_mut() }.copy_within( |
| Range::<usize>::from(TextRange::new( |
| line_source.string_offset + indent_size, |
| string_end_offset, |
| )), |
| copy_into.into(), |
| ); |
| copy_into += line_docs_len - indent_size; |
| |
| if let Some(inner_attrs_start) = &mut guard.0.inline_inner_docs_start |
| && *inner_attrs_start == line_source.string_offset |
| { |
| *inner_attrs_start -= accumulated_offset; |
| } |
| // The removals in the string accumulate, but in the AST not, because it already points |
| // to the beginning of each attribute. |
| // Also, we need to shift the AST offset of every line, but the string offset of the first |
| // line should not get shifted (in general, the shift for the string offset is by the |
| // number of lines until the current one, excluding the current one). |
| line_source.string_offset -= accumulated_offset; |
| if let Some(ref mut ast_offset) = line_source.ast_offset { |
| *ast_offset += indent_size; |
| } |
| |
| accumulated_offset += indent_size; |
| } |
| // Don't use `String::truncate()` here because it's not guaranteed to not do UTF-8-dependent things, |
| // and we may have temporarily broken the string's encoding. |
| unsafe { guard.0.docs.as_mut_vec() }.truncate(copy_into.into()); |
| |
| std::mem::forget(guard); |
| } |
| |
| fn remove_last_newline(&mut self) { |
| self.docs.truncate(self.docs.len().saturating_sub(1)); |
| } |
| |
| fn shrink_to_fit(&mut self) { |
| let Docs { |
| docs, |
| docs_source_map, |
| outline_mod: _, |
| inline_file: _, |
| prefix_len: _, |
| inline_inner_docs_start: _, |
| outline_inner_docs_start: _, |
| macro_calls, |
| } = self; |
| docs.shrink_to_fit(); |
| docs_source_map.shrink_to_fit(); |
| macro_calls.shrink_to_fit(); |
| } |
| } |
| |
| /// Copied verbatim from rustc's [`beautify_doc_string`], modulo `CommentKind`/`CommentShape` |
| /// renaming. |
| /// |
| /// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L38 |
| fn get_vertical_trim(lines: &[&str]) -> Option<(usize, usize)> { |
| let mut i = 0; |
| let mut j = lines.len(); |
| // first line of all-stars should be omitted |
| if lines.first().is_some_and(|line| line.chars().all(|c| c == '*')) { |
| i += 1; |
| } |
| |
| // like the first, a last line of all stars should be omitted |
| if j > i && !lines[j - 1].is_empty() && lines[j - 1].chars().all(|c| c == '*') { |
| j -= 1; |
| } |
| |
| if i != 0 || j != lines.len() { Some((i, j)) } else { None } |
| } |
| |
| /// Copied verbatim from rustc's [`beautify_doc_string`], modulo `CommentKind`/`CommentShape` |
| /// renaming and returning `String` rather than interning to `Symbol`. |
| /// |
| /// [`beautify_doc_string`]: https://github.com/rust-lang/rust/blob/16a623ad672a92409b5c04beb303583c6cf72a7e/compiler/rustc_ast/src/util/comments.rs#L54 |
| fn get_horizontal_trim(lines: &[&str], kind: ast::CommentShape) -> Option<String> { |
| let mut i = usize::MAX; |
| let mut first = true; |
| |
| // In case we have doc comments like `/**` or `/*!`, we want to remove stars if they are |
| // present. However, we first need to strip the empty lines so they don't get in the middle |
| // when we try to compute the "horizontal trim". |
| let lines = match kind { |
| ast::CommentShape::Block => { |
| // Whatever happens, we skip the first line. |
| let mut i = lines |
| .first() |
| .map(|l| if l.trim_start().starts_with('*') { 0 } else { 1 }) |
| .unwrap_or(0); |
| let mut j = lines.len(); |
| |
| while i < j && lines[i].trim().is_empty() { |
| i += 1; |
| } |
| while j > i && lines[j - 1].trim().is_empty() { |
| j -= 1; |
| } |
| &lines[i..j] |
| } |
| ast::CommentShape::Line => lines, |
| }; |
| |
| for line in lines { |
| for (j, c) in line.chars().enumerate() { |
| if j > i || !"* \t".contains(c) { |
| return None; |
| } |
| if c == '*' { |
| if first { |
| i = j; |
| first = false; |
| } else if i != j { |
| return None; |
| } |
| break; |
| } |
| } |
| if i >= line.len() { |
| return None; |
| } |
| } |
| Some(lines.first()?[..i].to_string()) |
| } |
| |
| struct DocMacroExpander<'db> { |
| db: &'db dyn SourceDatabase, |
| krate: Crate, |
| macro_depth: u32, |
| recursion_limit: u32, |
| resolver: Resolver<'db>, |
| file_id: HirFileId, |
| ast_id_map: &'db AstIdMap, |
| span_map: SpanMap<'db>, |
| } |
| |
| fn expand_doc_expr_via_macro_pipeline<'db>( |
| expander: &mut DocMacroExpander<'db>, |
| macro_calls: &mut ThinVec<(AstId<ast::MacroCall>, MacroCallId)>, |
| expr: ast::Expr, |
| ) -> Option<String> { |
| match expr { |
| ast::Expr::ParenExpr(paren_expr) => { |
| expand_doc_expr_via_macro_pipeline(expander, macro_calls, paren_expr.expr()?) |
| } |
| ast::Expr::Literal(literal) => match literal.kind() { |
| ast::LiteralKind::String(string) => string.value().ok().map(Into::into), |
| _ => None, |
| }, |
| ast::Expr::MacroExpr(macro_expr) => { |
| let macro_call = macro_expr.macro_call()?; |
| expand_doc_macro_call(expander, macro_calls, macro_call) |
| } |
| _ => None, |
| } |
| } |
| |
| fn expand_doc_macro_call<'db>( |
| expander: &mut DocMacroExpander<'db>, |
| macro_calls: &mut ThinVec<(AstId<ast::MacroCall>, MacroCallId)>, |
| macro_call: ast::MacroCall, |
| ) -> Option<String> { |
| if expander.macro_depth >= expander.recursion_limit { |
| return None; |
| } |
| |
| let path = macro_call.path()?; |
| let mod_path = ModPath::from_src(expander.db, path, &mut |range| { |
| expander.span_map.span_for_range(range).ctx |
| })?; |
| let call_site = expander.span_map.span_for_range(macro_call.syntax().text_range()); |
| let ast_id = AstId::new(expander.file_id, expander.ast_id_map.ast_id(¯o_call)); |
| let call_id = macro_call_as_call_id( |
| expander.db, |
| ast_id, |
| &mod_path, |
| call_site.ctx, |
| ExpandTo::Expr, |
| expander.krate, |
| expander.macro_depth + 1, |
| |path| { |
| expander.resolver.resolve_path_as_macro_def(expander.db, path, Some(MacroSubNs::Bang)) |
| }, |
| &mut |_, _| (), |
| ) |
| .ok()? |
| .value?; |
| macro_calls.push((ast_id, call_id)); |
| |
| let (parse, span_map) = &call_id.parse_macro_expansion(expander.db).value; |
| let expr = parse.clone().cast::<ast::Expr>().map(|parse| parse.tree())?; |
| |
| // Build a new source context for the expansion file so that any further |
| // recursive expansion (e.g. a user macro expanding to `concat!(...)`) |
| // correctly resolves AstIds and spans in the expansion. |
| let expansion_file_id: HirFileId = call_id.into(); |
| let old_file_id = std::mem::replace(&mut expander.file_id, expansion_file_id); |
| let old_span_map = |
| std::mem::replace(&mut expander.span_map, SpanMap::ExpansionSpanMap(span_map)); |
| let old_ast_id_map = |
| std::mem::replace(&mut expander.ast_id_map, expansion_file_id.ast_id_map(expander.db)); |
| expander.macro_depth += 1; |
| |
| let expansion = expand_doc_expr_via_macro_pipeline(expander, macro_calls, expr); |
| |
| expander.file_id = old_file_id; |
| expander.span_map = old_span_map; |
| expander.ast_id_map = old_ast_id_map; |
| expander.macro_depth -= 1; |
| |
| expansion |
| } |
| |
| fn extend_with_attrs<'a, 'db>( |
| result: &mut Docs, |
| db: &'db dyn SourceDatabase, |
| krate: Crate, |
| node: &SyntaxNode, |
| file_id: HirFileId, |
| expect_inner_attrs: bool, |
| indent: &mut Indent, |
| get_cfg_options: &dyn Fn() -> &'a CfgOptions, |
| cfg_options: &mut Option<&'a CfgOptions>, |
| make_resolver: &dyn Fn() -> Resolver<'db>, |
| ) { |
| // Lazily initialised when we first encounter a `#[doc = macro!()]`. |
| let mut expander = None; |
| |
| expand_cfg_attr_with_doc_comments::<_, Infallible>( |
| AttrDocCommentIter::from_syntax_node(node).filter(|attr| match attr { |
| Either::Left(attr) => attr.kind().is_inner() == expect_inner_attrs, |
| Either::Right(comment) => comment |
| .kind() |
| .doc |
| .is_some_and(|kind| (kind == ast::CommentPlacement::Inner) == expect_inner_attrs), |
| }), |
| || *cfg_options.get_or_insert_with(get_cfg_options), |
| |attr| { |
| match attr { |
| Either::Right(doc_comment) => result.extend_with_doc_comment(doc_comment, indent), |
| Either::Left((attr, _)) => match attr { |
| ast::Meta::KeyValueMeta(attr) if attr.path().is1("doc") => { |
| if let Some(value) = attr.expr() { |
| if let ast::Expr::Literal(value) = &value |
| && let ast::LiteralKind::String(value) = value.kind() |
| { |
| result.extend_with_doc_attr(value, indent); |
| } else { |
| let exp = expander.get_or_insert_with(|| { |
| let resolver = make_resolver(); |
| let def_map = resolver.top_level_def_map(); |
| let recursion_limit = def_map.recursion_limit(); |
| DocMacroExpander { |
| db, |
| krate, |
| macro_depth: file_id.macro_expansion_depth(db), |
| recursion_limit, |
| resolver, |
| file_id, |
| ast_id_map: file_id.ast_id_map(db), |
| span_map: file_id.span_map(db), |
| } |
| }); |
| if let Some(expanded) = expand_doc_expr_via_macro_pipeline( |
| exp, |
| &mut result.macro_calls, |
| value, |
| ) { |
| result.extend_with_unmapped_doc_str(&expanded, indent); |
| } |
| } |
| } |
| } |
| _ => {} |
| }, |
| } |
| ControlFlow::Continue(()) |
| }, |
| ); |
| } |
| |
| pub(crate) fn extract_docs<'a, 'db>( |
| db: &'db dyn SourceDatabase, |
| krate: Crate, |
| resolver: &dyn Fn() -> Resolver<'db>, |
| get_cfg_options: &dyn Fn() -> &'a CfgOptions, |
| source: InFile<ast::AnyHasAttrs>, |
| outer_mod_decl: Option<InFile<ast::Module>>, |
| inner_attrs_node: Option<SyntaxNode>, |
| ) -> Option<Box<Docs>> { |
| let mut result = Docs { |
| docs: String::new(), |
| docs_source_map: Vec::new(), |
| outline_mod: None, |
| inline_file: source.file_id, |
| prefix_len: TextSize::new(0), |
| inline_inner_docs_start: None, |
| outline_inner_docs_start: None, |
| macro_calls: ThinVec::new(), |
| }; |
| |
| let mut cfg_options = None; |
| |
| let mut indent = Indent::default(); |
| if let Some(outer_mod_decl) = outer_mod_decl { |
| // For outer docs (the `mod foo;` declaration), use the module's own resolver. |
| extend_with_attrs( |
| &mut result, |
| db, |
| krate, |
| outer_mod_decl.value.syntax(), |
| outer_mod_decl.file_id, |
| false, |
| &mut indent, |
| get_cfg_options, |
| &mut cfg_options, |
| resolver, |
| ); |
| result.outline_mod = Some((outer_mod_decl.file_id, result.docs_source_map.len())); |
| } |
| |
| // For inline docs, use the item's own resolver. |
| extend_with_attrs( |
| &mut result, |
| db, |
| krate, |
| source.value.syntax(), |
| source.file_id, |
| false, |
| &mut indent, |
| get_cfg_options, |
| &mut cfg_options, |
| resolver, |
| ); |
| if let Some(inner_attrs_node) = &inner_attrs_node { |
| result.inline_inner_docs_start = Some(TextSize::of(&result.docs)); |
| extend_with_attrs( |
| &mut result, |
| db, |
| krate, |
| inner_attrs_node, |
| source.file_id, |
| true, |
| &mut indent, |
| get_cfg_options, |
| &mut cfg_options, |
| resolver, |
| ); |
| } |
| result.remove_indent(&indent); |
| |
| result.remove_last_newline(); |
| |
| result.shrink_to_fit(); |
| |
| if result.is_empty() { None } else { Some(Box::new(result)) } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use expect_test::expect; |
| use hir_expand::InFile; |
| use syntax::{AstToken, ast}; |
| use test_fixture::WithFixture; |
| use thin_vec::ThinVec; |
| use tt::{TextRange, TextSize}; |
| |
| use crate::test_db::TestDB; |
| |
| use super::{DocCommentKind, Docs, Indent, IsInnerDoc}; |
| |
| #[test] |
| fn docs() { |
| let (_db, file_id) = TestDB::with_single_file(""); |
| let mut docs = Docs { |
| docs: String::new(), |
| docs_source_map: Vec::new(), |
| outline_mod: None, |
| inline_file: file_id.into(), |
| prefix_len: TextSize::new(0), |
| inline_inner_docs_start: None, |
| outline_inner_docs_start: None, |
| macro_calls: ThinVec::new(), |
| }; |
| let mut indent = Indent::default(); |
| |
| let outer = " foo\n\tbar baz"; |
| let mut ast_offset = TextSize::new(123); |
| for line in outer.split('\n') { |
| docs.extend_with_doc_str( |
| line, |
| ast_offset, |
| DocCommentKind::Sugared(ast::CommentShape::Line), |
| &mut indent, |
| ); |
| ast_offset += TextSize::of(line) + TextSize::of("\n"); |
| } |
| |
| docs.inline_inner_docs_start = Some(TextSize::of(&docs.docs)); |
| ast_offset += TextSize::new(123); |
| let inner = " bar \n baz"; |
| for line in inner.split('\n') { |
| docs.extend_with_doc_str( |
| line, |
| ast_offset, |
| DocCommentKind::Sugared(ast::CommentShape::Line), |
| &mut indent, |
| ); |
| ast_offset += TextSize::of(line) + TextSize::of("\n"); |
| } |
| |
| expect![[r#" |
| [ |
| DocsSourceMapLine { |
| string_offset: 0, |
| ast_offset: Some( |
| 123, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 5, |
| ast_offset: Some( |
| 128, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 15, |
| ast_offset: Some( |
| 261, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 20, |
| ast_offset: Some( |
| 267, |
| ), |
| }, |
| ] |
| "#]] |
| .assert_debug_eq(&docs.docs_source_map); |
| |
| docs.remove_indent(&indent); |
| |
| assert_eq!(docs.inline_inner_docs_start, Some(TextSize::new(13))); |
| |
| assert_eq!(docs.docs, "foo\nbar baz\nbar\nbaz\n"); |
| expect![[r#" |
| [ |
| DocsSourceMapLine { |
| string_offset: 0, |
| ast_offset: Some( |
| 124, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 4, |
| ast_offset: Some( |
| 129, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 13, |
| ast_offset: Some( |
| 262, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 17, |
| ast_offset: Some( |
| 268, |
| ), |
| }, |
| ] |
| "#]] |
| .assert_debug_eq(&docs.docs_source_map); |
| |
| docs.append(&docs.clone()); |
| docs.prepend_str("prefix---"); |
| assert_eq!(docs.docs, "prefix---foo\nbar baz\nbar\nbaz\nfoo\nbar baz\nbar\nbaz\n"); |
| expect![[r#" |
| [ |
| DocsSourceMapLine { |
| string_offset: 0, |
| ast_offset: Some( |
| 124, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 4, |
| ast_offset: Some( |
| 129, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 13, |
| ast_offset: Some( |
| 262, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 17, |
| ast_offset: Some( |
| 268, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 21, |
| ast_offset: Some( |
| 124, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 25, |
| ast_offset: Some( |
| 129, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 34, |
| ast_offset: Some( |
| 262, |
| ), |
| }, |
| DocsSourceMapLine { |
| string_offset: 38, |
| ast_offset: Some( |
| 268, |
| ), |
| }, |
| ] |
| "#]] |
| .assert_debug_eq(&docs.docs_source_map); |
| |
| let range = |start, end| TextRange::new(TextSize::new(start), TextSize::new(end)); |
| let in_file = |range| InFile::new(file_id.into(), range); |
| assert_eq!(docs.find_ast_range(range(0, 2)), None); |
| assert_eq!(docs.find_ast_range(range(8, 10)), None); |
| assert_eq!( |
| docs.find_ast_range(range(9, 10)), |
| Some((in_file(range(124, 125)), IsInnerDoc::No)) |
| ); |
| assert_eq!(docs.find_ast_range(range(20, 23)), None); |
| assert_eq!( |
| docs.find_ast_range(range(23, 25)), |
| Some((in_file(range(263, 265)), IsInnerDoc::Yes)) |
| ); |
| } |
| |
| #[test] |
| fn sugared_desugared_mix() { |
| let (_db, file_id) = TestDB::with_single_file(""); |
| let mut docs = Docs { |
| docs: String::new(), |
| docs_source_map: Vec::new(), |
| outline_mod: None, |
| inline_file: file_id.into(), |
| prefix_len: TextSize::new(0), |
| inline_inner_docs_start: None, |
| outline_inner_docs_start: None, |
| macro_calls: ThinVec::new(), |
| }; |
| let mut indent = Indent::default(); |
| |
| docs.push_doc_lines( |
| " hello!", |
| None, |
| DocCommentKind::Sugared(ast::CommentShape::Line), |
| &mut indent, |
| ); |
| docs.push_doc_lines("another", None, DocCommentKind::Desugared, &mut indent); |
| docs.remove_indent(&indent); |
| docs.remove_last_newline(); |
| |
| assert_eq!(docs.docs(), "hello!\nanother"); |
| } |
| |
| /// Extracts the docs of the first comment in `source`, running the same normalization as |
| /// [`super::extract_docs`] does for inline docs. |
| fn comment_docs(source: &str) -> Docs { |
| let (_db, file_id) = TestDB::with_single_file(""); |
| let comment = syntax::SourceFile::parse(source, span::Edition::CURRENT) |
| .syntax_node() |
| .descendants_with_tokens() |
| .filter_map(|it| it.into_token()) |
| .find_map(ast::Comment::cast) |
| .expect("no comment in the fixture"); |
| let mut docs = Docs { |
| docs: String::new(), |
| docs_source_map: Vec::new(), |
| outline_mod: None, |
| inline_file: file_id.into(), |
| prefix_len: TextSize::new(0), |
| inline_inner_docs_start: None, |
| outline_inner_docs_start: None, |
| macro_calls: ThinVec::new(), |
| }; |
| let mut indent = Indent::default(); |
| docs.extend_with_doc_comment(comment, &mut indent); |
| docs.remove_indent(&indent); |
| docs.remove_last_newline(); |
| docs |
| } |
| |
| #[test] |
| fn block_doc_comment_stars() { |
| #[track_caller] |
| fn check(source: &str, expect: expect_test::Expect) { |
| expect.assert_eq(&comment_docs(source).docs); |
| } |
| |
| // The decoration is stripped, but markdown bullets and `*foo` are content. |
| // `*bar` doesn't start with `* ` / `**`, so rustc's beautifier only strips the |
| // horizontal `[ \t]*` prefix (here a single space) and leaves the leading `*` in |
| // place. That in turn pins the block's minimum indent at 0, so surrounding lines |
| // aren't re-indented. |
| check( |
| "/**\n * foo\n *\n * * bullet\n *bar\n */", |
| expect![[r#" |
| foo |
| |
| * bullet |
| *bar |
| "#]], |
| ); |
| // Single-line block doc comments are left alone, like rustdoc does. |
| check("/** * item */", expect!["* item"]); |
| // So are blocks without a consistent star column. |
| check( |
| "/**\n * foo\n * bar\n */", |
| expect![[r#" |
| * foo |
| * bar |
| "#]], |
| ); |
| } |
| |
| #[test] |
| fn block_doc_comment_source_map() { |
| let docs = comment_docs("/**\n * foo\n * bar\n */"); |
| // `.lines()` (matching rustc) doesn't emit a leading empty entry for the newline |
| // right after `/**`, so the docs body starts at `foo`, not with a blank line. |
| assert_eq!(docs.docs, "foo\nbar\n"); |
| |
| let range = |start, end| TextRange::new(TextSize::new(start), TextSize::new(end)); |
| let in_file = |range| InFile::new(docs.inline_file, range); |
| let mapped = |start, end| docs.find_ast_range(range(start, end)); |
| // Both `foo` and `bar` map back past the stripped ` * ` decoration. |
| assert_eq!(mapped(0, 3), Some((in_file(range(7, 10)), IsInnerDoc::No))); |
| assert_eq!(mapped(4, 7), Some((in_file(range(14, 17)), IsInnerDoc::No))); |
| } |
| } |