about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--crates/ide/src/doc_links.rs112
-rw-r--r--crates/ide/src/doc_links/tests.rs152
-rw-r--r--crates/ide/src/lib.rs15
-rw-r--r--crates/project-model/src/cargo_workspace.rs10
-rw-r--r--crates/rust-analyzer/src/config.rs4
-rw-r--r--crates/rust-analyzer/src/handlers/request.rs37
-rw-r--r--crates/rust-analyzer/src/lsp_ext.rs16
-rw-r--r--docs/dev/lsp-extensions.md26
8 files changed, 307 insertions, 65 deletions
diff --git a/crates/ide/src/doc_links.rs b/crates/ide/src/doc_links.rs
index 8d86c615d44..597b28d36d7 100644
--- a/crates/ide/src/doc_links.rs
+++ b/crates/ide/src/doc_links.rs
@@ -5,6 +5,8 @@ mod tests;
 
 mod intra_doc_links;
 
+use std::ffi::OsStr;
+
 use pulldown_cmark::{BrokenLink, CowStr, Event, InlineStr, LinkType, Options, Parser, Tag};
 use pulldown_cmark_to_cmark::{cmark_resume_with_options, Options as CMarkOptions};
 use stdx::format_to;
@@ -29,8 +31,16 @@ use crate::{
     FilePosition, Semantics,
 };
 
-/// Weblink to an item's documentation.
-pub(crate) type DocumentationLink = String;
+/// Web and local links to an item's documentation.
+#[derive(Default, Debug, Clone, PartialEq, Eq)]
+pub struct DocumentationLinks {
+    /// The URL to the documentation on docs.rs.
+    /// May not lead anywhere.
+    pub web_url: Option<String>,
+    /// The URL to the documentation in the local file system.
+    /// May not lead anywhere.
+    pub local_url: Option<String>,
+}
 
 const MARKDOWN_OPTIONS: Options =
     Options::ENABLE_FOOTNOTES.union(Options::ENABLE_TABLES).union(Options::ENABLE_TASKLISTS);
