aboutsummaryrefslogtreecommitdiff
path: root/crates/ide/src/doc_links.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/ide/src/doc_links.rs')
-rw-r--r--crates/ide/src/doc_links.rs546
1 files changed, 546 insertions, 0 deletions
diff --git a/crates/ide/src/doc_links.rs b/crates/ide/src/doc_links.rs
new file mode 100644
index 000000000..06af36b73
--- /dev/null
+++ b/crates/ide/src/doc_links.rs
@@ -0,0 +1,546 @@
1//! Resolves and rewrites links in markdown documentation.
2
3use std::iter::once;
4
5use itertools::Itertools;
6use pulldown_cmark::{CowStr, Event, LinkType, Options, Parser, Tag};
7use pulldown_cmark_to_cmark::{cmark_with_options, Options as CmarkOptions};
8use url::Url;
9
10use hir::{
11 db::{DefDatabase, HirDatabase},
12 Adt, AsAssocItem, AsName, AssocItem, AssocItemContainer, Crate, Field, HasAttrs, ItemInNs,
13 ModuleDef,
14};
15use ide_db::{
16 defs::{classify_name, classify_name_ref, Definition},
17 RootDatabase,
18};
19use syntax::{ast, match_ast, AstNode, SyntaxKind::*, SyntaxToken, TokenAtOffset, T};
20
21use crate::{FilePosition, Semantics};
22
23pub type DocumentationLink = String;
24
25/// Rewrite documentation links in markdown to point to an online host (e.g. docs.rs)
26pub fn rewrite_links(db: &RootDatabase, markdown: &str, definition: &Definition) -> String {
27 let doc = Parser::new_with_broken_link_callback(
28 markdown,
29 Options::empty(),
30 Some(&|label, _| Some((/*url*/ label.to_string(), /*title*/ label.to_string()))),
31 );
32
33 let doc = map_links(doc, |target, title: &str| {
34 // This check is imperfect, there's some overlap between valid intra-doc links
35 // and valid URLs so we choose to be too eager to try to resolve what might be
36 // a URL.
37 if target.contains("://") {
38 (target.to_string(), title.to_string())
39 } else {
40 // Two posibilities:
41 // * path-based links: `../../module/struct.MyStruct.html`
42 // * module-based links (AKA intra-doc links): `super::super::module::MyStruct`
43 if let Some(rewritten) = rewrite_intra_doc_link(db, *definition, target, title) {
44 return rewritten;
45 }
46 if let Definition::ModuleDef(def) = *definition {
47 if let Some(target) = rewrite_url_link(db, def, target) {
48 return (target, title.to_string());
49 }
50 }
51
52 (target.to_string(), title.to_string())
53 }
54 });
55 let mut out = String::new();
56 let mut options = CmarkOptions::default();
57 options.code_block_backticks = 3;
58 cmark_with_options(doc, &mut out, None, options).ok();
59 out
60}
61
62/// Remove all links in markdown documentation.
63pub fn remove_links(markdown: &str) -> String {
64 let mut drop_link = false;
65
66 let mut opts = Options::empty();
67 opts.insert(Options::ENABLE_FOOTNOTES);
68
69 let doc = Parser::new_with_broken_link_callback(
70 markdown,
71 opts,
72 Some(&|_, _| Some((String::new(), String::new()))),
73 );
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())))
78 } else {
79 drop_link = true;
80 None
81 }
82 }
83 Event::End(_) if drop_link => {
84 drop_link = false;
85 None
86 }
87 _ => Some(evt),
88 });
89
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();
94 out
95}
96
97// FIXME:
98// BUG: For Option::Some
99// Returns https://doc.rust-lang.org/nightly/core/prelude/v1/enum.Option.html#variant.Some
100// Instead of https://doc.rust-lang.org/nightly/core/option/enum.Option.html
101//
102// This should cease to be a problem if RFC2988 (Stable Rustdoc URLs) is implemented
103// https://github.com/rust-lang/rfcs/pull/2988
104fn get_doc_link(db: &RootDatabase, definition: Definition) -> Option<String> {
105 // Get the outermost definition for the moduledef. This is used to resolve the public path to the type,
106 // then we can join the method, field, etc onto it if required.
107 let target_def: ModuleDef = match definition {
108 Definition::ModuleDef(moddef) => match moddef {
109 ModuleDef::Function(f) => f
110 .as_assoc_item(db)
111 .and_then(|assoc| match assoc.container(db) {
112 AssocItemContainer::Trait(t) => Some(t.into()),
113 AssocItemContainer::ImplDef(impld) => {
114 impld.target_ty(db).as_adt().map(|adt| adt.into())
115 }
116 })
117 .unwrap_or_else(|| f.clone().into()),
118 moddef => moddef,
119 },
120 Definition::Field(f) => f.parent_def(db).into(),
121 // FIXME: Handle macros
122 _ => return None,
123 };
124
125 let ns = ItemInNs::from(target_def.clone());
126
127 let module = definition.module(db)?;
128 let krate = module.krate();
129 let import_map = db.import_map(krate.into());
130 let base = once(krate.declaration_name(db)?.to_string())
131 .chain(import_map.path_of(ns)?.segments.iter().map(|name| name.to_string()))
132 .join("/");
133
134 let filename = get_symbol_filename(db, &target_def);
135 let fragment = match definition {
136 Definition::ModuleDef(moddef) => match moddef {
137 ModuleDef::Function(f) => {
138 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Function(f)))
139 }
140 ModuleDef::Const(c) => {
141 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::Const(c)))
142 }
143 ModuleDef::TypeAlias(ty) => {
144 get_symbol_fragment(db, &FieldOrAssocItem::AssocItem(AssocItem::TypeAlias(ty)))
145 }
146 _ => None,
147 },
148 Definition::Field(field) => get_symbol_fragment(db, &FieldOrAssocItem::Field(field)),
149 _ => None,
150 };
151
152 get_doc_url(db, &krate)
153 .and_then(|url| url.join(&base).ok())
154 .and_then(|url| filename.as_deref().and_then(|f| url.join(f).ok()))
155 .and_then(
156 |url| if let Some(fragment) = fragment { url.join(&fragment).ok() } else { Some(url) },
157 )
158 .map(|url| url.into_string())
159}
160
161fn rewrite_intra_doc_link(
162 db: &RootDatabase,
163 def: Definition,
164 target: &str,
165 title: &str,
166) -> Option<(String, String)> {
167 let link = if target.is_empty() { title } else { target };
168 let (link, ns) = parse_link(link);
169 let resolved = match def {
170 Definition::ModuleDef(def) => match def {
171 ModuleDef::Module(it) => it.resolve_doc_path(db, link, ns),
172 ModuleDef::Function(it) => it.resolve_doc_path(db, link, ns),
173 ModuleDef::Adt(it) => it.resolve_doc_path(db, link, ns),
174 ModuleDef::EnumVariant(it) => it.resolve_doc_path(db, link, ns),
175 ModuleDef::Const(it) => it.resolve_doc_path(db, link, ns),
176 ModuleDef::Static(it) => it.resolve_doc_path(db, link, ns),
177 ModuleDef::Trait(it) => it.resolve_doc_path(db, link, ns),
178 ModuleDef::TypeAlias(it) => it.resolve_doc_path(db, link, ns),
179 ModuleDef::BuiltinType(_) => return None,
180 },
181 Definition::Macro(it) => it.resolve_doc_path(db, link, ns),
182 Definition::Field(it) => it.resolve_doc_path(db, link, ns),
183 Definition::SelfType(_) | Definition::Local(_) | Definition::TypeParam(_) => return None,
184 }?;
185 let krate = resolved.module(db)?.krate();
186 let canonical_path = resolved.canonical_path(db)?;
187 let new_target = get_doc_url(db, &krate)?
188 .join(&format!("{}/", krate.declaration_name(db)?))
189 .ok()?
190 .join(&canonical_path.replace("::", "/"))
191 .ok()?
192 .join(&get_symbol_filename(db, &resolved)?)
193 .ok()?
194 .into_string();
195 let new_title = strip_prefixes_suffixes(title);
196 Some((new_target, new_title.to_string()))
197}
198
199/// Try to resolve path to local documentation via path-based links (i.e. `../gateway/struct.Shard.html`).
200fn rewrite_url_link(db: &RootDatabase, def: ModuleDef, target: &str) -> Option<String> {
201 if !(target.contains('#') || target.contains(".html")) {
202 return None;
203 }
204
205 let module = def.module(db)?;
206 let krate = module.krate();
207 let canonical_path = def.canonical_path(db)?;
208 let base = format!("{}/{}", krate.declaration_name(db)?, canonical_path.replace("::", "/"));
209
210 get_doc_url(db, &krate)
211 .and_then(|url| url.join(&base).ok())
212 .and_then(|url| {
213 get_symbol_filename(db, &def).as_deref().map(|f| url.join(f).ok()).flatten()
214 })
215 .and_then(|url| url.join(target).ok())
216 .map(|url| url.into_string())
217}
218
219/// Retrieve a link to documentation for the given symbol.
220pub(crate) fn external_docs(
221 db: &RootDatabase,
222 position: &FilePosition,
223) -> Option<DocumentationLink> {
224 let sema = Semantics::new(db);
225 let file = sema.parse(position.file_id).syntax().clone();
226 let token = pick_best(file.token_at_offset(position.offset))?;
227 let token = sema.descend_into_macros(token);
228
229 let node = token.parent();
230 let definition = match_ast! {
231 match node {
232 ast::NameRef(name_ref) => classify_name_ref(&sema, &name_ref).map(|d| d.definition(sema.db)),
233 ast::Name(name) => classify_name(&sema, &name).map(|d| d.definition(sema.db)),
234 _ => None,
235 }
236 };
237
238 get_doc_link(db, definition?)
239}
240
241/// Rewrites a markdown document, applying 'callback' to each link.
242fn map_links<'e>(
243 events: impl Iterator<Item = Event<'e>>,
244 callback: impl Fn(&str, &str) -> (String, String),
245) -> impl Iterator<Item = Event<'e>> {
246 let mut in_link = false;
247 let mut link_target: Option<CowStr> = None;
248
249 events.map(move |evt| match evt {
250 Event::Start(Tag::Link(_link_type, ref target, _)) => {
251 in_link = true;
252 link_target = Some(target.clone());
253 evt
254 }
255 Event::End(Tag::Link(link_type, _target, _)) => {
256 in_link = false;
257 Event::End(Tag::Link(link_type, link_target.take().unwrap(), CowStr::Borrowed("")))
258 }
259 Event::Text(s) if in_link => {
260 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
261 link_target = Some(CowStr::Boxed(link_target_s.into()));
262 Event::Text(CowStr::Boxed(link_name.into()))
263 }
264 Event::Code(s) if in_link => {
265 let (link_target_s, link_name) = callback(&link_target.take().unwrap(), &s);
266 link_target = Some(CowStr::Boxed(link_target_s.into()));
267 Event::Code(CowStr::Boxed(link_name.into()))
268 }
269 _ => evt,
270 })
271}
272
273fn parse_link(s: &str) -> (&str, Option<hir::Namespace>) {
274 let path = strip_prefixes_suffixes(s);
275 let ns = ns_from_intra_spec(s);
276 (path, ns)
277}
278
279/// Strip prefixes, suffixes, and inline code marks from the given string.
280fn strip_prefixes_suffixes(mut s: &str) -> &str {
281 s = s.trim_matches('`');
282
283 [
284 (TYPES.0.iter(), TYPES.1.iter()),
285 (VALUES.0.iter(), VALUES.1.iter()),
286 (MACROS.0.iter(), MACROS.1.iter()),
287 ]
288 .iter()
289 .for_each(|(prefixes, suffixes)| {
290 prefixes.clone().for_each(|prefix| s = s.trim_start_matches(*prefix));
291 suffixes.clone().for_each(|suffix| s = s.trim_end_matches(*suffix));
292 });
293 s.trim_start_matches('@').trim()
294}
295
296static TYPES: ([&str; 7], [&str; 0]) =
297 (["type", "struct", "enum", "mod", "trait", "union", "module"], []);
298static VALUES: ([&str; 8], [&str; 1]) =
299 (["value", "function", "fn", "method", "const", "static", "mod", "module"], ["()"]);
300static MACROS: ([&str; 1], [&str; 1]) = (["macro"], ["!"]);
301
302/// Extract the specified namespace from an intra-doc-link if one exists.
303///
304/// # Examples
305///
306/// * `struct MyStruct` -> `Namespace::Types`
307/// * `panic!` -> `Namespace::Macros`
308/// * `fn@from_intra_spec` -> `Namespace::Values`
309fn ns_from_intra_spec(s: &str) -> Option<hir::Namespace> {
310 [
311 (hir::Namespace::Types, (TYPES.0.iter(), TYPES.1.iter())),
312 (hir::Namespace::Values, (VALUES.0.iter(), VALUES.1.iter())),
313 (hir::Namespace::Macros, (MACROS.0.iter(), MACROS.1.iter())),
314 ]
315 .iter()
316 .filter(|(_ns, (prefixes, suffixes))| {
317 prefixes
318 .clone()
319 .map(|prefix| {
320 s.starts_with(*prefix)
321 && s.chars()
322 .nth(prefix.len() + 1)
323 .map(|c| c == '@' || c == ' ')
324 .unwrap_or(false)
325 })
326 .any(|cond| cond)
327 || suffixes
328 .clone()
329 .map(|suffix| {
330 s.starts_with(*suffix)
331 && s.chars()
332 .nth(suffix.len() + 1)
333 .map(|c| c == '@' || c == ' ')
334 .unwrap_or(false)
335 })
336 .any(|cond| cond)
337 })
338 .map(|(ns, (_, _))| *ns)
339 .next()
340}
341
342/// Get the root URL for the documentation of a crate.
343///
344/// ```
345/// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
346/// ^^^^^^^^^^^^^^^^^^^^^^^^^^
347/// ```
348fn get_doc_url(db: &RootDatabase, krate: &Crate) -> Option<Url> {
349 krate
350 .get_html_root_url(db)
351 .or_else(|| {
352 // Fallback to docs.rs. This uses `display_name` and can never be
353 // correct, but that's what fallbacks are about.
354 //
355 // FIXME: clicking on the link should just open the file in the editor,
356 // instead of falling back to external urls.
357 Some(format!("https://docs.rs/{}/*/", krate.declaration_name(db)?))
358 })
359 .and_then(|s| Url::parse(&s).ok())
360}
361
362/// Get the filename and extension generated for a symbol by rustdoc.
363///
364/// ```
365/// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
366/// ^^^^^^^^^^^^^^^^^^^
367/// ```
368fn get_symbol_filename(db: &dyn HirDatabase, definition: &ModuleDef) -> Option<String> {
369 Some(match definition {
370 ModuleDef::Adt(adt) => match adt {
371 Adt::Struct(s) => format!("struct.{}.html", s.name(db)),
372 Adt::Enum(e) => format!("enum.{}.html", e.name(db)),
373 Adt::Union(u) => format!("union.{}.html", u.name(db)),
374 },
375 ModuleDef::Module(_) => "index.html".to_string(),
376 ModuleDef::Trait(t) => format!("trait.{}.html", t.name(db)),
377 ModuleDef::TypeAlias(t) => format!("type.{}.html", t.name(db)),
378 ModuleDef::BuiltinType(t) => format!("primitive.{}.html", t.as_name()),
379 ModuleDef::Function(f) => format!("fn.{}.html", f.name(db)),
380 ModuleDef::EnumVariant(ev) => {
381 format!("enum.{}.html#variant.{}", ev.parent_enum(db).name(db), ev.name(db))
382 }
383 ModuleDef::Const(c) => format!("const.{}.html", c.name(db)?),
384 ModuleDef::Static(s) => format!("static.{}.html", s.name(db)?),
385 })
386}
387
388enum FieldOrAssocItem {
389 Field(Field),
390 AssocItem(AssocItem),
391}
392
393/// Get the fragment required to link to a specific field, method, associated type, or associated constant.
394///
395/// ```
396/// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
397/// ^^^^^^^^^^^^^^
398/// ```
399fn get_symbol_fragment(db: &dyn HirDatabase, field_or_assoc: &FieldOrAssocItem) -> Option<String> {
400 Some(match field_or_assoc {
401 FieldOrAssocItem::Field(field) => format!("#structfield.{}", field.name(db)),
402 FieldOrAssocItem::AssocItem(assoc) => match assoc {
403 AssocItem::Function(function) => {
404 let is_trait_method = matches!(
405 function.as_assoc_item(db).map(|assoc| assoc.container(db)),
406 Some(AssocItemContainer::Trait(..))
407 );
408 // This distinction may get more complicated when specialisation is available.
409 // Rustdoc makes this decision based on whether a method 'has defaultness'.
410 // Currently this is only the case for provided trait methods.
411 if is_trait_method && !function.has_body(db) {
412 format!("#tymethod.{}", function.name(db))
413 } else {
414 format!("#method.{}", function.name(db))
415 }
416 }
417 AssocItem::Const(constant) => format!("#associatedconstant.{}", constant.name(db)?),
418 AssocItem::TypeAlias(ty) => format!("#associatedtype.{}", ty.name(db)),
419 },
420 })
421}
422
423fn pick_best(tokens: TokenAtOffset<SyntaxToken>) -> Option<SyntaxToken> {
424 return tokens.max_by_key(priority);
425 fn priority(n: &SyntaxToken) -> usize {
426 match n.kind() {
427 IDENT | INT_NUMBER => 3,
428 T!['('] | T![')'] => 2,
429 kind if kind.is_trivia() => 0,
430 _ => 1,
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use expect_test::{expect, Expect};
438
439 use crate::fixture;
440
441 fn check(ra_fixture: &str, expect: Expect) {
442 let (analysis, position) = fixture::position(ra_fixture);
443 let url = analysis.external_docs(position).unwrap().expect("could not find url for symbol");
444
445 expect.assert_eq(&url)
446 }
447
448 #[test]
449 fn test_doc_url_struct() {
450 check(
451 r#"
452pub struct Fo<|>o;
453"#,
454 expect![[r#"https://docs.rs/test/*/test/struct.Foo.html"#]],
455 );
456 }
457
458 #[test]
459 fn test_doc_url_fn() {
460 check(
461 r#"
462pub fn fo<|>o() {}
463"#,
464 expect![[r##"https://docs.rs/test/*/test/fn.foo.html#method.foo"##]],
465 );
466 }
467
468 #[test]
469 fn test_doc_url_inherent_method() {
470 check(
471 r#"
472pub struct Foo;
473
474impl Foo {
475 pub fn met<|>hod() {}
476}
477
478"#,
479 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#method.method"##]],
480 );
481 }
482
483 #[test]
484 fn test_doc_url_trait_provided_method() {
485 check(
486 r#"
487pub trait Bar {
488 fn met<|>hod() {}
489}
490
491"#,
492 expect![[r##"https://docs.rs/test/*/test/trait.Bar.html#method.method"##]],
493 );
494 }
495
496 #[test]
497 fn test_doc_url_trait_required_method() {
498 check(
499 r#"
500pub trait Foo {
501 fn met<|>hod();
502}
503
504"#,
505 expect![[r##"https://docs.rs/test/*/test/trait.Foo.html#tymethod.method"##]],
506 );
507 }
508
509 #[test]
510 fn test_doc_url_field() {
511 check(
512 r#"
513pub struct Foo {
514 pub fie<|>ld: ()
515}
516
517"#,
518 expect![[r##"https://docs.rs/test/*/test/struct.Foo.html#structfield.field"##]],
519 );
520 }
521
522 // FIXME: ImportMap will return re-export paths instead of public module
523 // paths. The correct path to documentation will never be a re-export.
524 // This problem stops us from resolving stdlib items included in the prelude
525 // such as `Option::Some` correctly.
526 #[ignore = "ImportMap may return re-exports"]
527 #[test]
528 fn test_reexport_order() {
529 check(
530 r#"
531pub mod wrapper {
532 pub use module::Item;
533
534 pub mod module {
535 pub struct Item;
536 }
537}
538
539fn foo() {
540 let bar: wrapper::It<|>em;
541}
542 "#,
543 expect![[r#"https://docs.rs/test/*/test/wrapper/module/struct.Item.html"#]],
544 )
545 }
546}