1 //! Extracts, resolves and rewrites links and intra-doc links in markdown documentation.
4 convert::{TryFrom, TryInto},
8 use itertools::Itertools;
9 use pulldown_cmark::{BrokenLink, CowStr, Event, InlineStr, LinkType, Options, Parser, Tag};
10 use pulldown_cmark_to_cmark::{cmark_with_options, Options as CmarkOptions};
14 db::{DefDatabase, HirDatabase},
15 Adt, AsAssocItem, AssocItem, AssocItemContainer, Crate, Field, HasAttrs, ItemInNs, ModuleDef,
18 defs::{Definition, NameClass, NameRefClass},
19 helpers::pick_best_token,
22 use syntax::{ast, match_ast, AstNode, SyntaxKind::*, SyntaxNode, TextRange, T};
24 use crate::{FilePosition, Semantics};
26 pub(crate) type DocumentationLink = String;
28 /// Rewrite documentation links in markdown to point to an online host (e.g. docs.rs)
29 pub(crate) fn rewrite_links(db: &RootDatabase, markdown: &str, definition: &Definition) -> String {
30 let mut cb = broken_link_clone_cb;
32 Parser::new_with_broken_link_callback(markdown, Options::ENABLE_TASKLISTS, Some(&mut cb));
34 let doc = map_links(doc, |target, title: &str| {
35 // This check is imperfect, there's some overlap between valid intra-doc links
36 // and valid URLs so we choose to be too eager to try to resolve what might be
38 if target.contains("://") {
39 (target.to_string(), title.to_string())
42 // * path-based links: `../../module/struct.MyStruct.html`
43 // * module-based links (AKA intra-doc links): `super::super::module::MyStruct`
44 if let Some(rewritten) = rewrite_intra_doc_link(db, *definition, target, title) {
47 if let Definition::ModuleDef(def) = *definition {
48 if let Some(target) = rewrite_url_link(db, def, target) {
49 return (target, title.to_string());
53 (target.to_string(), title.to_string())
56 let mut out = String::new();
57 let mut options = CmarkOptions::default();
58 options.code_block_backticks = 3;
59 cmark_with_options(doc, &mut out, None, options).ok();
63 /// Remove all links in markdown documentation.
64 pub(crate) fn remove_links(markdown: &str) -> String {
65 let mut drop_link = false;
67 let opts = Options::ENABLE_TASKLISTS | Options::ENABLE_FOOTNOTES;
69 let mut cb = |_: BrokenLink| {
70 let empty = InlineStr::try_from("").unwrap();
71 Some((CowStr::Inlined(empty), CowStr::Inlined(empty)))
73 let doc = Parser::new_with_broken_link_callback(markdown, opts, Some(&mut cb));
74 let doc = doc.filter_map(move |evt| match evt {
75 Event::Start(Tag::Link(link_type, ref target, ref title)) => {
76 if link_type == LinkType::Inline && target.contains("://") {
77 Some(Event::Start(Tag::Link(link_type, target.clone(), title.clone())))
83 Event::End(_) if drop_link => {
90 let mut out = String::new();
91 let mut options = CmarkOptions::default();
92 options.code_block_backticks = 3;
93 cmark_with_options(doc, &mut out, None, options).ok();
97 /// Retrieve a link to documentation for the given symbol.
98 pub(crate) fn external_docs(
100 position: &FilePosition,
101 ) -> Option<DocumentationLink> {
102 let sema = Semantics::new(db);
103 let file = sema.parse(position.file_id).syntax().clone();
104 let token = pick_best_token(file.token_at_offset(position.offset), |kind| match kind {
105 IDENT | INT_NUMBER => 3,
106 T!['('] | T![')'] => 2,
107 kind if kind.is_trivia() => 0,
110 let token = sema.descend_into_macros(token);
112 let node = token.parent()?;
113 let definition = match_ast! {
115 ast::NameRef(name_ref) => NameRefClass::classify(&sema, &name_ref).map(|d| d.referenced_field())?,
116 ast::Name(name) => NameClass::classify(&sema, &name).map(|d| d.defined_or_referenced_field())?,
121 get_doc_link(db, definition)
124 /// Extracts all links from a given markdown text.
125 pub(crate) fn extract_definitions_from_markdown(
127 ) -> Vec<(TextRange, String, Option<hir::Namespace>)> {
128 Parser::new_with_broken_link_callback(
130 Options::ENABLE_TASKLISTS,
131 Some(&mut broken_link_clone_cb),
134 .filter_map(|(event, range)| {
135 if let Event::Start(Tag::Link(_, target, title)) = event {
136 let link = if target.is_empty() { title } else { target };
137 let (link, ns) = parse_intra_doc_link(&link);
139 TextRange::new(range.start.try_into().ok()?, range.end.try_into().ok()?),
150 pub(crate) fn resolve_doc_path_for_def(
151 db: &dyn HirDatabase,
154 ns: Option<hir::Namespace>,
155 ) -> Option<hir::ModuleDef> {
157 Definition::ModuleDef(def) => match def {
158 hir::ModuleDef::Module(it) => it.resolve_doc_path(db, link, ns),
159 hir::ModuleDef::Function(it) => it.resolve_doc_path(db, link, ns),
160 hir::ModuleDef::Adt(it) => it.resolve_doc_path(db, link, ns),
161 hir::ModuleDef::Variant(it) => it.resolve_doc_path(db, link, ns),
162 hir::ModuleDef::Const(it) => it.resolve_doc_path(db, link, ns),
163 hir::ModuleDef::Static(it) => it.resolve_doc_path(db, link, ns),
164 hir::ModuleDef::Trait(it) => it.resolve_doc_path(db, link, ns),
165 hir::ModuleDef::TypeAlias(it) => it.resolve_doc_path(db, link, ns),
166 hir::ModuleDef::BuiltinType(_) => None,
168 Definition::Macro(it) => it.resolve_doc_path(db, link, ns),
169 Definition::Field(it) => it.resolve_doc_path(db, link, ns),
170 Definition::SelfType(_)
171 | Definition::Local(_)
172 | Definition::GenericParam(_)
173 | Definition::Label(_) => None,
177 pub(crate) fn doc_attributes(
178 sema: &Semantics<RootDatabase>,
180 ) -> Option<(hir::AttrsWithOwner, Definition)> {
183 ast::SourceFile(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Module(def)))),
184 ast::Module(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Module(def)))),
185 ast::Fn(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Function(def)))),
186 ast::Struct(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Adt(hir::Adt::Struct(def))))),
187 ast::Union(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Adt(hir::Adt::Union(def))))),
188 ast::Enum(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Adt(hir::Adt::Enum(def))))),
189 ast::Variant(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Variant(def)))),
190 ast::Trait(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Trait(def)))),
191 ast::Static(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Static(def)))),
192 ast::Const(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::Const(def)))),
193 ast::TypeAlias(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::ModuleDef(hir::ModuleDef::TypeAlias(def)))),
194 ast::Impl(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::SelfType(def))),
195 ast::RecordField(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::Field(def))),
196 ast::TupleField(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::Field(def))),
197 ast::Macro(it) => sema.to_def(&it).map(|def| (def.attrs(sema.db), Definition::Macro(def))),
198 // ast::Use(it) => sema.to_def(&it).map(|def| (Box::new(it) as _, def.attrs(sema.db))),
204 fn broken_link_clone_cb<'a, 'b>(link: BrokenLink<'a>) -> Option<(CowStr<'b>, CowStr<'b>)> {
205 // These allocations are actually unnecessary but the lifetimes on BrokenLinkCallback are wrong
206 // this is fixed in the repo but not on the crates.io release yet
208 /*url*/ link.reference.to_owned().into(),
209 /*title*/ link.reference.to_owned().into(),
214 // BUG: For Option::Some
215 // Returns https://doc.rust-lang.org/nightly/core/prelude/v1/enum.Option.html#variant.Some
216 // Instead of https://doc.rust-lang.org/nightly/core/option/enum.Option.html
218 // This should cease to be a problem if RFC2988 (Stable Rustdoc URLs) is implemented
219 // https://github.com/rust-lang/rfcs/pull/2988
220 fn get_doc_link(db: &RootDatabase, definition: Definition) -> Option<String> {
221 // Get the outermost definition for the module def. This is used to resolve the public path to the type,
222 // then we can join the method, field, etc onto it if required.
223 let target_def: ModuleDef = match definition {
224 Definition::ModuleDef(def) => match def {
225 ModuleDef::Function(f) => f
227 .and_then(|assoc| match assoc.container(db) {
228 AssocItemContainer::Trait(t) => Some(t.into()),
229 AssocItemContainer::Impl(impl_) => {
230 impl_.self_ty(db).as_adt().map(|adt| adt.into())
233 .unwrap_or_else(|| def),
236 Definition::Field(f) => f.parent_def(db).into(),
237 // FIXME: Handle macros
241 let ns = ItemInNs::from(target_def);
243 let krate = match definition {
244 // Definition::module gives back the parent module, we don't want that as it fails for root modules
245 Definition::ModuleDef(ModuleDef::Module(module)) => module.krate(),
246 _ => definition.module(db)?.krate(),
248 // FIXME: using import map doesn't make sense here. What we want here is
249 // canonical path. What import map returns is the shortest path suitable for
250 // import. See this test:
251 cov_mark::hit!(test_reexport_order);
252 let import_map = db.import_map(krate.into());
254 let mut base = krate.display_name(db)?.to_string();
255 let is_root_module = matches!(
257 Definition::ModuleDef(ModuleDef::Module(module)) if krate.root_module(db) == module
261 .chain(import_map.path_of(ns)?.segments.iter().map(|name| name.to_string()))
266 let filename = get_symbol_filename(db, &target_def);
267 let fragment = match definition {
268 Definition::ModuleDef(def) => match def {
269 ModuleDef::Function(f) => {
270 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Function(f)))
272 ModuleDef::Const(c) => {
273 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Const(c)))
275 ModuleDef::TypeAlias(ty) => {
276 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::TypeAlias(ty)))
280 Definition::Field(field) => get_symbol_fragment(db, &FieldOrAssocItem::Field(field)),
284 get_doc_url(db, &krate)?
287 .and_then(|mut url| {
288 if !matches!(definition, Definition::ModuleDef(ModuleDef::Module(..))) {
289 url.path_segments_mut().ok()?.pop();
293 .and_then(|url| url.join(filename.as_deref()?).ok())
295 |url| if let Some(fragment) = fragment { url.join(&fragment).ok() } else { Some(url) },
297 .map(|url| url.into())
300 fn rewrite_intra_doc_link(
305 ) -> Option<(String, String)> {
306 let link = if target.is_empty() { title } else { target };
307 let (link, ns) = parse_intra_doc_link(link);
308 let resolved = resolve_doc_path_for_def(db, def, link, ns)?;
309 let krate = resolved.module(db)?.krate();
310 let canonical_path = resolved.canonical_path(db)?;
311 let mut new_url = get_doc_url(db, &krate)?
312 .join(&format!("{}/", krate.display_name(db)?))
314 .join(&canonical_path.replace("::", "/"))
316 .join(&get_symbol_filename(db, &resolved)?)
319 if let ModuleDef::Trait(t) = resolved {
320 if let Some(assoc_item) = t.items(db).into_iter().find_map(|assoc_item| {
321 if let Some(name) = assoc_item.name(db) {
322 if *link == format!("{}::{}", canonical_path, name) {
323 return Some(assoc_item);
328 if let Some(fragment) =
329 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(assoc_item))
331 new_url = new_url.join(&fragment).ok()?;
336 Some((new_url.into(), strip_prefixes_suffixes(title).to_string()))
339 /// Try to resolve path to local documentation via path-based links (i.e. `../gateway/struct.Shard.html`).
340 fn rewrite_url_link(db: &RootDatabase, def: ModuleDef, target: &str) -> Option<String> {
341 if !(target.contains('#') || target.contains(".html")) {
345 let module = def.module(db)?;
346 let krate = module.krate();
347 let canonical_path = def.canonical_path(db)?;
348 let base = format!("{}/{}", krate.display_name(db)?, canonical_path.replace("::", "/"));
350 get_doc_url(db, &krate)
351 .and_then(|url| url.join(&base).ok())
353 get_symbol_filename(db, &def).as_deref().map(|f| url.join(f).ok()).flatten()
355 .and_then(|url| url.join(target).ok())
356 .map(|url| url.into())
359 /// Rewrites a markdown document, applying 'callback' to each link.
361 events: impl Iterator<Item = Event<'e>>,
362 callback: impl Fn(&str, &str) -> (String, String),
363 ) -> impl Iterator<Item = Event<'e>> {
364 let mut in_link = false;
365 let mut link_target: Option<CowStr> = None;
367 events.map(move |evt| match evt {
368 Event::Start(Tag::Link(_link_type, ref target, _)) => {
370 link_target = Some(target.clone());
373 Event::End(Tag::Link(link_type, _target, _)) => {
375 Event::End(Tag::Link(link_type, link_target.take().unwrap(), CowStr::Borrowed("")))
377 Event::Text(s) if in_link => {
378 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
379 link_target = Some(CowStr::Boxed(link_target_s.into()));
380 Event::Text(CowStr::Boxed(link_name.into()))
382 Event::Code(s) if in_link => {
383 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
384 link_target = Some(CowStr::Boxed(link_target_s.into()));
385 Event::Code(CowStr::Boxed(link_name.into()))
391 const TYPES: ([&str; 9], [&str; 0]) =
392 (["type", "struct", "enum", "mod", "trait", "union", "module", "prim", "primitive"], []);
393 const VALUES: ([&str; 8], [&str; 1]) =
394 (["value", "function", "fn", "method", "const", "static", "mod", "module"], ["()"]);
395 const MACROS: ([&str; 2], [&str; 1]) = (["macro", "derive"], ["!"]);
397 /// Extract the specified namespace from an intra-doc-link if one exists.
401 /// * `struct MyStruct` -> ("MyStruct", `Namespace::Types`)
402 /// * `panic!` -> ("panic", `Namespace::Macros`)
403 /// * `fn@from_intra_spec` -> ("from_intra_spec", `Namespace::Values`)
404 fn parse_intra_doc_link(s: &str) -> (&str, Option<hir::Namespace>) {
405 let s = s.trim_matches('`');
408 (hir::Namespace::Types, (TYPES.0.iter(), TYPES.1.iter())),
409 (hir::Namespace::Values, (VALUES.0.iter(), VALUES.1.iter())),
410 (hir::Namespace::Macros, (MACROS.0.iter(), MACROS.1.iter())),
414 .find_map(|(ns, (mut prefixes, mut suffixes))| {
415 if let Some(prefix) = prefixes.find(|&&prefix| {
416 s.starts_with(prefix)
417 && s.chars().nth(prefix.len()).map_or(false, |c| c == '@' || c == ' ')
419 Some((&s[prefix.len() + 1..], ns))
421 suffixes.find_map(|&suffix| s.strip_suffix(suffix).zip(Some(ns)))
424 .map_or((s, None), |(s, ns)| (s, Some(ns)))
427 fn strip_prefixes_suffixes(s: &str) -> &str {
429 (TYPES.0.iter(), TYPES.1.iter()),
430 (VALUES.0.iter(), VALUES.1.iter()),
431 (MACROS.0.iter(), MACROS.1.iter()),
435 .find_map(|(mut prefixes, mut suffixes)| {
436 if let Some(prefix) = prefixes.find(|&&prefix| {
437 s.starts_with(prefix)
438 && s.chars().nth(prefix.len()).map_or(false, |c| c == '@' || c == ' ')
440 Some(&s[prefix.len() + 1..])
442 suffixes.find_map(|&suffix| s.strip_suffix(suffix))
448 /// Get the root URL for the documentation of a crate.
451 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
452 /// ^^^^^^^^^^^^^^^^^^^^^^^^^^
454 fn get_doc_url(db: &RootDatabase, krate: &Crate) -> Option<Url> {
456 .get_html_root_url(db)
458 // Fallback to docs.rs. This uses `display_name` and can never be
459 // correct, but that's what fallbacks are about.
461 // FIXME: clicking on the link should just open the file in the editor,
462 // instead of falling back to external urls.
463 Some(format!("https://docs.rs/{}/*/", krate.display_name(db)?))
465 .and_then(|s| Url::parse(&s).ok())
468 /// Get the filename and extension generated for a symbol by rustdoc.
471 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
472 /// ^^^^^^^^^^^^^^^^^^^
474 fn get_symbol_filename(db: &dyn HirDatabase, definition: &ModuleDef) -> Option<String> {
475 Some(match definition {
476 ModuleDef::Adt(adt) => match adt {
477 Adt::Struct(s) => format!("struct.{}.html", s.name(db)),
478 Adt::Enum(e) => format!("enum.{}.html", e.name(db)),
479 Adt::Union(u) => format!("union.{}.html", u.name(db)),
481 ModuleDef::Module(_) => "index.html".to_string(),
482 ModuleDef::Trait(t) => format!("trait.{}.html", t.name(db)),
483 ModuleDef::TypeAlias(t) => format!("type.{}.html", t.name(db)),
484 ModuleDef::BuiltinType(t) => format!("primitive.{}.html", t.name()),
485 ModuleDef::Function(f) => format!("fn.{}.html", f.name(db)),
486 ModuleDef::Variant(ev) => {
487 format!("enum.{}.html#variant.{}", ev.parent_enum(db).name(db), ev.name(db))
489 ModuleDef::Const(c) => format!("const.{}.html", c.name(db)?),
490 ModuleDef::Static(s) => format!("static.{}.html", s.name(db)?),
494 enum FieldOrAssocItem {
496 AssocItem(AssocItem),
499 /// Get the fragment required to link to a specific field, method, associated type, or associated constant.
502 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
505 fn get_symbol_fragment(db: &dyn HirDatabase, field_or_assoc: &FieldOrAssocItem) -> Option<String> {
506 Some(match field_or_assoc {
507 FieldOrAssocItem::Field(field) => format!("#structfield.{}", field.name(db)),
508 FieldOrAssocItem::AssocItem(assoc) => match assoc {
509 AssocItem::Function(function) => {
510 let is_trait_method = function
512 .and_then(|assoc| assoc.containing_trait(db))
514 // This distinction may get more complicated when specialization is available.
515 // Rustdoc makes this decision based on whether a method 'has defaultness'.
516 // Currently this is only the case for provided trait methods.
517 if is_trait_method && !function.has_body(db) {
518 format!("#tymethod.{}", function.name(db))
520 format!("#method.{}", function.name(db))
523 AssocItem::Const(constant) => format!("#associatedconstant.{}", constant.name(db)?),
524 AssocItem::TypeAlias(ty) => format!("#associatedtype.{}", ty.name(db)),
531 use expect_test::{expect, Expect};
535 fn check(ra_fixture: &str, expect: Expect) {
536 let (analysis, position) = fixture::position(ra_fixture);
537 let url = analysis.external_docs(position).unwrap().expect("could not find url for symbol");
539 expect.assert_eq(&url)
543 fn test_doc_url_crate() {
546 //- /main.rs crate:main deps:test
548 //- /lib.rs crate:test
551 expect![[r#"https://docs.rs/test/*/test/index.html"#]],
556 fn test_doc_url_struct() {
561 expect![[r#"https://docs.rs/test/*/test/struct.Foo.html"#]],
566 fn test_doc_url_fn() {
571 expect![[r##"https://docs.rs/test/*/test/fn.foo.html#method.foo"##]],
576 fn test_doc_url_inherent_method() {
586 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#method.method"##]],
591 fn test_doc_url_trait_provided_method() {
599 expect![[r##"https://docs.rs/test/*/test/trait.Bar.html#method.method"##]],
604 fn test_doc_url_trait_required_method() {
612 expect![[r##"https://docs.rs/test/*/test/trait.Foo.html#tymethod.method"##]],
617 fn test_doc_url_field() {
625 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#structfield.field"##]],
637 expect![[r#"https://docs.rs/test/*/test/foo/bar/index.html"#]],
642 fn test_reexport_order() {
643 cov_mark::check!(test_reexport_order);
644 // FIXME: This should return
646 // https://docs.rs/test/*/test/wrapper/modulestruct.Item.html
648 // That is, we should point inside the module, rather than at the
653 pub use module::Item;
661 let bar: wrapper::It$0em;
664 expect![[r#"https://docs.rs/test/*/test/wrapper/struct.Item.html"#]],