@@ -109,7 +119,7 @@ pub(crate) fn remove_links(markdown: &str) -> String {
 
 // Feature: Open Docs
 //
-// Retrieve a link to documentation for the given symbol.
+// Retrieve a links to documentation for the given symbol.
 //
 // The simplest way to use this feature is via the context menu. Right-click on
 // the selected item. The context menu opens. Select **Open Docs**.
@@ -122,7 +132,9 @@ pub(crate) fn remove_links(markdown: &str) -> String {
 pub(crate) fn external_docs(
     db: &RootDatabase,
     position: &FilePosition,
-) -> Option<DocumentationLink> {
+    target_dir: Option<&OsStr>,
+    sysroot: Option<&OsStr>,
+) -> Option<DocumentationLinks> {
     let sema = &Semantics::new(db);
     let file = sema.parse(position.file_id).syntax().clone();
     let token = pick_best_token(file.token_at_offset(position.offset), |kind| match kind {
@@ -146,11 +158,11 @@ pub(crate) fn external_docs(
                 NameClass::Definition(it) | NameClass::ConstReference(it) => it,
                 NameClass::PatFieldShorthand { local_def: _, field_ref } => Definition::Field(field_ref),
             },
-            _ => return None,
+            _ => return None
         }
     };
 
-    get_doc_link(db, definition)
+    Some(get_doc_links(db, definition, target_dir, sysroot))
 }
 
 /// Extracts all links from a given markdown text returning the definition text range, link-text
@@ -308,19 +320,35 @@ fn broken_link_clone_cb(link: BrokenLink<'_>) -> Option<(CowStr<'_>, CowStr<'_>)
 //
 // This should cease to be a problem if RFC2988 (Stable Rustdoc URLs) is implemented
 // https://github.com/rust-lang/rfcs/pull/2988
-fn get_doc_link(db: &RootDatabase, def: Definition) -> Option<String> {
-    let (target, file, frag) = filename_and_frag_for_def(db, def)?;
+fn get_doc_links(
+    db: &RootDatabase,
+    def: Definition,
+    target_dir: Option<&OsStr>,
+    sysroot: Option<&OsStr>,
+) -> DocumentationLinks {
+    let join_url = |base_url: Option<Url>, path: &str| -> Option<Url> {
+        base_url.and_then(|url| url.join(path).ok())
+    };
+
+    let Some((target, file, frag)) = filename_and_frag_for_def(db, def) else { return Default::default(); };
 
-    let mut url = get_doc_base_url(db, target)?;
+    let (mut web_url, mut local_url) = get_doc_base_urls(db, target, target_dir, sysroot);
 
     if let Some(path) = mod_path_of_def(db, target) {
-        url = url.join(&path).ok()?;
+        web_url = join_url(web_url, &path);
+        local_url = join_url(local_url, &path);
     }
 
-    url = url.join(&file).ok()?;
-    url.set_fragment(frag.as_deref());
+    web_url = join_url(web_url, &file);
+    local_url = join_url(local_url, &file);
+
+    web_url.as_mut().map(|url| url.set_fragment(frag.as_deref()));
+    local_url.as_mut().map(|url| url.set_fragment(frag.as_deref()));
 
-    Some(url.into())
+    DocumentationLinks {
+        web_url: web_url.map(|it| it.into()),
+        local_url: local_url.map(|it| it.into()),
+    }
 }
 
 fn rewrite_intra_doc_link(
@@ -332,7 +360,7 @@ fn rewrite_intra_doc_link(
     let (link, ns) = parse_intra_doc_link(target);
 
     let resolved = resolve_doc_path_for_def(db, def, link, ns)?;
-    let mut url = get_doc_base_url(db, resolved)?;
+    let mut url = get_doc_base_urls(db, resolved, None, None).0?;
 
     let (_, file, frag) = filename_and_frag_for_def(db, resolved)?;
     if let Some(path) = mod_path_of_def(db, resolved) {
@@ -351,7 +379,7 @@ fn rewrite_url_link(db: &RootDatabase, def: Definition, target: &str) -> Option<
         return None;
     }
 
-    let mut url = get_doc_base_url(db, def)?;
+    let mut url = get_doc_base_urls(db, def, None, None).0?;
     let (def, file, frag) = filename_and_frag_for_def(db, def)?;
 
     if let Some(path) = mod_path_of_def(db, def) {
@@ -426,19 +454,38 @@ fn map_links<'e>(
 /// ```ignore
 /// https://doc.rust-lang.org/std/iter/trait.Iterator.html#tymethod.next
 /// ^^^^^^^^^^^^^^^^^^^^^^^^^^
+/// file:///project/root/target/doc/std/iter/trait.Iterator.html#tymethod.next
+/// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 /// ```
-fn get_doc_base_url(db: &RootDatabase, def: Definition) -> Option<Url> {
+fn get_doc_base_urls(
+    db: &RootDatabase,
+    def: Definition,
+    target_dir: Option<&OsStr>,
+    sysroot: Option<&OsStr>,
+) -> (Option<Url>, Option<Url>) {
+    let local_doc = target_dir
+        .and_then(|path| path.to_str())
+        .and_then(|path| Url::parse(&format!("file:///{path}/")).ok())
+        .and_then(|it| it.join("doc/").ok());
+    let system_doc = sysroot
+        .and_then(|it| it.to_str())
+        .map(|sysroot| format!("file:///{sysroot}/share/doc/rust/html/"))
+        .and_then(|it| Url::parse(&it).ok());
+
     // special case base url of `BuiltinType` to core
     // https://github.com/rust-lang/rust-analyzer/issues/12250
     if let Definition::BuiltinType(..) = def {
-        return Url::parse("https://doc.rust-lang.org/nightly/core/").ok();
+        let web_link = Url::parse("https://doc.rust-lang.org/nightly/core/").ok();
+        let system_link = system_doc.and_then(|it| it.join("core/").ok());
+        return (web_link, system_link);
     };
 
-    let krate = def.krate(db)?;
-    let display_name = krate.display_name(db)?;
+    let Some(krate) = def.krate(db) else { return Default::default() };
+    let Some(display_name) = krate.display_name(db) else { return Default::default() };
     let crate_data = &db.crate_graph()[krate.into()];
     let channel = crate_data.channel.map_or("nightly", ReleaseChannel::as_str);
-    let base = match &crate_data.origin {
+
+    let (web_base, local_base) = match &crate_data.origin {
         // std and co do not specify `html_root_url` any longer so we gotta handwrite this ourself.
         // FIXME: Use the toolchains channel instead of nightly
         CrateOrigin::Lang(
@@ -448,15 +495,17 @@ fn get_doc_base_url(db: &RootDatabase, def: Definition) -> Option<Url> {
             | LangCrateOrigin::Std
             | LangCrateOrigin::Test),
         ) => {
-            format!("https://doc.rust-lang.org/{channel}/{origin}")
+            let system_url = system_doc.and_then(|it| it.join(&format!("{origin}")).ok());
+            let web_url = format!("https://doc.rust-lang.org/{channel}/{origin}");
+            (Some(web_url), system_url)
         }
-        CrateOrigin::Lang(_) => return None,
+        CrateOrigin::Lang(_) => return (None, None),
         CrateOrigin::Rustc { name: _ } => {
-            format!("https://doc.rust-lang.org/{channel}/nightly-rustc/")
+            (Some(format!("https://doc.rust-lang.org/{channel}/nightly-rustc/")), None)
         }
         CrateOrigin::Local { repo: _, name: _ } => {
             // FIXME: These should not attempt to link to docs.rs!
-            krate.get_html_root_url(db).or_else(|| {
+            let weblink = krate.get_html_root_url(db).or_else(|| {
                 let version = krate.version(db);
                 // Fallback to docs.rs. This uses `display_name` and can never be
                 // correct, but that's what fallbacks are about.
@@ -468,10 +517,11 @@ fn get_doc_base_url(db: &RootDatabase, def: Definition) -> Option<Url> {
                     krate = display_name,
                     version = version.as_deref().unwrap_or("*")
                 ))
-            })?
+            });
+            (weblink, local_doc)
         }
         CrateOrigin::Library { repo: _, name } => {
-            krate.get_html_root_url(db).or_else(|| {
+            let weblink = krate.get_html_root_url(db).or_else(|| {
                 let version = krate.version(db);
                 // Fallback to docs.rs. This uses `display_name` and can never be
                 // correct, but that's what fallbacks are about.
@@ -483,10 +533,16 @@ fn get_doc_base_url(db: &RootDatabase, def: Definition) -> Option<Url> {
                     krate = name,
                     version = version.as_deref().unwrap_or("*")
                 ))
-            })?
+            });
+            (weblink, local_doc)
         }
     };
-    Url::parse(&base).ok()?.join(&format!("{display_name}/")).ok()
+    let web_base = web_base
+        .and_then(|it| Url::parse(&it).ok())
+        .and_then(|it| it.join(&format!("{display_name}/")).ok());
+    let local_base = local_base.and_then(|it| it.join(&format!("{display_name}/")).ok());
+
+    (web_base, local_base)
 }
 
 /// Get the filename and extension generated for a symbol by rustdoc.
diff --git a/crates/ide/src/doc_links/tests.rs b/crates/ide/src/doc_links/tests.rs
index b6b46c45088..05a64b33bfd 100644
--- a/crates/ide/src/doc_links/tests.rs
+++ b/crates/ide/src/doc_links/tests.rs
@@ -1,3 +1,5 @@
+use std::ffi::OsStr;
+
 use expect_test::{expect, Expect};
 use hir::{HasAttrs, Semantics};
 use ide_db::{
@@ -13,11 +15,33 @@ use crate::{
     fixture, TryToNav,
 };
 
-fn check_external_docs(ra_fixture: &str, expect: Expect) {
+fn check_external_docs(
+    ra_fixture: &str,
+    target_dir: Option<&OsStr>,
+    expect_web_url: Option<Expect>,
+    expect_local_url: Option<Expect>,
+    sysroot: Option<&OsStr>,
+) {
     let (analysis, position) = fixture::position(ra_fixture);
-    let url = analysis.external_docs(position).unwrap().expect("could not find url for symbol");
+    let links = analysis.external_docs(position, target_dir, sysroot).unwrap();
+
+    let web_url = links.web_url;
+    let local_url = links.local_url;
+
+    println!("web_url: {:?}", web_url);
+    println!("local_url: {:?}", local_url);
+
+    match (expect_web_url, web_url) {
+        (Some(expect), Some(url)) => expect.assert_eq(&url),
+        (None, None) => (),
+        _ => panic!("Unexpected web url"),
+    }
 
-    expect.assert_eq(&url)
+    match (expect_local_url, local_url) {
+        (Some(expect), Some(url)) => expect.assert_eq(&url),
+        (None, None) => (),
+        _ => panic!("Unexpected local url"),
+    }
 }
 
 fn check_rewrite(ra_fixture: &str, expect: Expect) {
@@ -97,6 +121,20 @@ fn node_to_def(
 }
 
 #[test]
+fn external_docs_doc_builtin_type() {
+    check_external_docs(
+        r#"
+//- /main.rs crate:foo
+let x: u3$02 = 0;
+"#,
+        Some(&OsStr::new("/home/user/project")),
+        Some(expect![[r#"https://doc.rust-lang.org/nightly/core/primitive.u32.html"#]]),
+        Some(expect![[r#"file:///sysroot/share/doc/rust/html/core/primitive.u32.html"#]]),
+        Some(&OsStr::new("/sysroot")),
+    );
+}
+
+#[test]
 fn external_docs_doc_url_crate() {
     check_external_docs(
         r#"
@@ -105,7 +143,10 @@ use foo$0::Foo;
 //- /lib.rs crate:foo
 pub struct Foo;
 "#,
-        expect![[r#"https://docs.rs/foo/*/foo/index.html"#]],
+        Some(&OsStr::new("/home/user/project")),
+        Some(expect![[r#"https://docs.rs/foo/*/foo/index.html"#]]),
+        Some(expect![[r#"file:///home/user/project/doc/foo/index.html"#]]),
+        Some(&OsStr::new("/sysroot")),
     );
 }
 
@@ -116,7 +157,10 @@ fn external_docs_doc_url_std_crate() {
 //- /main.rs crate:std
 use self$0;
 "#,
-        expect!["https://doc.rust-lang.org/stable/std/index.html"],
+        Some(&OsStr::new("/home/user/project")),
+        Some(expect!["https://doc.rust-lang.org/stable/std/index.html"]),
+        Some(expect!["file:///sysroot/share/doc/rust/html/std/index.html"]),
+        Some(&OsStr::new("/sysroot")),
     );
 }
 
@@ -127,7 +171,38 @@ fn external_docs_doc_url_struct() {
 //- /main.rs crate:foo
 pub struct Fo$0o;
 "#,
-        expect![[r#"https://docs.rs/foo/*/foo/struct.Foo.html"#]],
+        Some(&OsStr::new("/home/user/project")),
+        Some(expect![[r#"https://docs.rs/foo/*/foo/struct.Foo.html"#]]),
+        Some(expect![[r#"file:///home/user/project/doc/foo/struct.Foo.html"#]]),
+        Some(&OsStr::new("/sysroot")),
+    );
+}
+
+#[test]
+fn external_docs_doc_url_windows_backslash_path() {
+    check_external_docs(
+        r#"
+//- /main.rs crate:foo
+pub struct Fo$0o;
+"#,
+        Some(&OsStr::new(r"C:\Users\user\project")),
+        Some(expect![[r#"https://docs.rs/foo/*/foo/struct.Foo.html"#]]),
+        Some(expect![[r#"file:///C:/Users/user/project/doc/foo/struct.Foo.html"#]]),
+        Some(&OsStr::new("/sysroot")),
+    );
+}
+
+#[test]
+fn external_docs_doc_url_windows_slash_path() {
+    check_external_docs(
+        r#"
+//- /main.rs crate:foo
+pub struct Fo$0o;
+"#,
+        Some(&OsStr::new(r"C:/Users/user/project")),
+        Some(expect![[r#"https://docs.rs/foo/*/foo/struct.Foo.html"#]]),
+        Some(expect![[r#"file:///C:/Users/user/project/doc/foo/struct.Foo.html"#]]),
+        Some(&OsStr::new("/sysroot")),
     );
 }
 
@@ -140,7 +215,10 @@ pub struct Foo {
     field$0: ()
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#structfield.field"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#structfield.field"##]]),
+        None,
+        None,
     );
 }
 
@@ -151,7 +229,10 @@ fn external_docs_doc_url_fn() {
 //- /main.rs crate:foo
 pub fn fo$0o() {}
 "#,
-        expect![[r#"https://docs.rs/foo/*/foo/fn.foo.html"#]],
+        None,
+        Some(expect![[r#"https://docs.rs/foo/*/foo/fn.foo.html"#]]),
+        None,
+        None,
     );
 }
 
@@ -165,7 +246,10 @@ impl Foo {
     pub fn method$0() {}
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#method.method"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#method.method"##]]),
+        None,
+        None,
     );
     check_external_docs(
         r#"
@@ -175,7 +259,10 @@ impl Foo {
     const CONST$0: () = ();
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#associatedconstant.CONST"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#associatedconstant.CONST"##]]),
+        None,
+        None,
     );
 }
 
@@ -192,7 +279,10 @@ impl Trait for Foo {
     pub fn method$0() {}
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#method.method"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#method.method"##]]),
+        None,
+        None,
     );
     check_external_docs(
         r#"
@@ -205,7 +295,10 @@ impl Trait for Foo {
     const CONST$0: () = ();
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#associatedconstant.CONST"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#associatedconstant.CONST"##]]),
+        None,
+        None,
     );
     check_external_docs(
         r#"
@@ -218,7 +311,10 @@ impl Trait for Foo {
     type Type$0 = ();
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#associatedtype.Type"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/struct.Foo.html#associatedtype.Type"##]]),
+        None,
+        None,
     );
 }
 
@@ -231,7 +327,10 @@ pub trait Foo {
     fn method$0();
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/trait.Foo.html#tymethod.method"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/trait.Foo.html#tymethod.method"##]]),
+        None,
+        None,
     );
     check_external_docs(
         r#"
@@ -240,7 +339,10 @@ pub trait Foo {
     const CONST$0: ();
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/trait.Foo.html#associatedconstant.CONST"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/trait.Foo.html#associatedconstant.CONST"##]]),
+        None,
+        None,
     );
     check_external_docs(
         r#"
@@ -249,7 +351,10 @@ pub trait Foo {
     type Type$0;
 }
 "#,
-        expect![[r##"https://docs.rs/foo/*/foo/trait.Foo.html#associatedtype.Type"##]],
+        None,
+        Some(expect![[r##"https://docs.rs/foo/*/foo/trait.Foo.html#associatedtype.Type"##]]),
+        None,
+        None,
     );
 }
 
@@ -260,7 +365,10 @@ fn external_docs_trait() {
 //- /main.rs crate:foo
 trait Trait$0 {}
 "#,
-        expect![[r#"https://docs.rs/foo/*/foo/trait.Trait.html"#]],
+        None,
+        Some(expect![[r#"https://docs.rs/foo/*/foo/trait.Trait.html"#]]),
+        None,
+        None,
     )
 }
 
@@ -273,7 +381,10 @@ pub mod foo {
     pub mod ba$0r {}
 }
 "#,
-        expect![[r#"https://docs.rs/foo/*/foo/foo/bar/index.html"#]],
+        None,
+        Some(expect![[r#"https://docs.rs/foo/*/foo/foo/bar/index.html"#]]),
+        None,
+        None,
     )
 }
 
@@ -294,7 +405,10 @@ fn foo() {
     let bar: wrapper::It$0em;
 }
         "#,
-        expect![[r#"https://docs.rs/foo/*/foo/wrapper/module/struct.Item.html"#]],
+        None,
+        Some(expect![[r#"https://docs.rs/foo/*/foo/wrapper/module/struct.Item.html"#]]),
+        None,
+        None,
     )
 }
 
diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs
index 24e2aed65a5..1af4d39265f 100644
--- a/crates/ide/src/lib.rs
+++ b/crates/ide/src/lib.rs
@@ -61,7 +61,7 @@ mod view_item_tree;
 mod shuffle_crate_graph;
 mod fetch_crates;
 
-use std::sync::Arc;
+use std::{ffi::OsStr, sync::Arc};
 
 use cfg::CfgOptions;
 use fetch_crates::CrateInfo;
@@ -467,12 +467,19 @@ impl Analysis {
         self.with_db(|db| moniker::moniker(db, position))
     }
 
-    /// Return URL(s) for the documentation of the symbol under the cursor.
+    /// Returns URL(s) for the documentation of the symbol under the cursor.
+    /// # Arguments
+    /// * `position` - Position in the file.
+    /// * `target_dir` - Directory where the build output is storeda.
     pub fn external_docs(
         &self,
         position: FilePosition,
-    ) -> Cancellable<Option<doc_links::DocumentationLink>> {
-        self.with_db(|db| doc_links::external_docs(db, &position))
+        target_dir: Option<&OsStr>,
+        sysroot: Option<&OsStr>,
+    ) -> Cancellable<doc_links::DocumentationLinks> {
+        self.with_db(|db| {
+            doc_links::external_docs(db, &position, target_dir, sysroot).unwrap_or_default()
+        })
     }
 
     /// Computes parameter information at the given position.
diff --git a/crates/project-model/src/cargo_workspace.rs b/crates/project-model/src/cargo_workspace.rs
index fb98d61963c..e821cae00ac 100644
--- a/crates/project-model/src/cargo_workspace.rs
+++ b/crates/project-model/src/cargo_workspace.rs
@@ -32,6 +32,7 @@ pub struct CargoWorkspace {
     packages: Arena<PackageData>,
     targets: Arena<TargetData>,
     workspace_root: AbsPathBuf,
+    target_directory: AbsPathBuf,
 }
 
 impl ops::Index<Package> for CargoWorkspace {
@@ -414,7 +415,10 @@ impl CargoWorkspace {
         let workspace_root =
             AbsPathBuf::assert(PathBuf::from(meta.workspace_root.into_os_string()));
 
-        CargoWorkspace { packages, targets, workspace_root }
+        let target_directory =
+            AbsPathBuf::assert(PathBuf::from(meta.target_directory.into_os_string()));
+
+        CargoWorkspace { packages, targets, workspace_root, target_directory }
     }
 
     pub fn packages(&self) -> impl Iterator<Item = Package> + ExactSizeIterator + '_ {
@@ -432,6 +436,10 @@ impl CargoWorkspace {
         &self.workspace_root
     }
 
+    pub fn target_directory(&self) -> &AbsPath {
+        &self.target_directory
+    }
+
     pub fn package_flag(&self, package: &PackageData) -> String {
         if self.is_unique(&package.name) {
             package.name.clone()
diff --git a/crates/rust-analyzer/src/config.rs b/crates/rust-analyzer/src/config.rs
index aa6beb6351a..51874382a8b 100644
--- a/crates/rust-analyzer/src/config.rs
+++ b/crates/rust-analyzer/src/config.rs
@@ -1036,6 +1036,10 @@ impl Config {
         self.experimental("codeActionGroup")
     }
 
+    pub fn local_docs(&self) -> bool {
+        self.experimental("localDocs")
+    }
+
     pub fn open_server_logs(&self) -> bool {
         self.experimental("openServerLogs")
     }
diff --git a/crates/rust-analyzer/src/handlers/request.rs b/crates/rust-analyzer/src/handlers/request.rs
index f25dc74a142..3a208865a78 100644
--- a/crates/rust-analyzer/src/handlers/request.rs
+++ b/crates/rust-analyzer/src/handlers/request.rs
@@ -40,8 +40,8 @@ use crate::{
     global_state::{GlobalState, GlobalStateSnapshot},
     line_index::LineEndings,
     lsp_ext::{
-        self, CrateInfoResult, FetchDependencyListParams, FetchDependencyListResult,
-        PositionOrRange, ViewCrateGraphParams, WorkspaceSymbolParams,
+        self, CrateInfoResult, ExternalDocsPair, ExternalDocsResponse, FetchDependencyListParams,
+        FetchDependencyListResult, PositionOrRange, ViewCrateGraphParams, WorkspaceSymbolParams,
     },
     lsp_utils::{all_edits_are_disjoint, invalid_params_error},
     to_proto, LspError, Result,
@@ -1535,13 +1535,40 @@ pub(crate) fn handle_semantic_tokens_range(
 pub(crate) fn handle_open_docs(
     snap: GlobalStateSnapshot,
     params: lsp_types::TextDocumentPositionParams,
-) -> Result<Option<lsp_types::Url>> {
+) -> Result<ExternalDocsResponse> {
     let _p = profile::span("handle_open_docs");
     let position = from_proto::file_position(&snap, params)?;
 
-    let remote = snap.analysis.external_docs(position)?;
+    let ws_and_sysroot = snap.workspaces.iter().find_map(|ws| match ws {
+        ProjectWorkspace::Cargo { cargo, sysroot, .. } => Some((cargo, sysroot.as_ref().ok())),
+        ProjectWorkspace::Json { .. } => None,
+        ProjectWorkspace::DetachedFiles { .. } => None,
+    });
 
-    Ok(remote.and_then(|remote| Url::parse(&remote).ok()))
+    let (cargo, sysroot) = match ws_and_sysroot {
+        Some((ws, sysroot)) => (Some(ws), sysroot),
+        _ => (None, None),
+    };
+
+    let sysroot = sysroot.map(|p| p.root().as_os_str());
+    let target_dir = cargo.map(|cargo| cargo.target_directory()).map(|p| p.as_os_str());
+
+    let Ok(remote_urls) = snap.analysis.external_docs(position, target_dir, sysroot) else {
+        return if snap.config.local_docs() {
+            Ok(ExternalDocsResponse::WithLocal(Default::default()))
+            } else {
+            Ok(ExternalDocsResponse::Simple(None))
+            }
+    };
+
+    let web = remote_urls.web_url.and_then(|it| Url::parse(&it).ok());
+    let local = remote_urls.local_url.and_then(|it| Url::parse(&it).ok());
+
+    if snap.config.local_docs() {
+        Ok(ExternalDocsResponse::WithLocal(ExternalDocsPair { web, local }))
+    } else {
+        Ok(ExternalDocsResponse::Simple(web))
+    }
 }
 
 pub(crate) fn handle_open_cargo_toml(
diff --git a/crates/rust-analyzer/src/lsp_ext.rs b/crates/rust-analyzer/src/lsp_ext.rs
index 69e7d824680..4d67c8b305f 100644
--- a/crates/rust-analyzer/src/lsp_ext.rs
+++ b/crates/rust-analyzer/src/lsp_ext.rs
@@ -508,10 +508,24 @@ pub enum ExternalDocs {}
 
 impl Request for ExternalDocs {
     type Params = lsp_types::TextDocumentPositionParams;
-    type Result = Option<lsp_types::Url>;
+    type Result = ExternalDocsResponse;
     const METHOD: &'static str = "experimental/externalDocs";
 }
 
+#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
+#[serde(untagged)]
+pub enum ExternalDocsResponse {
+    Simple(Option<lsp_types::Url>),
+    WithLocal(ExternalDocsPair),
+}
+
+#[derive(Debug, Default, PartialEq, Serialize, Deserialize, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct ExternalDocsPair {
+    pub web: Option<lsp_types::Url>,
+    pub local: Option<lsp_types::Url>,
+}
+
 pub enum OpenCargoToml {}
 
 impl Request for OpenCargoToml {
diff --git a/docs/dev/lsp-extensions.md b/docs/dev/lsp-extensions.md
index a4ad3e5a553..bc58aa7220d 100644
--- a/docs/dev/lsp-extensions.md
+++ b/docs/dev/lsp-extensions.md
@@ -1,5 +1,5 @@
 <!---
-lsp_ext.rs hash: fdf1afd34548abbc
+lsp_ext.rs hash: 2d60bbffe70ae198
 
 If you need to change the above hash to make the test pass, please check if you
 need to adjust this doc as well and ping this issue:
@@ -386,14 +386,26 @@ rust-analyzer supports only one `kind`, `"cargo"`. The `args` for `"cargo"` look
 
 ## Open External Documentation
 
-This request is sent from client to server to get a URL to documentation for the symbol under the cursor, if available.
+This request is sent from the client to the server to obtain web and local URL(s) for documentation related to the symbol under the cursor, if available.
 
-**Method** `experimental/externalDocs`
+**Method:** `experimental/externalDocs`
 
-**Request:**: `TextDocumentPositionParams`
+**Request:** `TextDocumentPositionParams`
+
+**Response:** `string | null`
 
-**Response** `string | null`
+## Local Documentation
 
+**Experimental Client Capability:** `{ "localDocs": boolean }`
+
+If this capability is set, the `Open External Documentation` request returned from the server will have the following structure:
+
+```typescript
+interface ExternalDocsResponse {
+    web?: string;
+    local?: string;
+}
+```
 
 ## Analyzer Status
 
@@ -863,7 +875,7 @@ export interface Diagnostic {
 export interface FetchDependencyListParams {}
 ```
 
-**Response:** 
+**Response:**
 ```typescript
 export interface FetchDependencyListResult {
     crates: {
@@ -873,4 +885,4 @@ export interface FetchDependencyListResult {
     }[];
 }
 ```
-Returns all crates from this workspace, so it can be used create a viewTree to help navigate the dependency tree.
\ No newline at end of file
+Returns all crates from this workspace, so it can be used create a viewTree to help navigate the dependency tree.