Unnamed repository; edit this file 'description' to name the repository.
| -rw-r--r-- | crates/hir-def/src/attrs.rs | 772 | ||||
| -rw-r--r-- | crates/hir-def/src/attrs/docs.rs | 767 | ||||
| -rw-r--r-- | crates/ide/src/hover/tests.rs | 76 |
3 files changed, 880 insertions, 735 deletions
diff --git a/crates/hir-def/src/attrs.rs b/crates/hir-def/src/attrs.rs index a5bc283330..ddcfea2c21 100644 --- a/crates/hir-def/src/attrs.rs +++ b/crates/hir-def/src/attrs.rs @@ -12,27 +12,17 @@ //! its value. This way, queries are only called on items that have the attribute, which is //! usually only a few. //! -//! An exception to this model that is also defined in this module is documentation (doc -//! comments and `#[doc = "..."]` attributes). But it also has a more compact form than -//! the attribute: 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). The lowering and upmapping of doc comments is a bit complicated, -//! but it is encapsulated in the [`Docs`] struct. - -use std::{ - convert::Infallible, - iter::Peekable, - ops::{ControlFlow, Range}, -}; +//! Documentation (doc comments and `#[doc = "..."]` attributes) is handled by the [`docs`] +//! submodule. + +use std::{convert::Infallible, iter::Peekable, ops::ControlFlow}; use base_db::Crate; use cfg::{CfgExpr, CfgOptions}; use either::Either; use hir_expand::{ - AstId, ExpandTo, HirFileId, InFile, Lookup, - attrs::{Meta, expand_cfg_attr, expand_cfg_attr_with_doc_comments}, - mod_path::ModPath, - span_map::SpanMap, + InFile, Lookup, + attrs::{Meta, expand_cfg_attr}, }; use intern::Symbol; use itertools::Itertools; @@ -40,24 +30,26 @@ use la_arena::ArenaMap; use rustc_abi::ReprOptions; use rustc_hash::FxHashSet; use smallvec::SmallVec; -use span::AstIdMap; use syntax::{ - AstNode, AstToken, NodeOrToken, SmolStr, SourceFile, SyntaxNode, SyntaxToken, T, - ast::{self, AttrDocCommentIter, HasAttrs, IsString, TokenTreeChildren}, + AstNode, AstToken, NodeOrToken, SmolStr, SourceFile, T, + ast::{self, HasAttrs, TokenTreeChildren}, }; -use tt::{TextRange, TextSize}; +use tt::TextSize; use crate::{ AdtId, AstIdLoc, AttrDefId, FieldId, FunctionId, GenericDefId, HasModule, LifetimeParamId, LocalFieldId, MacroId, ModuleId, TypeOrConstParamId, VariantId, db::DefDatabase, hir::generics::{GenericParams, LocalLifetimeParamId, LocalTypeOrConstParamId}, - macro_call_as_call_id, - nameres::{MacroSubNs, ModuleOrigin, crate_def_map}, + nameres::ModuleOrigin, resolver::{HasResolver, Resolver}, src::{HasChildSource, HasSource}, }; +pub mod docs; + +pub use self::docs::{Docs, IsInnerDoc}; + #[inline] fn attrs_from_ast_id_loc<N: AstNode + Into<ast::AnyHasAttrs>>( db: &dyn DefDatabase, @@ -502,322 +494,12 @@ pub struct RustcLayoutScalarValidRange { pub end: Option<u128>, } -#[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` store 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 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>, -} - -#[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 - } - - 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 usize) { - let Some((doc, offset)) = comment.doc_comment() else { return }; - self.extend_with_doc_str(doc, comment.syntax().text_range().start() + offset, indent); - } - - fn extend_with_doc_attr(&mut self, value: SyntaxToken, indent: &mut usize) { - let Some(value) = ast::String::cast(value) else { return }; - 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, indent); - } - - fn extend_with_doc_str(&mut self, doc: &str, mut offset_in_ast: TextSize, indent: &mut usize) { - for line in doc.split('\n') { - self.docs_source_map.push(DocsSourceMapLine { - string_offset: TextSize::of(&self.docs), - ast_offset: Some(offset_in_ast), - }); - offset_in_ast += TextSize::of(line) + TextSize::of("\n"); - - let line = line.trim_end(); - if let Some(line_indent) = line.chars().position(|ch| !ch.is_whitespace()) { - // Empty lines are handled because `position()` returns `None` for them. - *indent = std::cmp::min(*indent, line_indent); - } - self.docs.push_str(line); - self.docs.push('\n'); - } - } - - fn extend_with_unmapped_doc_str(&mut self, doc: &str, indent: &mut usize) { - for line in doc.split('\n') { - self.docs_source_map.push(DocsSourceMapLine { - string_offset: TextSize::of(&self.docs), - ast_offset: None, - }); - let line = line.trim_end(); - if let Some(line_indent) = line.chars().position(|ch| !ch.is_whitespace()) { - *indent = std::cmp::min(*indent, line_indent); - } - self.docs.push_str(line); - self.docs.push('\n'); - } - } - - fn remove_indent(&mut self, indent: usize, start_source_map_index: usize) { - /// 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: _, - } = 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; - } - - let guard = Guard(self); - let source_map = &mut guard.0.docs_source_map[start_source_map_index..]; - 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 = line_docs.char_indices().nth(indent).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: _, - } = self; - docs.shrink_to_fit(); - docs_source_map.shrink_to_fit(); - } -} - #[derive(Debug, PartialEq, Eq, Hash)] pub struct DeriveInfo { pub trait_name: Symbol, pub helpers: Box<[Symbol]>, } -struct DocMacroExpander<'db> { - db: &'db dyn DefDatabase, - krate: Crate, - recursion_depth: usize, - recursion_limit: usize, -} - -struct DocExprSourceCtx<'db> { - resolver: Resolver<'db>, - file_id: HirFileId, - ast_id_map: &'db AstIdMap, - span_map: SpanMap, -} - fn extract_doc_aliases(result: &mut Vec<Symbol>, attr: Meta) -> ControlFlow<Infallible> { if let Meta::TokenTree { path, tt } = attr && path.is1("doc") @@ -846,217 +528,6 @@ fn extract_cfgs(result: &mut Vec<CfgExpr>, attr: Meta) -> ControlFlow<Infallible ControlFlow::Continue(()) } -fn expand_doc_expr_via_macro_pipeline<'db>( - expander: &mut DocMacroExpander<'db>, - source_ctx: &DocExprSourceCtx<'db>, - expr: ast::Expr, -) -> Option<String> { - match expr { - ast::Expr::ParenExpr(paren_expr) => { - expand_doc_expr_via_macro_pipeline(expander, source_ctx, 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()?; - let (expr, new_source_ctx) = expand_doc_macro_call(expander, source_ctx, macro_call)?; - // After expansion, the expr lives in the expansion file; use its source context. - expand_doc_expr_via_macro_pipeline(expander, &new_source_ctx, expr) - } - _ => None, - } -} - -fn expand_doc_macro_call<'db>( - expander: &mut DocMacroExpander<'db>, - source_ctx: &DocExprSourceCtx<'db>, - macro_call: ast::MacroCall, -) -> Option<(ast::Expr, DocExprSourceCtx<'db>)> { - if expander.recursion_depth >= expander.recursion_limit { - return None; - } - - let path = macro_call.path()?; - let mod_path = ModPath::from_src(expander.db, path, &mut |range| { - source_ctx.span_map.span_for_range(range).ctx - })?; - let call_site = source_ctx.span_map.span_for_range(macro_call.syntax().text_range()); - let ast_id = AstId::new(source_ctx.file_id, source_ctx.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, - |path| { - source_ctx.resolver.resolve_path_as_macro_def(expander.db, path, Some(MacroSubNs::Bang)) - }, - &mut |_, _| (), - ) - .ok()? - .value?; - - expander.recursion_depth += 1; - let parse = expander.db.parse_macro_expansion(call_id).value.0; - let expr = parse.cast::<ast::Expr>().map(|parse| parse.tree())?; - expander.recursion_depth -= 1; - - // 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 new_source_ctx = DocExprSourceCtx { - resolver: source_ctx.resolver.clone(), - file_id: expansion_file_id, - ast_id_map: expander.db.ast_id_map(expansion_file_id), - span_map: expander.db.span_map(expansion_file_id), - }; - Some((expr, new_source_ctx)) -} - -fn extend_with_attrs<'a, 'db>( - result: &mut Docs, - node: &SyntaxNode, - expect_inner_attrs: bool, - indent: &mut usize, - get_cfg_options: &dyn Fn() -> &'a CfgOptions, - cfg_options: &mut Option<&'a CfgOptions>, - mut expander: Option<&mut DocMacroExpander<'db>>, - source_ctx: Option<&DocExprSourceCtx<'db>>, -) { - 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, _, _, top_attr)) => match attr { - Meta::NamedKeyValue { name: Some(name), value: Some(value), .. } - if name.text() == "doc" => - { - result.extend_with_doc_attr(value, indent); - } - Meta::NamedKeyValue { name: Some(name), value: None, .. } - if name.text() == "doc" => - { - if let (Some(expander), Some(source_ctx)) = - (expander.as_deref_mut(), source_ctx) - && let Some(expr) = top_attr.expr() - && let Some(expanded) = - expand_doc_expr_via_macro_pipeline(expander, source_ctx, expr) - { - result.extend_with_unmapped_doc_str(&expanded, indent); - } - } - _ => {} - }, - } - ControlFlow::Continue(()) - }, - ); -} - -fn extract_docs<'a, 'db>( - mut expander: Option<&mut DocMacroExpander<'db>>, - resolver: Option<&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, - }; - - let mut cfg_options = None; - - if let Some(outer_mod_decl) = outer_mod_decl { - let mut indent = usize::MAX; - let outer_source_ctx = - if let (Some(expander), Some(resolver)) = (expander.as_deref(), resolver) { - Some(DocExprSourceCtx { - resolver: resolver.clone(), - file_id: outer_mod_decl.file_id, - ast_id_map: expander.db.ast_id_map(outer_mod_decl.file_id), - span_map: expander.db.span_map(outer_mod_decl.file_id), - }) - } else { - None - }; - extend_with_attrs( - &mut result, - outer_mod_decl.value.syntax(), - false, - &mut indent, - get_cfg_options, - &mut cfg_options, - expander.as_deref_mut(), - outer_source_ctx.as_ref(), - ); - result.remove_indent(indent, 0); - result.outline_mod = Some((outer_mod_decl.file_id, result.docs_source_map.len())); - } - - let inline_source_map_start = result.docs_source_map.len(); - let mut indent = usize::MAX; - let inline_source_ctx = - if let (Some(expander), Some(resolver)) = (expander.as_deref(), resolver) { - Some(DocExprSourceCtx { - resolver: resolver.clone(), - file_id: source.file_id, - ast_id_map: expander.db.ast_id_map(source.file_id), - span_map: expander.db.span_map(source.file_id), - }) - } else { - None - }; - extend_with_attrs( - &mut result, - source.value.syntax(), - false, - &mut indent, - get_cfg_options, - &mut cfg_options, - expander.as_deref_mut(), - inline_source_ctx.as_ref(), - ); - if let Some(inner_attrs_node) = &inner_attrs_node { - result.inline_inner_docs_start = Some(TextSize::of(&result.docs)); - extend_with_attrs( - &mut result, - inner_attrs_node, - true, - &mut indent, - get_cfg_options, - &mut cfg_options, - expander, - inline_source_ctx.as_ref(), - ); - } - result.remove_indent(indent, inline_source_map_start); - - result.remove_last_newline(); - - result.shrink_to_fit(); - - if result.docs.is_empty() { None } else { Some(Box::new(result)) } -} - #[salsa::tracked] impl AttrFlags { #[salsa::tracked] @@ -1494,20 +965,25 @@ impl AttrFlags { pub fn docs(db: &dyn DefDatabase, owner: AttrDefId) -> Option<Box<Docs>> { let (source, outer_mod_decl, _extra_crate_attrs, krate) = attrs_source(db, owner); let inner_attrs_node = source.value.inner_attributes_node(); - let resolver = resolver_for_attr_def_id(db, owner); - let def_map = crate_def_map(db, krate); - let recursion_limit = if cfg!(test) { - std::cmp::min(32, def_map.recursion_limit() as usize) + let outer_resolver = if outer_mod_decl.is_some() { + if let AttrDefId::ModuleId(module_id) = owner { + module_id + .containing_module(db) + .map(|parent| move || -> Resolver<'_> { parent.resolver(db) }) + } else { + None + } } else { - def_map.recursion_limit() as usize + None }; - let mut expander = DocMacroExpander { db, krate, recursion_depth: 0, recursion_limit }; // Note: we don't have to pass down `_extra_crate_attrs` here, since `extract_docs` // does not handle crate-level attributes related to docs. // See: https://doc.rust-lang.org/rustdoc/write-documentation/the-doc-attribute.html#at-the-crate-level - extract_docs( - Some(&mut expander), - Some(&resolver), + self::docs::extract_docs( + db, + krate, + outer_resolver, + || resolver_for_attr_def_id(db, owner), &|| krate.cfg_options(db), source, outer_mod_decl, @@ -1526,19 +1002,15 @@ impl AttrFlags { variant: VariantId, ) -> ArenaMap<LocalFieldId, Option<Box<Docs>>> { let krate = variant.module(db).krate(db); - let resolver = variant.resolver(db); - let def_map = crate_def_map(db, krate); - let recursion_limit = if cfg!(test) { - std::cmp::min(32, def_map.recursion_limit() as usize) - } else { - def_map.recursion_limit() as usize - }; collect_field_attrs(db, variant, |cfg_options, field| { - let mut expander = - DocMacroExpander { db, krate, recursion_depth: 0, recursion_limit }; - extract_docs( - Some(&mut expander), - Some(&resolver), + fn none_resolver<'db>() -> Option<fn() -> Resolver<'db>> { + None + } + self::docs::extract_docs( + db, + krate, + none_resolver(), + || variant.resolver(db), &|| cfg_options, field, None, @@ -1771,183 +1243,13 @@ fn next_doc_expr(it: &mut Peekable<TokenTreeChildren>) -> Option<DocAtom> { #[cfg(test)] mod tests { - use expect_test::expect; - use hir_expand::InFile; use test_fixture::WithFixture; - use tt::{TextRange, TextSize}; use crate::AttrDefId; - use crate::attrs::{AttrFlags, Docs, IsInnerDoc}; + use crate::attrs::AttrFlags; use crate::test_db::TestDB; #[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, - }; - let mut indent = usize::MAX; - - 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, &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, &mut indent); - ast_offset += TextSize::of(line) + TextSize::of("\n"); - } - - assert_eq!(indent, 1); - 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, 0); - - 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 crate_attrs() { let fixture = r#" //- /lib.rs crate:foo crate-attr:no_std crate-attr:cfg(target_arch="x86") diff --git a/crates/hir-def/src/attrs/docs.rs b/crates/hir-def/src/attrs/docs.rs new file mode 100644 index 0000000000..cf3a1845ea --- /dev/null +++ b/crates/hir-def/src/attrs/docs.rs @@ -0,0 +1,767 @@ +//! 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; +use cfg::CfgOptions; +use either::Either; +use hir_expand::{ + AstId, ExpandTo, HirFileId, InFile, + attrs::{Meta, expand_cfg_attr_with_doc_comments}, + mod_path::ModPath, + span_map::SpanMap, +}; +use span::AstIdMap; +use syntax::{ + AstNode, AstToken, SyntaxNode, + ast::{self, AttrDocCommentIter, HasAttrs, IsString}, +}; +use tt::{TextRange, TextSize}; + +use crate::{db::DefDatabase, macro_call_as_call_id, nameres::MacroSubNs, resolver::Resolver}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) 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` store 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 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>, +} + +#[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 + } + + 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 usize) { + let Some((doc, offset)) = comment.doc_comment() else { return }; + self.extend_with_doc_str(doc, comment.syntax().text_range().start() + offset, indent); + } + + fn extend_with_doc_attr(&mut self, value: syntax::SyntaxToken, indent: &mut usize) { + let Some(value) = ast::String::cast(value) else { return }; + 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, indent); + } + + pub(crate) fn extend_with_doc_str( + &mut self, + doc: &str, + offset_in_ast: TextSize, + indent: &mut usize, + ) { + self.push_doc_lines(doc, Some(offset_in_ast), indent); + } + + fn extend_with_unmapped_doc_str(&mut self, doc: &str, indent: &mut usize) { + self.push_doc_lines(doc, None, indent); + } + + fn push_doc_lines(&mut self, doc: &str, mut ast_offset: Option<TextSize>, indent: &mut usize) { + for line in doc.split('\n') { + self.docs_source_map + .push(DocsSourceMapLine { string_offset: TextSize::of(&self.docs), ast_offset }); + if let Some(ref mut offset) = ast_offset { + *offset += TextSize::of(line) + TextSize::of("\n"); + } + + let line = line.trim_end(); + if let Some(line_indent) = line.chars().position(|ch| !ch.is_whitespace()) { + // Empty lines are handled because `position()` returns `None` for them. + *indent = std::cmp::min(*indent, line_indent); + } + self.docs.push_str(line); + self.docs.push('\n'); + } + } + + fn remove_indent(&mut self, indent: usize, start_source_map_index: usize) { + /// 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: _, + } = 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; + } + + let guard = Guard(self); + let source_map = &mut guard.0.docs_source_map[start_source_map_index..]; + 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 = line_docs.char_indices().nth(indent).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: _, + } = self; + docs.shrink_to_fit(); + docs_source_map.shrink_to_fit(); + } +} + +struct DocMacroExpander<'db> { + db: &'db dyn DefDatabase, + krate: Crate, + recursion_depth: usize, + recursion_limit: usize, +} + +struct DocExprSourceCtx<'db> { + resolver: Resolver<'db>, + file_id: HirFileId, + ast_id_map: &'db AstIdMap, + span_map: SpanMap, +} + +fn expand_doc_expr_via_macro_pipeline<'db>( + expander: &mut DocMacroExpander<'db>, + source_ctx: &DocExprSourceCtx<'db>, + expr: ast::Expr, +) -> Option<String> { + match expr { + ast::Expr::ParenExpr(paren_expr) => { + expand_doc_expr_via_macro_pipeline(expander, source_ctx, 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()?; + let (expr, new_source_ctx) = expand_doc_macro_call(expander, source_ctx, macro_call)?; + // After expansion, the expr lives in the expansion file; use its source context. + expand_doc_expr_via_macro_pipeline(expander, &new_source_ctx, expr) + } + _ => None, + } +} + +fn expand_doc_macro_call<'db>( + expander: &mut DocMacroExpander<'db>, + source_ctx: &DocExprSourceCtx<'db>, + macro_call: ast::MacroCall, +) -> Option<(ast::Expr, DocExprSourceCtx<'db>)> { + if expander.recursion_depth >= expander.recursion_limit { + return None; + } + + let path = macro_call.path()?; + let mod_path = ModPath::from_src(expander.db, path, &mut |range| { + source_ctx.span_map.span_for_range(range).ctx + })?; + let call_site = source_ctx.span_map.span_for_range(macro_call.syntax().text_range()); + let ast_id = AstId::new(source_ctx.file_id, source_ctx.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, + |path| { + source_ctx.resolver.resolve_path_as_macro_def(expander.db, path, Some(MacroSubNs::Bang)) + }, + &mut |_, _| (), + ) + .ok()? + .value?; + + expander.recursion_depth += 1; + let parse = expander.db.parse_macro_expansion(call_id).value.0; + let expr = parse.cast::<ast::Expr>().map(|parse| parse.tree())?; + expander.recursion_depth -= 1; + + // 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 new_source_ctx = DocExprSourceCtx { + resolver: source_ctx.resolver.clone(), + file_id: expansion_file_id, + ast_id_map: expander.db.ast_id_map(expansion_file_id), + span_map: expander.db.span_map(expansion_file_id), + }; + Some((expr, new_source_ctx)) +} + +/// Quick check: does this syntax node have any `#[doc = expr]` attributes where the +/// value is not a simple string literal (i.e., it needs macro expansion)? +fn has_doc_macro_attr(node: &SyntaxNode) -> bool { + ast::AnyHasAttrs::cast(node.clone()).is_some_and(|owner| { + owner.attrs().any(|attr| { + let Some(meta) = attr.meta() else { return false }; + // Check it's a `doc` attribute with an expression (e.g. `#[doc = expr]`), + // but NOT a simple string literal (which wouldn't need macro expansion). + meta.path().is_some_and(|path| { + path.as_single_name_ref().is_some_and(|name| name.text() == "doc") + }) && meta.expr().is_some_and(|expr| !matches!(expr, ast::Expr::Literal(_))) + }) + }) +} + +fn extend_with_attrs<'a, 'db>( + result: &mut Docs, + node: &SyntaxNode, + expect_inner_attrs: bool, + indent: &mut usize, + get_cfg_options: &dyn Fn() -> &'a CfgOptions, + cfg_options: &mut Option<&'a CfgOptions>, + mut expander: Option<&mut DocMacroExpander<'db>>, + source_ctx: Option<&DocExprSourceCtx<'db>>, +) { + // FIXME: `#[cfg_attr(..., doc = macro!())]` is not handled correctly here: + // macro expansion inside `cfg_attr`-wrapped doc attributes is not supported yet. + // Fixing this properly requires changes to `expand_cfg_attr()`. + // See https://github.com/rust-lang/rust-analyzer/issues/18444 + 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, _, _, top_attr)) => match attr { + Meta::NamedKeyValue { name: Some(name), value: Some(value), .. } + if name.text() == "doc" => + { + result.extend_with_doc_attr(value, indent); + } + Meta::NamedKeyValue { name: Some(name), value: None, .. } + if name.text() == "doc" => + { + if let (Some(expander), Some(source_ctx)) = + (expander.as_deref_mut(), source_ctx) + && let Some(expr) = top_attr.expr() + && let Some(expanded) = + expand_doc_expr_via_macro_pipeline(expander, source_ctx, expr) + { + result.extend_with_unmapped_doc_str(&expanded, indent); + } + } + _ => {} + }, + } + ControlFlow::Continue(()) + }, + ); +} + +pub(crate) fn extract_docs<'a, 'db>( + db: &'db dyn DefDatabase, + krate: Crate, + // For outer docs on an outlined module, use the parent module's resolver. + // For inline docs (and non-module items), use the item's own resolver. + outer_resolver: Option<impl FnOnce() -> Resolver<'db>>, + inline_resolver: impl FnOnce() -> 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, + }; + + let mut cfg_options = None; + + if let Some(outer_mod_decl) = outer_mod_decl { + let mut indent = usize::MAX; + // For outer docs (the `mod foo;` declaration), use the parent module's resolver + // so that macros are resolved in the parent's scope. + let (mut outer_expander, outer_source_ctx) = + if has_doc_macro_attr(outer_mod_decl.value.syntax()) + && let Some(make) = outer_resolver + { + let resolver = make(); + let def_map = resolver.top_level_def_map(); + let recursion_limit = def_map.recursion_limit() as usize; + let expander = DocMacroExpander { db, krate, recursion_depth: 0, recursion_limit }; + let source_ctx = DocExprSourceCtx { + resolver, + file_id: outer_mod_decl.file_id, + ast_id_map: db.ast_id_map(outer_mod_decl.file_id), + span_map: db.span_map(outer_mod_decl.file_id), + }; + (Some(expander), Some(source_ctx)) + } else { + (None, None) + }; + extend_with_attrs( + &mut result, + outer_mod_decl.value.syntax(), + false, + &mut indent, + get_cfg_options, + &mut cfg_options, + outer_expander.as_mut(), + outer_source_ctx.as_ref(), + ); + result.remove_indent(indent, 0); + result.outline_mod = Some((outer_mod_decl.file_id, result.docs_source_map.len())); + } + + let inline_source_map_start = result.docs_source_map.len(); + let mut indent = usize::MAX; + // For inline docs, use the item's own resolver. + let needs_expansion = has_doc_macro_attr(source.value.syntax()) + || inner_attrs_node.as_ref().is_some_and(|n| has_doc_macro_attr(n)); + let (mut inline_expander, inline_source_ctx) = if needs_expansion { + let resolver = inline_resolver(); + let def_map = resolver.top_level_def_map(); + let recursion_limit = def_map.recursion_limit() as usize; + let expander = DocMacroExpander { db, krate, recursion_depth: 0, recursion_limit }; + let source_ctx = DocExprSourceCtx { + resolver, + file_id: source.file_id, + ast_id_map: db.ast_id_map(source.file_id), + span_map: db.span_map(source.file_id), + }; + (Some(expander), Some(source_ctx)) + } else { + (None, None) + }; + extend_with_attrs( + &mut result, + source.value.syntax(), + false, + &mut indent, + get_cfg_options, + &mut cfg_options, + inline_expander.as_mut(), + inline_source_ctx.as_ref(), + ); + if let Some(inner_attrs_node) = &inner_attrs_node { + result.inline_inner_docs_start = Some(TextSize::of(&result.docs)); + extend_with_attrs( + &mut result, + inner_attrs_node, + true, + &mut indent, + get_cfg_options, + &mut cfg_options, + inline_expander.as_mut(), + inline_source_ctx.as_ref(), + ); + } + result.remove_indent(indent, inline_source_map_start); + + result.remove_last_newline(); + + result.shrink_to_fit(); + + if result.docs.is_empty() { None } else { Some(Box::new(result)) } +} + +#[cfg(test)] +mod tests { + use expect_test::expect; + use hir_expand::InFile; + use test_fixture::WithFixture; + use tt::{TextRange, TextSize}; + + use crate::test_db::TestDB; + + use super::{Docs, 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, + }; + let mut indent = usize::MAX; + + 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, &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, &mut indent); + ast_offset += TextSize::of(line) + TextSize::of("\n"); + } + + assert_eq!(indent, 1); + 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, 0); + + 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)) + ); + } +} diff --git a/crates/ide/src/hover/tests.rs b/crates/ide/src/hover/tests.rs index d979269507..14ce51d08a 100644 --- a/crates/ide/src/hover/tests.rs +++ b/crates/ide/src/hover/tests.rs @@ -11658,3 +11658,79 @@ struct Bar { "#]], ); } + +#[test] +fn test_hover_doc_attr_macro_on_outlined_mod_resolves_from_parent() { + // Outer doc-macro on `mod foo;` should resolve from the parent module, + // and combine with inner `//!` docs from the module file. + check( + r#" +//- /main.rs +macro_rules! doc_str { + () => { "expanded from parent" }; +} + +/// plain outer doc +#[doc = doc_str!()] +mod foo$0; + +//- /foo.rs +//! inner module docs +pub struct Bar; +"#, + expect![[r#" + *foo* + + ```rust + ra_test_fixture + ``` + + ```rust + mod foo + ``` + + --- + + plain outer doc + expanded from parent + inner module docs + "#]], + ); +} + +#[test] +fn test_hover_doc_attr_macro_on_outlined_mod_combined_with_inner_docs() { + // Outer doc macro on `mod foo;` (resolved from parent) should combine with + // inner docs from the module file. + check( + r#" +//- /main.rs +macro_rules! doc_str { + () => { "outer doc from macro" }; +} + +#[doc = doc_str!()] +mod foo$0; + +//- /foo.rs +//! inner module docs +pub struct Bar; +"#, + expect![[r#" + *foo* + + ```rust + ra_test_fixture + ``` + + ```rust + mod foo + ``` + + --- + + outer doc from macro + inner module docs + "#]], + ); +} |