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, AsName, AssocItem, AssocItemContainer, Crate, Field, HasAttrs, ItemInNs,
16 defs::{Definition, NameClass, NameRefClass},
19 use syntax::{ast, match_ast, AstNode, SyntaxKind::*, SyntaxToken, TokenAtOffset, T};
21 use crate::{FilePosition, Semantics};
23 pub(crate) type DocumentationLink = String;
25 /// Rewrite documentation links in markdown to point to an online host (e.g. docs.rs)
26 pub(crate) fn rewrite_links(db: &RootDatabase, markdown: &str, definition: &Definition) -> String {
27 let mut cb = |link: BrokenLink| {
29 /*url*/ link.reference.to_owned().into(),
30 /*title*/ link.reference.to_owned().into(),
33 let doc = Parser::new_with_broken_link_callback(markdown, Options::empty(), Some(&mut cb));
35 let doc = map_links(doc, |target, title: &str| {
36 // This check is imperfect, there's some overlap between valid intra-doc links
37 // and valid URLs so we choose to be too eager to try to resolve what might be
39 if target.contains("://") {
40 (target.to_string(), title.to_string())
43 // * path-based links: `../../module/struct.MyStruct.html`
44 // * module-based links (AKA intra-doc links): `super::super::module::MyStruct`
45 if let Some(rewritten) = rewrite_intra_doc_link(db, *definition, target, title) {
48 if let Definition::ModuleDef(def) = *definition {
49 if let Some(target) = rewrite_url_link(db, def, target) {
50 return (target, title.to_string());
54 (target.to_string(), title.to_string())
57 let mut out = String::new();
58 let mut options = CmarkOptions::default();
59 options.code_block_backticks = 3;
60 cmark_with_options(doc, &mut out, None, options).ok();
64 pub(crate) fn extract_definitions_from_markdown(
66 ) -> Vec<(String, Option<hir::Namespace>, Range<usize>)> {
68 let mut cb = |link: BrokenLink| {
70 /*url*/ link.reference.to_owned().into(),
71 /*title*/ link.reference.to_owned().into(),
74 let doc = Parser::new_with_broken_link_callback(markdown, Options::empty(), Some(&mut cb));
75 for (event, range) in doc.into_offset_iter() {
77 Event::Start(Tag::Link(_link_type, ref target, ref title)) => {
78 let link = if target.is_empty() { title } else { target };
79 let (link, ns) = parse_link(link);
80 res.push((link.to_string(), ns, range));
88 /// Remove all links in markdown documentation.
89 pub(crate) fn remove_links(markdown: &str) -> String {
90 let mut drop_link = false;
92 let mut opts = Options::empty();
93 opts.insert(Options::ENABLE_FOOTNOTES);
95 let mut cb = |_: BrokenLink| {
96 let empty = InlineStr::try_from("").unwrap();
97 Some((CowStr::Inlined(empty.clone()), CowStr::Inlined(empty)))
99 let doc = Parser::new_with_broken_link_callback(markdown, opts, Some(&mut cb));
100 let doc = doc.filter_map(move |evt| match evt {
101 Event::Start(Tag::Link(link_type, ref target, ref title)) => {
102 if link_type == LinkType::Inline && target.contains("://") {
103 Some(Event::Start(Tag::Link(link_type, target.clone(), title.clone())))
109 Event::End(_) if drop_link => {
116 let mut out = String::new();
117 let mut options = CmarkOptions::default();
118 options.code_block_backticks = 3;
119 cmark_with_options(doc, &mut out, None, options).ok();
124 // BUG: For Option::Some
125 // Returns https://doc.rust-lang.org/nightly/core/prelude/v1/enum.Option.html#variant.Some
126 // Instead of https://doc.rust-lang.org/nightly/core/option/enum.Option.html
128 // This should cease to be a problem if RFC2988 (Stable Rustdoc URLs) is implemented
129 // https://github.com/rust-lang/rfcs/pull/2988
130 fn get_doc_link(db: &RootDatabase, definition: Definition) -> Option<String> {
131 // Get the outermost definition for the moduledef. This is used to resolve the public path to the type,
132 // then we can join the method, field, etc onto it if required.
133 let target_def: ModuleDef = match definition {
134 Definition::ModuleDef(moddef) => match moddef {
135 ModuleDef::Function(f) => f
137 .and_then(|assoc| match assoc.container(db) {
138 AssocItemContainer::Trait(t) => Some(t.into()),
139 AssocItemContainer::Impl(impld) => {
140 impld.target_ty(db).as_adt().map(|adt| adt.into())
143 .unwrap_or_else(|| f.clone().into()),
146 Definition::Field(f) => f.parent_def(db).into(),
147 // FIXME: Handle macros
151 let ns = ItemInNs::from(target_def.clone());
153 let module = definition.module(db)?;
154 let krate = module.krate();
155 let import_map = db.import_map(krate.into());
156 let base = once(krate.display_name(db)?.to_string())
157 .chain(import_map.path_of(ns)?.segments.iter().map(|name| name.to_string()))
161 let filename = get_symbol_filename(db, &target_def);
162 let fragment = match definition {
163 Definition::ModuleDef(moddef) => match moddef {
164 ModuleDef::Function(f) => {
165 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Function(f)))
167 ModuleDef::Const(c) => {
168 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Const(c)))
170 ModuleDef::TypeAlias(ty) => {
171 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::TypeAlias(ty)))
175 Definition::Field(field) => get_symbol_fragment(db, &FieldOrAssocItem::Field(field)),
179 get_doc_url(db, &krate)?
182 .and_then(|mut url| {
183 if !matches!(definition, Definition::ModuleDef(ModuleDef::Module(..))) {
184 url.path_segments_mut().ok()?.pop();
188 .and_then(|url| url.join(filename.as_deref()?).ok())
190 |url| if let Some(fragment) = fragment { url.join(&fragment).ok() } else { Some(url) },
192 .map(|url| url.into_string())
195 fn rewrite_intra_doc_link(
200 ) -> Option<(String, String)> {
201 let link = if target.is_empty() { title } else { target };
202 let (link, ns) = parse_link(link);
203 let resolved = match def {
204 Definition::ModuleDef(def) => match def {
205 ModuleDef::Module(it) => it.resolve_doc_path(db, link, ns),
206 ModuleDef::Function(it) => it.resolve_doc_path(db, link, ns),
207 ModuleDef::Adt(it) => it.resolve_doc_path(db, link, ns),
208 ModuleDef::Variant(it) => it.resolve_doc_path(db, link, ns),
209 ModuleDef::Const(it) => it.resolve_doc_path(db, link, ns),
210 ModuleDef::Static(it) => it.resolve_doc_path(db, link, ns),
211 ModuleDef::Trait(it) => it.resolve_doc_path(db, link, ns),
212 ModuleDef::TypeAlias(it) => it.resolve_doc_path(db, link, ns),
213 ModuleDef::BuiltinType(_) => return None,
215 Definition::Macro(it) => it.resolve_doc_path(db, link, ns),
216 Definition::Field(it) => it.resolve_doc_path(db, link, ns),
217 Definition::SelfType(_)
218 | Definition::Local(_)
219 | Definition::GenericParam(_)
220 | Definition::Label(_) => return None,
222 let krate = resolved.module(db)?.krate();
223 let canonical_path = resolved.canonical_path(db)?;
224 let new_target = get_doc_url(db, &krate)?
225 .join(&format!("{}/", krate.display_name(db)?))
227 .join(&canonical_path.replace("::", "/"))
229 .join(&get_symbol_filename(db, &resolved)?)
232 let new_title = strip_prefixes_suffixes(title);
233 Some((new_target, new_title.to_string()))
236 /// Try to resolve path to local documentation via path-based links (i.e. `../gateway/struct.Shard.html`).
237 fn rewrite_url_link(db: &RootDatabase, def: ModuleDef, target: &str) -> Option<String> {
238 if !(target.contains('#') || target.contains(".html")) {
242 let module = def.module(db)?;
243 let krate = module.krate();
244 let canonical_path = def.canonical_path(db)?;
245 let base = format!("{}/{}", krate.display_name(db)?, canonical_path.replace("::", "/"));
247 get_doc_url(db, &krate)
248 .and_then(|url| url.join(&base).ok())
250 get_symbol_filename(db, &def).as_deref().map(|f| url.join(f).ok()).flatten()
252 .and_then(|url| url.join(target).ok())
253 .map(|url| url.into_string())
256 /// Retrieve a link to documentation for the given symbol.
257 pub(crate) fn external_docs(
259 position: &FilePosition,
260 ) -> Option<DocumentationLink> {
261 let sema = Semantics::new(db);
262 let file = sema.parse(position.file_id).syntax().clone();
263 let token = pick_best(file.token_at_offset(position.offset))?;
264 let token = sema.descend_into_macros(token);
266 let node = token.parent();
267 let definition = match_ast! {
269 ast::NameRef(name_ref) => NameRefClass::classify(&sema, &name_ref).map(|d| d.referenced(sema.db)),
270 ast::Name(name) => NameClass::classify(&sema, &name).map(|d| d.referenced_or_defined(sema.db)),
275 get_doc_link(db, definition?)
278 /// Rewrites a markdown document, applying 'callback' to each link.
280 events: impl Iterator<Item = Event<'e>>,
281 callback: impl Fn(&str, &str) -> (String, String),
282 ) -> impl Iterator<Item = Event<'e>> {
283 let mut in_link = false;
284 let mut link_target: Option<CowStr> = None;
286 events.map(move |evt| match evt {
287 Event::Start(Tag::Link(_link_type, ref target, _)) => {
289 link_target = Some(target.clone());
292 Event::End(Tag::Link(link_type, _target, _)) => {
294 Event::End(Tag::Link(link_type, link_target.take().unwrap(), CowStr::Borrowed("")))
296 Event::Text(s) if in_link => {
297 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
298 link_target = Some(CowStr::Boxed(link_target_s.into()));
299 Event::Text(CowStr::Boxed(link_name.into()))
301 Event::Code(s) if in_link => {
302 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
303 link_target = Some(CowStr::Boxed(link_target_s.into()));
304 Event::Code(CowStr::Boxed(link_name.into()))
310 fn parse_link(s: &str) -> (&str, Option<hir::Namespace>) {
311 let path = strip_prefixes_suffixes(s);
312 let ns = ns_from_intra_spec(s);
316 /// Strip prefixes, suffixes, and inline code marks from the given string.
317 fn strip_prefixes_suffixes(mut s: &str) -> &str {
318 s = s.trim_matches('`');
321 (TYPES.0.iter(), TYPES.1.iter()),
322 (VALUES.0.iter(), VALUES.1.iter()),
323 (MACROS.0.iter(), MACROS.1.iter()),
326 .for_each(|(prefixes, suffixes)| {
327 prefixes.clone().for_each(|prefix| s = s.trim_start_matches(*prefix));
328 suffixes.clone().for_each(|suffix| s = s.trim_end_matches(*suffix));
330 s.trim_start_matches('@').trim()
333 static TYPES: ([&str; 7], [&str; 0]) =
334 (["type", "struct", "enum", "mod", "trait", "union", "module"], []);
335 static VALUES: ([&str; 8], [&str; 1]) =
336 (["value", "function", "fn", "method", "const", "static", "mod", "module"], ["()"]);
337 static MACROS: ([&str; 1], [&str; 1]) = (["macro"], ["!"]);
339 /// Extract the specified namespace from an intra-doc-link if one exists.
343 /// * `struct MyStruct` -> `Namespace::Types`
344 /// * `panic!` -> `Namespace::Macros`
345 /// * `fn@from_intra_spec` -> `Namespace::Values`
346 fn ns_from_intra_spec(s: &str) -> Option<hir::Namespace> {
348 (hir::Namespace::Types, (TYPES.0.iter(), TYPES.1.iter())),
349 (hir::Namespace::Values, (VALUES.0.iter(), VALUES.1.iter())),
350 (hir::Namespace::Macros, (MACROS.0.iter(), MACROS.1.iter())),
353 .filter(|(_ns, (prefixes, suffixes))| {
357 s.starts_with(*prefix)
359 .nth(prefix.len() + 1)
360 .map(|c| c == '@' || c == ' ')
367 s.starts_with(*suffix)
369 .nth(suffix.len() + 1)
370 .map(|c| c == '@' || c == ' ')
375 .map(|(ns, (_, _))| *ns)
379 /// Get the root URL for the documentation of a crate.
382 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
383 /// ^^^^^^^^^^^^^^^^^^^^^^^^^^
385 fn get_doc_url(db: &RootDatabase, krate: &Crate) -> Option<Url> {
387 .get_html_root_url(db)
389 // Fallback to docs.rs. This uses `display_name` and can never be
390 // correct, but that's what fallbacks are about.
392 // FIXME: clicking on the link should just open the file in the editor,
393 // instead of falling back to external urls.
394 Some(format!("https://docs.rs/{}/*/", krate.display_name(db)?))
396 .and_then(|s| Url::parse(&s).ok())
399 /// Get the filename and extension generated for a symbol by rustdoc.
402 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
403 /// ^^^^^^^^^^^^^^^^^^^
405 fn get_symbol_filename(db: &dyn HirDatabase, definition: &ModuleDef) -> Option<String> {
406 Some(match definition {
407 ModuleDef::Adt(adt) => match adt {
408 Adt::Struct(s) => format!("struct.{}.html", s.name(db)),
409 Adt::Enum(e) => format!("enum.{}.html", e.name(db)),
410 Adt::Union(u) => format!("union.{}.html", u.name(db)),
412 ModuleDef::Module(_) => "index.html".to_string(),
413 ModuleDef::Trait(t) => format!("trait.{}.html", t.name(db)),
414 ModuleDef::TypeAlias(t) => format!("type.{}.html", t.name(db)),
415 ModuleDef::BuiltinType(t) => format!("primitive.{}.html", t.as_name()),
416 ModuleDef::Function(f) => format!("fn.{}.html", f.name(db)),
417 ModuleDef::Variant(ev) => {
418 format!("enum.{}.html#variant.{}", ev.parent_enum(db).name(db), ev.name(db))
420 ModuleDef::Const(c) => format!("const.{}.html", c.name(db)?),
421 ModuleDef::Static(s) => format!("static.{}.html", s.name(db)?),
425 enum FieldOrAssocItem {
427 AssocItem(AssocItem),
430 /// Get the fragment required to link to a specific field, method, associated type, or associated constant.
433 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
436 fn get_symbol_fragment(db: &dyn HirDatabase, field_or_assoc: &FieldOrAssocItem) -> Option<String> {
437 Some(match field_or_assoc {
438 FieldOrAssocItem::Field(field) => format!("#structfield.{}", field.name(db)),
439 FieldOrAssocItem::AssocItem(assoc) => match assoc {
440 AssocItem::Function(function) => {
441 let is_trait_method = function
443 .and_then(|assoc| assoc.containing_trait(db))
445 // This distinction may get more complicated when specialization is available.
446 // Rustdoc makes this decision based on whether a method 'has defaultness'.
447 // Currently this is only the case for provided trait methods.
448 if is_trait_method && !function.has_body(db) {
449 format!("#tymethod.{}", function.name(db))
451 format!("#method.{}", function.name(db))
454 AssocItem::Const(constant) => format!("#associatedconstant.{}", constant.name(db)?),
455 AssocItem::TypeAlias(ty) => format!("#associatedtype.{}", ty.name(db)),
460 fn pick_best(tokens: TokenAtOffset<SyntaxToken>) -> Option<SyntaxToken> {
461 return tokens.max_by_key(priority);
462 fn priority(n: &SyntaxToken) -> usize {
464 IDENT | INT_NUMBER => 3,
465 T!['('] | T![')'] => 2,
466 kind if kind.is_trivia() => 0,
474 use expect_test::{expect, Expect};
478 fn check(ra_fixture: &str, expect: Expect) {
479 let (analysis, position) = fixture::position(ra_fixture);
480 let url = analysis.external_docs(position).unwrap().expect("could not find url for symbol");
482 expect.assert_eq(&url)
486 fn test_doc_url_struct() {
491 expect![[r#"https://docs.rs/test/*/test/struct.Foo.html"#]],
496 fn test_doc_url_fn() {
501 expect![[r##"https://docs.rs/test/*/test/fn.foo.html#method.foo"##]],
506 fn test_doc_url_inherent_method() {
516 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#method.method"##]],
521 fn test_doc_url_trait_provided_method() {
529 expect![[r##"https://docs.rs/test/*/test/trait.Bar.html#method.method"##]],
534 fn test_doc_url_trait_required_method() {
542 expect![[r##"https://docs.rs/test/*/test/trait.Foo.html#tymethod.method"##]],
547 fn test_doc_url_field() {
555 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#structfield.field"##]],
567 expect![[r#"https://docs.rs/test/*/test/foo/bar/index.html"#]],
571 // FIXME: ImportMap will return re-export paths instead of public module
572 // paths. The correct path to documentation will never be a re-export.
573 // This problem stops us from resolving stdlib items included in the prelude
574 // such as `Option::Some` correctly.
575 #[ignore = "ImportMap may return re-exports"]
577 fn test_reexport_order() {
581 pub use module::Item;
589 let bar: wrapper::It$0em;
592 expect![[r#"https://docs.rs/test/*/test/wrapper/module/struct.Item.html"#]],