1 //! Resolves and rewrites links in markdown documentation.
3 use std::{convert::TryFrom, iter::once, ops::Range};
5 use itertools::Itertools;
6 use pulldown_cmark::{BrokenLink, CowStr, Event, InlineStr, LinkType, Options, Parser, Tag};
7 use pulldown_cmark_to_cmark::{cmark_with_options, Options as CmarkOptions};
11 db::{DefDatabase, HirDatabase},
12 Adt, AsAssocItem, AssocItem, AssocItemContainer, Crate, Field, HasAttrs, ItemInNs, ModuleDef,
15 defs::{Definition, NameClass, NameRefClass},
18 use syntax::{ast, match_ast, AstNode, SyntaxKind::*, SyntaxToken, TokenAtOffset, T};
20 use crate::{FilePosition, Semantics};
22 pub(crate) type DocumentationLink = String;
24 /// Rewrite documentation links in markdown to point to an online host (e.g. docs.rs)
25 pub(crate) fn rewrite_links(db: &RootDatabase, markdown: &str, definition: &Definition) -> String {
26 let mut cb = |link: BrokenLink| {
28 /*url*/ link.reference.to_owned().into(),
29 /*title*/ link.reference.to_owned().into(),
32 let doc = Parser::new_with_broken_link_callback(markdown, Options::empty(), 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 pub(crate) fn extract_definitions_from_markdown(
65 ) -> Vec<(String, Option<hir::Namespace>, Range<usize>)> {
67 let mut cb = |link: BrokenLink| {
69 /*url*/ link.reference.to_owned().into(),
70 /*title*/ link.reference.to_owned().into(),
73 let doc = Parser::new_with_broken_link_callback(markdown, Options::empty(), Some(&mut cb));
74 for (event, range) in doc.into_offset_iter() {
76 Event::Start(Tag::Link(_link_type, ref target, ref title)) => {
77 let link = if target.is_empty() { title } else { target };
78 let (link, ns) = parse_link(link);
79 res.push((link.to_string(), ns, range));
87 /// Remove all links in markdown documentation.
88 pub(crate) fn remove_links(markdown: &str) -> String {
89 let mut drop_link = false;
91 let mut opts = Options::empty();
92 opts.insert(Options::ENABLE_FOOTNOTES);
94 let mut cb = |_: BrokenLink| {
95 let empty = InlineStr::try_from("").unwrap();
96 Some((CowStr::Inlined(empty.clone()), CowStr::Inlined(empty)))
98 let doc = Parser::new_with_broken_link_callback(markdown, opts, Some(&mut cb));
99 let doc = doc.filter_map(move |evt| match evt {
100 Event::Start(Tag::Link(link_type, ref target, ref title)) => {
101 if link_type == LinkType::Inline && target.contains("://") {
102 Some(Event::Start(Tag::Link(link_type, target.clone(), title.clone())))
108 Event::End(_) if drop_link => {
115 let mut out = String::new();
116 let mut options = CmarkOptions::default();
117 options.code_block_backticks = 3;
118 cmark_with_options(doc, &mut out, None, options).ok();
123 // BUG: For Option::Some
124 // Returns https://doc.rust-lang.org/nightly/core/prelude/v1/enum.Option.html#variant.Some
125 // Instead of https://doc.rust-lang.org/nightly/core/option/enum.Option.html
127 // This should cease to be a problem if RFC2988 (Stable Rustdoc URLs) is implemented
128 // https://github.com/rust-lang/rfcs/pull/2988
129 fn get_doc_link(db: &RootDatabase, definition: Definition) -> Option<String> {
130 // Get the outermost definition for the moduledef. This is used to resolve the public path to the type,
131 // then we can join the method, field, etc onto it if required.
132 let target_def: ModuleDef = match definition {
133 Definition::ModuleDef(moddef) => match moddef {
134 ModuleDef::Function(f) => f
136 .and_then(|assoc| match assoc.container(db) {
137 AssocItemContainer::Trait(t) => Some(t.into()),
138 AssocItemContainer::Impl(impld) => {
139 impld.target_ty(db).as_adt().map(|adt| adt.into())
142 .unwrap_or_else(|| f.clone().into()),
145 Definition::Field(f) => f.parent_def(db).into(),
146 // FIXME: Handle macros
150 let ns = ItemInNs::from(target_def.clone());
152 let module = definition.module(db)?;
153 let krate = module.krate();
154 let import_map = db.import_map(krate.into());
155 let base = once(krate.display_name(db)?.to_string())
156 .chain(import_map.path_of(ns)?.segments.iter().map(|name| name.to_string()))
160 let filename = get_symbol_filename(db, &target_def);
161 let fragment = match definition {
162 Definition::ModuleDef(moddef) => match moddef {
163 ModuleDef::Function(f) => {
164 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Function(f)))
166 ModuleDef::Const(c) => {
167 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Const(c)))
169 ModuleDef::TypeAlias(ty) => {
170 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::TypeAlias(ty)))
174 Definition::Field(field) => get_symbol_fragment(db, &FieldOrAssocItem::Field(field)),
178 get_doc_url(db, &krate)?
181 .and_then(|mut url| {
182 if !matches!(definition, Definition::ModuleDef(ModuleDef::Module(..))) {
183 url.path_segments_mut().ok()?.pop();
187 .and_then(|url| url.join(filename.as_deref()?).ok())
189 |url| if let Some(fragment) = fragment { url.join(&fragment).ok() } else { Some(url) },
191 .map(|url| url.into_string())
194 fn rewrite_intra_doc_link(
199 ) -> Option<(String, String)> {
200 let link = if target.is_empty() { title } else { target };
201 let (link, ns) = parse_link(link);
202 let resolved = match def {
203 Definition::ModuleDef(def) => match def {
204 ModuleDef::Module(it) => it.resolve_doc_path(db, link, ns),
205 ModuleDef::Function(it) => it.resolve_doc_path(db, link, ns),
206 ModuleDef::Adt(it) => it.resolve_doc_path(db, link, ns),
207 ModuleDef::Variant(it) => it.resolve_doc_path(db, link, ns),
208 ModuleDef::Const(it) => it.resolve_doc_path(db, link, ns),
209 ModuleDef::Static(it) => it.resolve_doc_path(db, link, ns),
210 ModuleDef::Trait(it) => it.resolve_doc_path(db, link, ns),
211 ModuleDef::TypeAlias(it) => it.resolve_doc_path(db, link, ns),
212 ModuleDef::BuiltinType(_) => return None,
214 Definition::Macro(it) => it.resolve_doc_path(db, link, ns),
215 Definition::Field(it) => it.resolve_doc_path(db, link, ns),
216 Definition::SelfType(_)
217 | Definition::Local(_)
218 | Definition::GenericParam(_)
219 | Definition::Label(_) => return None,
221 let krate = resolved.module(db)?.krate();
222 let canonical_path = resolved.canonical_path(db)?;
223 let mut new_url = get_doc_url(db, &krate)?
224 .join(&format!("{}/", krate.display_name(db)?))
226 .join(&canonical_path.replace("::", "/"))
228 .join(&get_symbol_filename(db, &resolved)?)
231 if let ModuleDef::Trait(t) = resolved {
232 let items = t.items(db);
233 if let Some(field_or_assoc_item) = items.iter().find_map(|assoc_item| {
234 if let Some(name) = assoc_item.name(db) {
235 if *link == format!("{}::{}", canonical_path, name) {
236 return Some(FieldOrAssocItem::AssocItem(*assoc_item));
241 if let Some(fragment) = get_symbol_fragment(db, &field_or_assoc_item) {
242 new_url = new_url.join(&fragment).ok()?;
247 let new_target = new_url.into_string();
248 let new_title = strip_prefixes_suffixes(title);
249 Some((new_target, new_title.to_string()))
252 /// Try to resolve path to local documentation via path-based links (i.e. `../gateway/struct.Shard.html`).
253 fn rewrite_url_link(db: &RootDatabase, def: ModuleDef, target: &str) -> Option<String> {
254 if !(target.contains('#') || target.contains(".html")) {
258 let module = def.module(db)?;
259 let krate = module.krate();
260 let canonical_path = def.canonical_path(db)?;
261 let base = format!("{}/{}", krate.display_name(db)?, canonical_path.replace("::", "/"));
263 get_doc_url(db, &krate)
264 .and_then(|url| url.join(&base).ok())
266 get_symbol_filename(db, &def).as_deref().map(|f| url.join(f).ok()).flatten()
268 .and_then(|url| url.join(target).ok())
269 .map(|url| url.into_string())
272 /// Retrieve a link to documentation for the given symbol.
273 pub(crate) fn external_docs(
275 position: &FilePosition,
276 ) -> Option<DocumentationLink> {
277 let sema = Semantics::new(db);
278 let file = sema.parse(position.file_id).syntax().clone();
279 let token = pick_best(file.token_at_offset(position.offset))?;
280 let token = sema.descend_into_macros(token);
282 let node = token.parent();
283 let definition = match_ast! {
285 ast::NameRef(name_ref) => NameRefClass::classify(&sema, &name_ref).map(|d| d.referenced(sema.db)),
286 ast::Name(name) => NameClass::classify(&sema, &name).map(|d| d.referenced_or_defined(sema.db)),
291 get_doc_link(db, definition?)
294 /// Rewrites a markdown document, applying 'callback' to each link.
296 events: impl Iterator<Item = Event<'e>>,
297 callback: impl Fn(&str, &str) -> (String, String),
298 ) -> impl Iterator<Item = Event<'e>> {
299 let mut in_link = false;
300 let mut link_target: Option<CowStr> = None;
302 events.map(move |evt| match evt {
303 Event::Start(Tag::Link(_link_type, ref target, _)) => {
305 link_target = Some(target.clone());
308 Event::End(Tag::Link(link_type, _target, _)) => {
310 Event::End(Tag::Link(link_type, link_target.take().unwrap(), CowStr::Borrowed("")))
312 Event::Text(s) if in_link => {
313 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
314 link_target = Some(CowStr::Boxed(link_target_s.into()));
315 Event::Text(CowStr::Boxed(link_name.into()))
317 Event::Code(s) if in_link => {
318 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
319 link_target = Some(CowStr::Boxed(link_target_s.into()));
320 Event::Code(CowStr::Boxed(link_name.into()))
326 fn parse_link(s: &str) -> (&str, Option<hir::Namespace>) {
327 let path = strip_prefixes_suffixes(s);
328 let ns = ns_from_intra_spec(s);
332 /// Strip prefixes, suffixes, and inline code marks from the given string.
333 fn strip_prefixes_suffixes(mut s: &str) -> &str {
334 s = s.trim_matches('`');
337 (TYPES.0.iter(), TYPES.1.iter()),
338 (VALUES.0.iter(), VALUES.1.iter()),
339 (MACROS.0.iter(), MACROS.1.iter()),
342 .for_each(|(prefixes, suffixes)| {
343 prefixes.clone().for_each(|prefix| s = s.trim_start_matches(*prefix));
344 suffixes.clone().for_each(|suffix| s = s.trim_end_matches(*suffix));
346 s.trim_start_matches('@').trim()
349 static TYPES: ([&str; 7], [&str; 0]) =
350 (["type", "struct", "enum", "mod", "trait", "union", "module"], []);
351 static VALUES: ([&str; 8], [&str; 1]) =
352 (["value", "function", "fn", "method", "const", "static", "mod", "module"], ["()"]);
353 static MACROS: ([&str; 1], [&str; 1]) = (["macro"], ["!"]);
355 /// Extract the specified namespace from an intra-doc-link if one exists.
359 /// * `struct MyStruct` -> `Namespace::Types`
360 /// * `panic!` -> `Namespace::Macros`
361 /// * `fn@from_intra_spec` -> `Namespace::Values`
362 fn ns_from_intra_spec(s: &str) -> Option<hir::Namespace> {
364 (hir::Namespace::Types, (TYPES.0.iter(), TYPES.1.iter())),
365 (hir::Namespace::Values, (VALUES.0.iter(), VALUES.1.iter())),
366 (hir::Namespace::Macros, (MACROS.0.iter(), MACROS.1.iter())),
369 .filter(|(_ns, (prefixes, suffixes))| {
373 s.starts_with(*prefix)
375 .nth(prefix.len() + 1)
376 .map(|c| c == '@' || c == ' ')
383 s.starts_with(*suffix)
385 .nth(suffix.len() + 1)
386 .map(|c| c == '@' || c == ' ')
391 .map(|(ns, (_, _))| *ns)
395 /// Get the root URL for the documentation of a crate.
398 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
399 /// ^^^^^^^^^^^^^^^^^^^^^^^^^^
401 fn get_doc_url(db: &RootDatabase, krate: &Crate) -> Option<Url> {
403 .get_html_root_url(db)
405 // Fallback to docs.rs. This uses `display_name` and can never be
406 // correct, but that's what fallbacks are about.
408 // FIXME: clicking on the link should just open the file in the editor,
409 // instead of falling back to external urls.
410 Some(format!("https://docs.rs/{}/*/", krate.display_name(db)?))
412 .and_then(|s| Url::parse(&s).ok())
415 /// Get the filename and extension generated for a symbol by rustdoc.
418 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
419 /// ^^^^^^^^^^^^^^^^^^^
421 fn get_symbol_filename(db: &dyn HirDatabase, definition: &ModuleDef) -> Option<String> {
422 Some(match definition {
423 ModuleDef::Adt(adt) => match adt {
424 Adt::Struct(s) => format!("struct.{}.html", s.name(db)),
425 Adt::Enum(e) => format!("enum.{}.html", e.name(db)),
426 Adt::Union(u) => format!("union.{}.html", u.name(db)),
428 ModuleDef::Module(_) => "index.html".to_string(),
429 ModuleDef::Trait(t) => format!("trait.{}.html", t.name(db)),
430 ModuleDef::TypeAlias(t) => format!("type.{}.html", t.name(db)),
431 ModuleDef::BuiltinType(t) => format!("primitive.{}.html", t.name()),
432 ModuleDef::Function(f) => format!("fn.{}.html", f.name(db)),
433 ModuleDef::Variant(ev) => {
434 format!("enum.{}.html#variant.{}", ev.parent_enum(db).name(db), ev.name(db))
436 ModuleDef::Const(c) => format!("const.{}.html", c.name(db)?),
437 ModuleDef::Static(s) => format!("static.{}.html", s.name(db)?),
441 enum FieldOrAssocItem {
443 AssocItem(AssocItem),
446 /// Get the fragment required to link to a specific field, method, associated type, or associated constant.
449 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
452 fn get_symbol_fragment(db: &dyn HirDatabase, field_or_assoc: &FieldOrAssocItem) -> Option<String> {
453 Some(match field_or_assoc {
454 FieldOrAssocItem::Field(field) => format!("#structfield.{}", field.name(db)),
455 FieldOrAssocItem::AssocItem(assoc) => match assoc {
456 AssocItem::Function(function) => {
457 let is_trait_method = function
459 .and_then(|assoc| assoc.containing_trait(db))
461 // This distinction may get more complicated when specialization is available.
462 // Rustdoc makes this decision based on whether a method 'has defaultness'.
463 // Currently this is only the case for provided trait methods.
464 if is_trait_method && !function.has_body(db) {
465 format!("#tymethod.{}", function.name(db))
467 format!("#method.{}", function.name(db))
470 AssocItem::Const(constant) => format!("#associatedconstant.{}", constant.name(db)?),
471 AssocItem::TypeAlias(ty) => format!("#associatedtype.{}", ty.name(db)),
476 fn pick_best(tokens: TokenAtOffset<SyntaxToken>) -> Option<SyntaxToken> {
477 return tokens.max_by_key(priority);
478 fn priority(n: &SyntaxToken) -> usize {
480 IDENT | INT_NUMBER => 3,
481 T!['('] | T![')'] => 2,
482 kind if kind.is_trivia() => 0,
490 use expect_test::{expect, Expect};
494 fn check(ra_fixture: &str, expect: Expect) {
495 let (analysis, position) = fixture::position(ra_fixture);
496 let url = analysis.external_docs(position).unwrap().expect("could not find url for symbol");
498 expect.assert_eq(&url)
502 fn test_doc_url_struct() {
507 expect![[r#"https://docs.rs/test/*/test/struct.Foo.html"#]],
512 fn test_doc_url_fn() {
517 expect![[r##"https://docs.rs/test/*/test/fn.foo.html#method.foo"##]],
522 fn test_doc_url_inherent_method() {
532 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#method.method"##]],
537 fn test_doc_url_trait_provided_method() {
545 expect![[r##"https://docs.rs/test/*/test/trait.Bar.html#method.method"##]],
550 fn test_doc_url_trait_required_method() {
558 expect![[r##"https://docs.rs/test/*/test/trait.Foo.html#tymethod.method"##]],
563 fn test_doc_url_field() {
571 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#structfield.field"##]],
583 expect![[r#"https://docs.rs/test/*/test/foo/bar/index.html"#]],
587 // FIXME: ImportMap will return re-export paths instead of public module
588 // paths. The correct path to documentation will never be a re-export.
589 // This problem stops us from resolving stdlib items included in the prelude
590 // such as `Option::Some` correctly.
591 #[ignore = "ImportMap may return re-exports"]
593 fn test_reexport_order() {
597 pub use module::Item;
605 let bar: wrapper::It$0em;
608 expect![[r#"https://docs.rs/test/*/test/wrapper/module/struct.Item.html"#]],