From 604d07b05a45add6f732caddf13d7c438c9f3894 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:52:26 +0800 Subject: [PATCH] feat: rename references in intra-doc links --- Cargo.lock | 1 + .../src/handlers/convert_closure_to_fn.rs | 3 +- crates/ide-db/Cargo.toml | 1 + crates/ide-db/src/documentation.rs | 8 + .../src/documentation/intra_doc_links.rs | 286 ++++++++++++++++++ crates/ide-db/src/rename.rs | 4 +- crates/ide-db/src/search.rs | 103 +++++-- crates/ide/src/doc_links.rs | 108 +------ crates/ide/src/doc_links/intra_doc_links.rs | 73 ----- crates/ide/src/doc_links/tests.rs | 4 +- crates/ide/src/rename.rs | 54 +++- crates/ide/src/syntax_highlighting/inject.rs | 4 +- 12 files changed, 444 insertions(+), 205 deletions(-) create mode 100644 crates/ide-db/src/documentation/intra_doc_links.rs delete mode 100644 crates/ide/src/doc_links/intra_doc_links.rs diff --git a/Cargo.lock b/Cargo.lock index 19bdd0c7635a..3f1be37f59a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,6 +1129,7 @@ dependencies = [ "nohash-hasher", "parser", "profile", + "pulldown-cmark", "rayon", "rustc-hash 2.1.2", "salsa", diff --git a/crates/ide-assists/src/handlers/convert_closure_to_fn.rs b/crates/ide-assists/src/handlers/convert_closure_to_fn.rs index 83effa11820b..11cfcbeaf1df 100644 --- a/crates/ide-assists/src/handlers/convert_closure_to_fn.rs +++ b/crates/ide-assists/src/handlers/convert_closure_to_fn.rs @@ -575,7 +575,8 @@ fn handle_calls( let name = match usage.name { FileReferenceNode::Name(name) => name.syntax().clone(), FileReferenceNode::NameRef(name_ref) => name_ref.syntax().clone(), - FileReferenceNode::FormatStringEntry(..) => continue, + FileReferenceNode::FormatStringEntry(..) + | FileReferenceNode::IntraDocLink(..) => continue, FileReferenceNode::Lifetime(_) => { unreachable!("impossible usage") } diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml index 501579277bbd..6d91fee7f3ec 100644 --- a/crates/ide-db/Cargo.toml +++ b/crates/ide-db/Cargo.toml @@ -28,6 +28,7 @@ triomphe.workspace = true nohash-hasher.workspace = true bitflags.workspace = true smallvec.workspace = true +pulldown-cmark.workspace = true # local deps base-db.workspace = true diff --git a/crates/ide-db/src/documentation.rs b/crates/ide-db/src/documentation.rs index 407049f4b362..1a0878f7ff5e 100644 --- a/crates/ide-db/src/documentation.rs +++ b/crates/ide-db/src/documentation.rs @@ -1,8 +1,16 @@ //! Documentation attribute related utilities. +mod intra_doc_links; + use std::borrow::Cow; use hir::{HasAttrs, db::HirDatabase, resolve_doc_path_on}; +pub(crate) use intra_doc_links::intra_doc_links; +pub use intra_doc_links::{ + doc_attributes, extract_intra_doc_link_occurrences, parse_intra_doc_link, + resolve_doc_path_for_def, strip_intra_doc_link_disambiguators, +}; + /// Holds documentation #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Documentation<'db>(Cow<'db, str>); diff --git a/crates/ide-db/src/documentation/intra_doc_links.rs b/crates/ide-db/src/documentation/intra_doc_links.rs new file mode 100644 index 000000000000..012c9a37efb0 --- /dev/null +++ b/crates/ide-db/src/documentation/intra_doc_links.rs @@ -0,0 +1,286 @@ +//! Helper tools for intra doc links. + +use pulldown_cmark::{BrokenLink, CowStr, Event, LinkType, Options, Parser, Tag}; + +use hir::{AttrsWithOwner, HasAttrs, Semantics, db::HirDatabase}; +use syntax::{AstNode, SyntaxNode, TextRange, TextSize, ast, match_ast}; + +use crate::{ + EditionedFileId, RootDatabase, + defs::Definition, + documentation::{Documentation, HasDocs}, +}; + +const MARKDOWN_OPTIONS: Options = + Options::ENABLE_FOOTNOTES.union(Options::ENABLE_TABLES).union(Options::ENABLE_TASKLISTS); + +const TYPES: (&[&str], &[&str]) = + (&["type", "struct", "enum", "mod", "trait", "union", "module", "prim", "primitive"], &[]); +const VALUES: (&[&str], &[&str]) = + (&["value", "function", "fn", "method", "const", "static", "mod", "module"], &["()"]); +const MACROS: (&[&str], &[&str]) = (&["macro", "derive"], &["!"]); + +/// Extract the specified namespace from an intra-doc link, if one exists. +/// +/// # Examples +/// +/// * `struct MyStruct` -> (`MyStruct`, `Namespace::Types`) +/// * `panic!` -> (`panic`, `Namespace::Macros`) +/// * `fn@from_intra_spec` -> (`from_intra_spec`, `Namespace::Values`) +pub fn parse_intra_doc_link(s: &str) -> (&str, Option) { + let s = s.trim_matches('`'); + + [ + (hir::Namespace::Types, TYPES), + (hir::Namespace::Values, VALUES), + (hir::Namespace::Macros, MACROS), + ] + .into_iter() + .find_map(|(ns, (prefixes, suffixes))| { + if let Some(prefix) = prefixes.iter().find(|&&prefix| { + s.starts_with(prefix) + && s.chars().nth(prefix.len()).is_some_and(|c| c == '@' || c == ' ') + }) { + Some((&s[prefix.len() + 1..], ns)) + } else { + suffixes.iter().find_map(|&suffix| s.strip_suffix(suffix).zip(Some(ns))) + } + }) + .map_or((s, None), |(s, ns)| (s, Some(ns))) +} + +pub fn strip_intra_doc_link_disambiguators(s: &str) -> &str { + [TYPES, VALUES, MACROS] + .into_iter() + .find_map(|(prefixes, suffixes)| { + if let Some(prefix) = prefixes.iter().find(|&&prefix| { + s.starts_with(prefix) + && s.chars().nth(prefix.len()).is_some_and(|c| c == '@' || c == ' ') + }) { + Some(&s[prefix.len() + 1..]) + } else { + suffixes.iter().find_map(|&suffix| s.strip_suffix(suffix)) + } + }) + .unwrap_or(s) +} + +/// Extracts all intra-doc link occurrences from Markdown documentation. +pub fn extract_intra_doc_link_occurrences( + docs: &Documentation<'_>, +) -> Vec<(TextRange, String, Option)> { + Parser::new_with_broken_link_callback( + docs.as_str(), + MARKDOWN_OPTIONS, + Some(&mut broken_link_clone_cb), + ) + .into_offset_iter() + .filter_map(|(event, range)| match event { + Event::Start(Tag::Link(_, target, _)) => { + let (link, ns) = parse_intra_doc_link(&target); + Some(( + TextRange::new(range.start.try_into().ok()?, range.end.try_into().ok()?), + link.to_owned(), + ns, + )) + } + _ => None, + }) + .collect() +} + +fn extract_intra_doc_link_targets( + docs: &Documentation<'_>, +) -> Vec<(TextRange, String, Option)> { + let mut broken_link_callback = broken_link_clone_cb; + let parser = Parser::new_with_broken_link_callback( + docs.as_str(), + MARKDOWN_OPTIONS, + Some(&mut broken_link_callback), + ); + let mut targets = parser + .reference_definitions() + .iter() + .filter_map(|(_, definition)| { + let source = &docs.as_str()[definition.span.clone()]; + let dest_start = source.find("]:")? + 2; + let dest = definition.dest.as_ref(); + let offset = dest_start + source[dest_start..].find(dest)?; + let start = definition.span.start + offset; + let range = + TextRange::new(start.try_into().ok()?, (start + dest.len()).try_into().ok()?); + let (link, ns) = parse_intra_doc_link(dest); + Some((range, link.to_owned(), ns)) + }) + .collect::>(); + + targets.extend(parser.into_offset_iter().filter_map(|(event, range)| { + let Event::Start(Tag::Link(link_type, target, _)) = event else { return None }; + let source = &docs.as_str()[range.clone()]; + let target = target.as_ref(); + let offset = match link_type { + LinkType::Inline => { + let dest_start = source.rfind("](")? + 2; + dest_start + source[dest_start..].find(target)? + } + LinkType::ReferenceUnknown => source.rfind(target)?, + LinkType::CollapsedUnknown | LinkType::ShortcutUnknown => source.find(target)?, + _ => return None, + }; + let start = range.start + offset; + let range = TextRange::new(start.try_into().ok()?, (start + target.len()).try_into().ok()?); + let (link, ns) = parse_intra_doc_link(target); + Some((range, link.to_owned(), ns)) + })); + targets +} + +pub fn resolve_doc_path_for_def<'db>( + db: &dyn HirDatabase, + def: Definition<'db>, + link: &str, + ns: Option, + is_inner_doc: hir::IsInnerDoc, +) -> Option> { + match def { + Definition::Module(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Crate(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Function(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Adt(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::EnumVariant(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Const(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Static(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Trait(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::TypeAlias(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Macro(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::Field(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::SelfType(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::ExternCrateDecl(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), + Definition::BuiltinAttr(_) + | Definition::BuiltinType(_) + | Definition::BuiltinLifetime(_) + | Definition::ToolModule(_) + | Definition::TupleField(_) + | Definition::Local(_) + | Definition::GenericParam(_) + | Definition::Label(_) + | Definition::DeriveHelper(_) + | Definition::InlineAsmRegOrRegClass(_) + | Definition::InlineAsmOperand(_) => None, + } + .map(Definition::from) +} + +pub fn doc_attributes<'db>( + sema: &Semantics<'db, RootDatabase>, + node: &SyntaxNode, +) -> Option<(AttrsWithOwner, Definition<'db>)> { + match_ast! { + match node { + ast::SourceFile(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Module(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Fn(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Struct(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(hir::Adt::Struct(def)))), + ast::Union(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(hir::Adt::Union(def)))), + ast::Enum(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(hir::Adt::Enum(def)))), + ast::Variant(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Trait(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Static(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Const(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::TypeAlias(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Impl(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::RecordField(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::TupleField(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::Macro(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + ast::ExternCrate(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), + _ => None + } + } +} + +pub(crate) fn intra_doc_links<'db>( + sema: &Semantics<'db, RootDatabase>, + file_id: EditionedFileId, + node: &SyntaxNode, + name: &str, +) -> Vec<(TextRange, Definition<'db>)> { + // FIXME: Decide how edits to macro-expanded documentation should map back to source before + // descending into macro expansions here + let Some((attributes, owner)) = doc_attributes(sema, node) else { return Vec::new() }; + let Some(docs) = attributes.hir_docs(sema.db) else { return Vec::new() }; + if !docs.docs().contains(name) { + return Vec::new(); + } + + let mut res = Vec::new(); + + for (dest_range, link, ns) in + extract_intra_doc_link_targets(&Documentation::new_borrowed(docs.docs())) + { + let path = link.split_once('#').map_or(link.as_str(), |(path, _)| path); + if path.is_empty() { + continue; + } + let dest = &docs.docs()[dest_range]; + let dest_path = dest.split_once('#').map_or(dest, |(path, _)| path); + let Some(path_offset) = dest_path.rfind(path) else { continue }; + let Ok(path_offset) = TextSize::try_from(path_offset) else { continue }; + let path_offset = dest_range.start() + path_offset; + + let mut segment_start = 0; + for segment in path.split("::") { + let segment_end = segment_start + segment.len(); + if segment.trim_start_matches("r#") != name { + segment_start = segment_end + 2; + continue; + } + let (Ok(range_start), Ok(range_end)) = + (TextSize::try_from(segment_start), TextSize::try_from(segment_end)) + else { + break; + }; + let range = TextRange::new(path_offset + range_start, path_offset + range_end); + let Some((mapped, is_inner)) = docs.find_ast_range(range) else { + segment_start = segment_end + 2; + continue; + }; + if mapped.file_id == file_id { + let prefix = &path[..segment_end]; + let prefix_ns = (segment_end == path.len()).then_some(ns).flatten(); + if let Some(def) = + resolve_doc_path_for_def(sema.db, owner, prefix, prefix_ns, is_inner) + { + res.push((mapped.value, def)); + } + } + segment_start = segment_end + 2; + } + } + + res +} + +fn broken_link_clone_cb(link: BrokenLink<'_>) -> Option<(CowStr<'_>, CowStr<'_>)> { + Some((link.reference.clone(), link.reference)) +} + +#[cfg(test)] +mod tests { + use expect_test::{Expect, expect}; + + use super::*; + + fn check(link: &str, expected: Expect) { + let (link, namespace) = parse_intra_doc_link(link); + let namespace = namespace.map_or_else(String::new, |it| format!(" ({it:?})")); + expected.assert_eq(&format!("{link}{namespace}")); + } + + #[test] + fn parses_disambiguators() { + check("foo", expect![[r#"foo"#]]); + check("struct Struct", expect![[r#"Struct (Types)"#]]); + check("makro!", expect![[r#"makro (Macros)"#]]); + check("fn@function", expect![[r#"function (Values)"#]]); + } +} diff --git a/crates/ide-db/src/rename.rs b/crates/ide-db/src/rename.rs index 16224ae5ee0b..8b53c2e63667 100644 --- a/crates/ide-db/src/rename.rs +++ b/crates/ide-db/src/rename.rs @@ -332,7 +332,7 @@ fn rename_mod( } let def = Definition::Module(module); - let usages = def.usages(sema).all(); + let usages = def.usages(sema).include_intra_doc_links().all(); let ref_edits = usages.iter().map(|(file_id, references)| { let edition = file_id.edition(sema.db); ( @@ -393,7 +393,7 @@ fn rename_reference<'db>( } let def = convert_to_def_in_trait(sema.db, def); - let usages = def.usages(sema).all(); + let usages = def.usages(sema).include_intra_doc_links().all(); if !usages.is_empty() && ident_kind == IdentifierKind::Underscore { cov_mark::hit!(rename_underscore_multiple); diff --git a/crates/ide-db/src/search.rs b/crates/ide-db/src/search.rs index 6a492a54798c..c8557faac9f9 100644 --- a/crates/ide-db/src/search.rs +++ b/crates/ide-db/src/search.rs @@ -19,7 +19,8 @@ use parser::SyntaxKind; use rustc_hash::{FxHashMap, FxHashSet}; use salsa::Database; use syntax::{ - AstNode, AstToken, SmolStr, SyntaxElement, SyntaxNode, TextRange, TextSize, ToSmolStr, + AstNode, AstToken, SmolStr, SyntaxElement, SyntaxNode, SyntaxToken, TextRange, TextSize, + ToSmolStr, ast::{self, HasName, Rename}, match_ast, }; @@ -28,6 +29,7 @@ use triomphe::Arc; use crate::{ RootDatabase, defs::{Definition, NameClass, NameRefClass}, + documentation::intra_doc_links, traits::{as_trait_assoc_def, convert_to_def_in_trait}, }; @@ -80,6 +82,7 @@ pub enum FileReferenceNode { NameRef(ast::NameRef), Lifetime(ast::Lifetime), FormatStringEntry(ast::String, TextRange), + IntraDocLink(SyntaxToken, TextRange), } impl FileReferenceNode { @@ -89,6 +92,7 @@ impl FileReferenceNode { FileReferenceNode::NameRef(it) => it.syntax().text_range(), FileReferenceNode::Lifetime(it) => it.syntax().text_range(), FileReferenceNode::FormatStringEntry(_, range) => *range, + FileReferenceNode::IntraDocLink(_, range) => *range, } } pub fn syntax(&self) -> SyntaxElement { @@ -97,6 +101,7 @@ impl FileReferenceNode { FileReferenceNode::NameRef(it) => it.syntax().clone().into(), FileReferenceNode::Lifetime(it) => it.syntax().clone().into(), FileReferenceNode::FormatStringEntry(it, _) => it.syntax().clone().into(), + FileReferenceNode::IntraDocLink(it, _) => it.clone().into(), } } pub fn into_name_like(self) -> Option { @@ -104,7 +109,9 @@ impl FileReferenceNode { FileReferenceNode::Name(it) => Some(ast::NameLike::Name(it)), FileReferenceNode::NameRef(it) => Some(ast::NameLike::NameRef(it)), FileReferenceNode::Lifetime(it) => Some(ast::NameLike::Lifetime(it)), - FileReferenceNode::FormatStringEntry(_, _) => None, + FileReferenceNode::FormatStringEntry(_, _) | FileReferenceNode::IntraDocLink(_, _) => { + None + } } } pub fn as_name_ref(&self) -> Option<&ast::NameRef> { @@ -127,6 +134,9 @@ impl FileReferenceNode { FileReferenceNode::FormatStringEntry(it, range) => { &it.text()[*range - it.syntax().text_range().start()] } + FileReferenceNode::IntraDocLink(it, range) => { + &it.text()[*range - it.text_range().start()] + } } } } @@ -448,6 +458,7 @@ impl<'db> Definition<'db> { sema, scope: None, include_self_kw_refs: None, + include_intra_doc_links: false, search_self_mod: false, included_categories: ReferenceCategory::all(), exclude_library_files: false, @@ -465,6 +476,8 @@ pub struct FindUsages<'a, 'db> { assoc_item_container: Option, /// whether to search for the `Self` type of the definition include_self_kw_refs: Option>, + /// whether to search for references in intra-doc links + include_intra_doc_links: bool, /// whether to search for the `self` module search_self_mod: bool, /// categories to include while collecting usages @@ -481,6 +494,15 @@ impl<'a, 'db> FindUsages<'a, 'db> { self } + /// Include references in intra-doc links. + /// + /// These are excluded by default because not all usage consumers can handle references + /// embedded in documentation. + pub fn include_intra_doc_links(mut self) -> Self { + self.include_intra_doc_links = true; + self + } + /// Limit the search to a given [`SearchScope`]. pub fn in_scope(self, scope: &'a SearchScope) -> Self { self.set_scope(Some(scope)) @@ -944,6 +966,43 @@ impl<'a, 'db> FindUsages<'a, 'db> { true } + fn search_intra_doc_links( + &self, + search_scope: &SearchScope, + name: &str, + sink: &mut dyn FnMut(EditionedFileId, FileReference) -> bool, + ) -> bool { + for (_, file_id, search_range) in + Self::scope_files(self.sema.db, search_scope, self.exclude_library_files) + { + let tree = self.sema.parse(file_id).syntax().clone(); + for node in tree.descendants() { + for (range, def) in intra_doc_links(self.sema, file_id, &node, name) { + if !search_range.contains_range(range) || !self.is_reference_to(def) { + continue; + } + let Some(token) = tree + .token_at_offset(range.start()) + .find(|token| token.text_range().contains_range(range)) + else { + continue; + }; + if sink( + file_id, + FileReference { + range, + name: FileReferenceNode::IntraDocLink(token, range), + category: ReferenceCategory::empty(), + }, + ) { + return true; + } + } + } + } + false + } + pub fn search(&self, sink: &mut dyn FnMut(EditionedFileId, FileReference) -> bool) { let _p = tracing::info_span!("FindUsages:search").entered(); let sema = self.sema; @@ -1002,6 +1061,10 @@ impl<'a, 'db> FindUsages<'a, 'db> { None => return, }; + if self.include_intra_doc_links && self.search_intra_doc_links(&search_scope, name, sink) { + return; + } + // FIXME: This should probably depend on the number of the results (specifically, the number of false results). if name.len() <= 7 && self.short_associated_function_fast_search(sink, &search_scope, name) { @@ -1256,28 +1319,7 @@ impl<'a, 'db> FindUsages<'a, 'db> { } match NameRefClass::classify(self.sema, name_ref) { - Some(NameRefClass::Definition(def, _)) - if self.def == def - // is our def a trait assoc item? then we want to find all assoc items from trait impls of our trait - || matches!(self.assoc_item_container, Some(hir::AssocItemContainer::Trait(_))) - && convert_to_def_in_trait(self.sema.db, def) == self.def => - { - let FileRange { file_id, range } = self.sema.original_range(name_ref.syntax()); - let reference = FileReference { - range, - name: FileReferenceNode::NameRef(name_ref.clone()), - category: ReferenceCategory::new(self.sema, &def, name_ref), - }; - sink(file_id, reference) - } - // FIXME: special case type aliases, we can't filter between impl and trait defs here as we lack the substitutions - // so we always resolve all assoc type aliases to both their trait def and impl defs - Some(NameRefClass::Definition(def, _)) - if self.assoc_item_container.is_some() - && matches!(self.def, Definition::TypeAlias(_)) - && convert_to_def_in_trait(self.sema.db, def) - == convert_to_def_in_trait(self.sema.db, self.def) => - { + Some(NameRefClass::Definition(def, _)) if self.is_reference_to(def) => { let FileRange { file_id, range } = self.sema.original_range(name_ref.syntax()); let reference = FileReference { range, @@ -1327,6 +1369,19 @@ impl<'a, 'db> FindUsages<'a, 'db> { } } + fn is_reference_to(&self, def: Definition<'db>) -> bool { + self.def == def + // A trait associated item also refers to its counterparts in trait impls. + || matches!(self.assoc_item_container, Some(hir::AssocItemContainer::Trait(_))) + && convert_to_def_in_trait(self.sema.db, def) == self.def + // FIXME: Without substitutions, associated type aliases cannot be narrowed down to a + // particular trait definition or implementation. + || self.assoc_item_container.is_some() + && matches!(self.def, Definition::TypeAlias(_)) + && convert_to_def_in_trait(self.sema.db, def) + == convert_to_def_in_trait(self.sema.db, self.def) + } + fn is_excluded_name_ref(&self, name_ref: &ast::NameRef) -> bool { (!self.included_categories.contains(ReferenceCategory::TEST) && is_name_ref_in_test(self.sema, name_ref)) diff --git a/crates/ide/src/doc_links.rs b/crates/ide/src/doc_links.rs index de8cf971da0e..2c3f69afacab 100644 --- a/crates/ide/src/doc_links.rs +++ b/crates/ide/src/doc_links.rs @@ -3,8 +3,6 @@ #[cfg(test)] mod tests; -mod intra_doc_links; - use std::ops::Range; use pulldown_cmark::{BrokenLink, CowStr, Event, InlineStr, LinkType, Options, Parser, Tag}; @@ -12,14 +10,12 @@ use pulldown_cmark_to_cmark::{Options as CMarkOptions, cmark_with_options}; use stdx::format_to; use url::Url; -use hir::{ - Adt, AsAssocItem, AssocItem, AssocItemContainer, AttrsWithOwner, HasAttrs, db::HirDatabase, -}; +use hir::{Adt, AsAssocItem, AssocItem, AssocItemContainer, AttrsWithOwner, db::HirDatabase}; use ide_db::{ RootDatabase, base_db::{CrateOrigin, LangCrateOrigin, ReleaseChannel, toolchain_channel}, defs::{Definition, NameClass, NameRefClass}, - documentation::{Documentation, HasDocs}, + documentation::{Documentation, parse_intra_doc_link, strip_intra_doc_link_disambiguators}, helpers::pick_best_token, }; use syntax::{ @@ -30,9 +26,10 @@ use syntax::{ match_ast, }; -use crate::{ - FilePosition, Semantics, - doc_links::intra_doc_links::{parse_intra_doc_link, strip_prefixes_suffixes}, +use crate::{FilePosition, Semantics}; + +pub(crate) use ide_db::documentation::{ + doc_attributes, extract_intra_doc_link_occurrences, resolve_doc_path_for_def, }; /// Web and local links to an item's documentation. @@ -182,95 +179,6 @@ pub(crate) fn external_docs( Some(get_doc_links(db, definition, target_dir, sysroot)) } -/// Extracts all links from a given markdown text returning the definition text range, link-text -/// and the namespace if known. -pub(crate) fn extract_definitions_from_docs( - docs: &Documentation<'_>, -) -> Vec<(TextRange, String, Option)> { - Parser::new_with_broken_link_callback( - docs.as_str(), - MARKDOWN_OPTIONS, - Some(&mut broken_link_clone_cb), - ) - .into_offset_iter() - .filter_map(|(event, range)| match event { - Event::Start(Tag::Link(_, target, _)) => { - let (link, ns) = parse_intra_doc_link(&target); - Some(( - TextRange::new(range.start.try_into().ok()?, range.end.try_into().ok()?), - link.to_owned(), - ns, - )) - } - _ => None, - }) - .collect() -} - -pub(crate) fn resolve_doc_path_for_def<'db>( - db: &dyn HirDatabase, - def: Definition<'db>, - link: &str, - ns: Option, - is_inner_doc: hir::IsInnerDoc, -) -> Option> { - match def { - Definition::Module(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Crate(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Function(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Adt(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::EnumVariant(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Const(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Static(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Trait(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::TypeAlias(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Macro(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::Field(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::SelfType(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::ExternCrateDecl(it) => it.resolve_doc_path(db, link, ns, is_inner_doc), - Definition::BuiltinAttr(_) - | Definition::BuiltinType(_) - | Definition::BuiltinLifetime(_) - | Definition::ToolModule(_) - | Definition::TupleField(_) - | Definition::Local(_) - | Definition::GenericParam(_) - | Definition::Label(_) - | Definition::DeriveHelper(_) - | Definition::InlineAsmRegOrRegClass(_) - | Definition::InlineAsmOperand(_) => None, - } - .map(Definition::from) -} - -pub(crate) fn doc_attributes<'db>( - sema: &Semantics<'db, RootDatabase>, - node: &SyntaxNode, -) -> Option<(hir::AttrsWithOwner, Definition<'db>)> { - match_ast! { - match node { - ast::SourceFile(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Module(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Fn(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Struct(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(hir::Adt::Struct(def)))), - ast::Union(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(hir::Adt::Union(def)))), - ast::Enum(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(hir::Adt::Enum(def)))), - ast::Variant(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Trait(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Static(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Const(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::TypeAlias(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Impl(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::RecordField(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::TupleField(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::Macro(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - ast::ExternCrate(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::from(def))), - // ast::Use(it) => sema.to_def(&it).map(|def| (Box::new(it) as _, def.attrs(sema.db))), - _ => None - } - } -} - pub(crate) struct DocCommentToken { doc_token: SyntaxToken, prefix_len: TextSize, @@ -324,7 +232,7 @@ impl DocCommentToken { let (attributes, def) = Self::doc_attributes(sema, &node, is_inner)?; let doc_mapping = attributes.hir_docs(sema.db)?; let (in_expansion_range, link, ns, is_inner) = - extract_definitions_from_docs(&Documentation::new_borrowed(doc_mapping.docs())).into_iter().find_map(|(range, link, ns)| { + extract_intra_doc_link_occurrences(&Documentation::new_borrowed(doc_mapping.docs())).into_iter().find_map(|(range, link, ns)| { let (mapped, is_inner) = doc_mapping.find_ast_range(range)?; (mapped.value.contains(abs_in_expansion_offset)).then_some((mapped.value, link, ns, is_inner)) })?; @@ -447,7 +355,7 @@ fn rewrite_intra_doc_link( | LinkType::Reference | LinkType::Inline => title.to_owned(), LinkType::ShortcutUnknown | LinkType::CollapsedUnknown | LinkType::ReferenceUnknown => { - strip_prefixes_suffixes(title).to_owned() + strip_intra_doc_link_disambiguators(title).to_owned() } }; diff --git a/crates/ide/src/doc_links/intra_doc_links.rs b/crates/ide/src/doc_links/intra_doc_links.rs deleted file mode 100644 index c331734c785e..000000000000 --- a/crates/ide/src/doc_links/intra_doc_links.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Helper tools for intra doc links. - -const TYPES: (&[&str], &[&str]) = - (&["type", "struct", "enum", "mod", "trait", "union", "module", "prim", "primitive"], &[]); -const VALUES: (&[&str], &[&str]) = - (&["value", "function", "fn", "method", "const", "static", "mod", "module"], &["()"]); -const MACROS: (&[&str], &[&str]) = (&["macro", "derive"], &["!"]); - -/// Extract the specified namespace from an intra-doc-link if one exists. -/// -/// # Examples -/// -/// * `struct MyStruct` -> ("MyStruct", `Namespace::Types`) -/// * `panic!` -> ("panic", `Namespace::Macros`) -/// * `fn@from_intra_spec` -> ("from_intra_spec", `Namespace::Values`) -pub(super) fn parse_intra_doc_link(s: &str) -> (&str, Option) { - let s = s.trim_matches('`'); - - [ - (hir::Namespace::Types, TYPES), - (hir::Namespace::Values, VALUES), - (hir::Namespace::Macros, MACROS), - ] - .into_iter() - .find_map(|(ns, (prefixes, suffixes))| { - if let Some(prefix) = prefixes.iter().find(|&&prefix| { - s.starts_with(prefix) - && s.chars().nth(prefix.len()).is_some_and(|c| c == '@' || c == ' ') - }) { - Some((&s[prefix.len() + 1..], ns)) - } else { - suffixes.iter().find_map(|&suffix| s.strip_suffix(suffix).zip(Some(ns))) - } - }) - .map_or((s, None), |(s, ns)| (s, Some(ns))) -} - -pub(super) fn strip_prefixes_suffixes(s: &str) -> &str { - [TYPES, VALUES, MACROS] - .into_iter() - .find_map(|(prefixes, suffixes)| { - if let Some(prefix) = prefixes.iter().find(|&&prefix| { - s.starts_with(prefix) - && s.chars().nth(prefix.len()).is_some_and(|c| c == '@' || c == ' ') - }) { - Some(&s[prefix.len() + 1..]) - } else { - suffixes.iter().find_map(|&suffix| s.strip_suffix(suffix)) - } - }) - .unwrap_or(s) -} - -#[cfg(test)] -mod tests { - use expect_test::{Expect, expect}; - - use super::*; - - fn check(link: &str, expected: Expect) { - let (l, a) = parse_intra_doc_link(link); - let a = a.map_or_else(String::new, |a| format!(" ({a:?})")); - expected.assert_eq(&format!("{l}{a}")); - } - - #[test] - fn test_name() { - check("foo", expect![[r#"foo"#]]); - check("struct Struct", expect![[r#"Struct (Types)"#]]); - check("makro!", expect![[r#"makro (Macros)"#]]); - check("fn@function", expect![[r#"function (Values)"#]]); - } -} diff --git a/crates/ide/src/doc_links/tests.rs b/crates/ide/src/doc_links/tests.rs index 720528d0b52f..54c4ed08f8fa 100644 --- a/crates/ide/src/doc_links/tests.rs +++ b/crates/ide/src/doc_links/tests.rs @@ -12,7 +12,7 @@ use syntax::{AstNode, SyntaxNode, ast, match_ast}; use crate::{ TryToNav, - doc_links::{extract_definitions_from_docs, resolve_doc_path_for_def, rewrite_links}, + doc_links::{extract_intra_doc_link_occurrences, resolve_doc_path_for_def, rewrite_links}, fixture, }; @@ -59,7 +59,7 @@ fn check_doc_links(#[rust_analyzer::rust_fixture] ra_fixture: &str) { let sema = &Semantics::new(&analysis.db); hir::attach_db(sema.db, || { let (cursor_def, docs) = def_under_cursor(sema, &position); - let defs = extract_definitions_from_docs(&Documentation::new_borrowed(docs.docs())); + let defs = extract_intra_doc_link_occurrences(&Documentation::new_borrowed(docs.docs())); let actual: Vec<_> = defs .into_iter() .flat_map(|(text_range, link, ns)| { diff --git a/crates/ide/src/rename.rs b/crates/ide/src/rename.rs index 4a2cc5f551b6..1c53606ae0ff 100644 --- a/crates/ide/src/rename.rs +++ b/crates/ide/src/rename.rs @@ -175,7 +175,7 @@ pub(crate) fn rename( bail!("Cannot rename alias reference to `self`") } }; - let mut usages = def.usages(&sema).all(); + let mut usages = def.usages(&sema).include_intra_doc_links().all(); // FIXME: hack - removes the usage that triggered this rename operation. match usages.references.get_mut(&file_id).and_then(|refs| { @@ -1210,6 +1210,58 @@ fn main() { ); } + #[test] + fn test_rename_intra_doc_links() { + check( + "NewName", + r#" +//! [`OldName`], [the type](struct@OldName), [with a fragment](OldName#OldName), and [the reference][old] +//! `OldName` in ordinary prose +//! +//! [old]: OldName + +struct Old$0Name; +"#, + r#" +//! [`NewName`], [the type](struct@NewName), [with a fragment](NewName#OldName), and [the reference][old] +//! `OldName` in ordinary prose +//! +//! [old]: NewName + +struct NewName; +"#, + ); + } + + #[test] + fn test_rename_intra_doc_link_path_segment() { + check( + "renamed", + r#" +mod fir$0st { + pub struct Target; +} +mod second { + pub struct Target; +} + +/// See [`first::Target`] and [`second::Target`]. +struct Docs; +"#, + r#" +mod renamed { + pub struct Target; +} +mod second { + pub struct Target; +} + +/// See [`renamed::Target`] and [`second::Target`]. +struct Docs; +"#, + ); + } + #[test] fn test_rename_macro_multiple_occurrences() { check( diff --git a/crates/ide/src/syntax_highlighting/inject.rs b/crates/ide/src/syntax_highlighting/inject.rs index 56020b401780..8108f4ae744d 100644 --- a/crates/ide/src/syntax_highlighting/inject.rs +++ b/crates/ide/src/syntax_highlighting/inject.rs @@ -13,7 +13,7 @@ use triomphe::Arc; use crate::{ Analysis, HlMod, HlRange, HlTag, RootDatabase, - doc_links::{doc_attributes, extract_definitions_from_docs, resolve_doc_path_for_def}, + doc_links::{doc_attributes, extract_intra_doc_link_occurrences, resolve_doc_path_for_def}, syntax_highlighting::{HighlightConfig, highlights::Highlights}, }; @@ -98,7 +98,7 @@ pub(super) fn doc_comment( let Some(docs) = attributes.hir_docs(sema.db) else { return }; // Extract intra-doc links and emit highlights for them. - extract_definitions_from_docs(&Documentation::new_borrowed(docs.docs())) + extract_intra_doc_link_occurrences(&Documentation::new_borrowed(docs.docs())) .into_iter() .filter_map(|(range, link, ns)| { docs.find_ast_range(range)