From f00bfbb58a38ac4049e60e7542978d92c65b992f Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Thu, 3 Sep 2026 22:38:16 +0800 Subject: [PATCH 01/10] Update R help integration --- crates/amalthea/src/comm/help_comm.rs | 40 ++++++++++++++++++++++++++ crates/ark/src/help/r_help.rs | 29 +++++++++++++++++++ crates/ark/src/modules/positron/help.R | 20 +++++++++++++ 3 files changed, 89 insertions(+) diff --git a/crates/amalthea/src/comm/help_comm.rs b/crates/amalthea/src/comm/help_comm.rs index ff4ea415f4..f7af6c1379 100644 --- a/crates/amalthea/src/comm/help_comm.rs +++ b/crates/amalthea/src/comm/help_comm.rs @@ -11,6 +11,19 @@ use serde::Deserialize; use serde::Serialize; +/// A help topic offered as an autocomplete suggestion. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct HelpTopicSuggestion { + /// The topic label shown to the user. + pub label: String, + + /// The exact topic value used to open help. + pub topic: String, + + /// Optional context such as the package containing the topic. + pub detail: Option +} + /// Possible values for Kind in ShowHelp #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, strum_macros::Display, strum_macros::EnumString)] pub enum ShowHelpKind { @@ -34,6 +47,13 @@ pub struct ShowHelpTopicParams { pub topic: String, } +/// Parameters for the SearchHelp method. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SearchHelpParams { + /// The help query to search for + pub query: String, +} + /// Parameters for the ShowHelp method. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ShowHelpParams { @@ -62,6 +82,20 @@ pub enum HelpBackendRequest { #[serde(rename = "show_help_topic")] ShowHelpTopic(ShowHelpTopicParams), + /// Search the active interpreter's help system. + /// + /// Searches interpreter-wide help and displays the resulting page via a + /// Show Help notification. + #[serde(rename = "search_help")] + SearchHelp(SearchHelpParams), + + /// List help topics for autocomplete. + /// + /// Returns interpreter-wide help topics that can be offered as search + /// suggestions. + #[serde(rename = "get_help_topics")] + GetHelpTopics, + } /** @@ -74,6 +108,12 @@ pub enum HelpBackendReply { /// Help notification. ShowHelpTopicReply(bool), + /// Whether the search results page was shown. + SearchHelpReply(bool), + + /// Help topic suggestions. + GetHelpTopicsReply(Vec), + } /** diff --git a/crates/ark/src/help/r_help.rs b/crates/ark/src/help/r_help.rs index b302df9e6d..5dbf8ee9a6 100644 --- a/crates/ark/src/help/r_help.rs +++ b/crates/ark/src/help/r_help.rs @@ -9,6 +9,7 @@ use amalthea::comm::comm_channel::CommMsg; use amalthea::comm::help_comm::HelpBackendReply; use amalthea::comm::help_comm::HelpBackendRequest; use amalthea::comm::help_comm::HelpFrontendEvent; +use amalthea::comm::help_comm::HelpTopicSuggestion; use amalthea::comm::help_comm::ShowHelpKind; use amalthea::comm::help_comm::ShowHelpParams; use anyhow::anyhow; @@ -75,6 +76,34 @@ impl RHelp { Err(err) => Err(err), } }, + HelpBackendRequest::SearchHelp(search) => { + let shown = r_task(|| { + RFunction::from(".ps.help.searchHelp") + .add(search.query) + .call()? + .to::() + })?; + Ok(HelpBackendReply::SearchHelpReply(shown)) + }, + HelpBackendRequest::GetHelpTopics => { + let topics = r_task(|| { + RFunction::from(".ps.help.getHelpTopics") + .call()? + .to::>() + })?; + let suggestions = topics + .into_iter() + .filter_map(|entry| { + let (package, topic) = entry.split_once('\u{1f}')?; + Some(HelpTopicSuggestion { + label: topic.to_string(), + topic: format!("{package}::{topic}"), + detail: Some(package.to_string()), + }) + }) + .collect(); + Ok(HelpBackendReply::GetHelpTopicsReply(suggestions)) + }, } } diff --git a/crates/ark/src/modules/positron/help.R b/crates/ark/src/modules/positron/help.R index ea28385f00..52201fbb4b 100644 --- a/crates/ark/src/modules/positron/help.R +++ b/crates/ark/src/modules/positron/help.R @@ -60,6 +60,26 @@ help <- function(topic, package = NULL) { length(results) > 0 } +# Search all installed help documentation and show R's native HTML results page. +#' @export +.ps.help.searchHelp <- function(query) { + results <- utils::help.search(query, package = NULL) + + if (!in_ark_tests()) { + print(results) + } + + TRUE +} + +# Return package-qualified help aliases for frontend autocomplete. +#' @export +.ps.help.getHelpTopics <- function() { + matches <- utils::help.search(".", fields = "alias", package = NULL)$matches + matches <- matches[matches[, "Type"] == "help", , drop = FALSE] + unique(paste(matches[, "Package"], matches[, "Topic"], sep = "\u001f")) +} + # Resolve the package specifier, if there is one split_topic <- function(topic) { # Try `:::` first, as `::` will match both From 915c4931bdbaf753f83361f35781fb7c9d6ddaf7 Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Fri, 4 Sep 2026 01:34:09 +0800 Subject: [PATCH 02/10] Add help.rs --- crates/ark/tests/integration/help.rs | 66 ++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/ark/tests/integration/help.rs b/crates/ark/tests/integration/help.rs index cd751c4e7d..90fcb87eee 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -13,6 +13,8 @@ use amalthea::comm::comm_channel::CommMsg; use amalthea::comm::event::CommEvent; use amalthea::comm::help_comm::HelpBackendReply; use amalthea::comm::help_comm::HelpBackendRequest; +use amalthea::comm::help_comm::HelpTopicSuggestion; +use amalthea::comm::help_comm::SearchHelpParams; use amalthea::comm::help_comm::ShowHelpTopicParams; use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; use amalthea::socket::comm::CommOutgoingTx; @@ -83,6 +85,7 @@ impl TestRHelp { assert!(found); assert_eq!(id, request_id); }, + reply => panic!("Unexpected help reply: {reply:?}"), } }, _ => { @@ -90,6 +93,58 @@ impl TestRHelp { }, } } + + fn test_search(&self, query: &str, id: &str) { + let request = HelpBackendRequest::SearchHelp(SearchHelpParams { + query: String::from(query), + }); + let data = serde_json::to_value(request).unwrap(); + let request_id = String::from(id); + self.comm + .incoming_tx + .send(CommMsg::Rpc { + id: request_id.clone(), + parent_header: dummy_jupyter_header(), + data, + }) + .unwrap(); + + // Search results can be emitted before the RPC response. Consume the + // navigation event first when that happens. + let mut response = self.iopub_rx.recv_comm_msg(); + while let CommMsg::Data(_) = response { + response = self.iopub_rx.recv_comm_msg(); + } + let CommMsg::Rpc { id, data, .. } = response else { + panic!("Unexpected response from help comm: {response:?}"); + }; + assert_eq!(id, request_id); + assert_eq!( + serde_json::from_value::(data).unwrap(), + HelpBackendReply::SearchHelpReply(true) + ); + } + + fn get_topics(&self, id: &str) -> Vec { + let data = serde_json::to_value(HelpBackendRequest::GetHelpTopics).unwrap(); + self.comm + .incoming_tx + .send(CommMsg::Rpc { + id: String::from(id), + parent_header: dummy_jupyter_header(), + data, + }) + .unwrap(); + + let response = self.iopub_rx.recv_comm_msg(); + let CommMsg::Rpc { data, .. } = response else { + panic!("Unexpected response from help comm: {response:?}"); + }; + match serde_json::from_value::(data).unwrap() { + HelpBackendReply::GetHelpTopicsReply(topics) => topics, + reply => panic!("Unexpected help reply: {reply:?}"), + } + } } /** @@ -126,6 +181,17 @@ fn test_help_comm() { assert!(RHelp::is_help_url(url.as_str(), r_help_port)); } +#[test] +fn test_help_search_comm() { + let r_help = TestRHelp::new(String::from("test-help-search-comm-id")); + + r_help.test_search("linear model", "help-search-test-id"); + let topics = r_help.get_topics("help-topics-test-id"); + assert!(topics + .iter() + .any(|topic| { topic.label == "plot" && topic.topic == "graphics::plot" })); +} + #[test] fn test_custom_help_handlers() { let r_help = TestRHelp::new(); From 746e2aa59b6ecad24bbdb56816f902473e959852 Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Fri, 4 Sep 2026 02:14:19 +0800 Subject: [PATCH 03/10] Adapt help search to synchronous comm handlers --- crates/ark/src/help/r_help.rs | 18 +++---- crates/ark/tests/integration/help.rs | 76 +++++++--------------------- 2 files changed, 26 insertions(+), 68 deletions(-) diff --git a/crates/ark/src/help/r_help.rs b/crates/ark/src/help/r_help.rs index 5dbf8ee9a6..86b04ac3c1 100644 --- a/crates/ark/src/help/r_help.rs +++ b/crates/ark/src/help/r_help.rs @@ -77,20 +77,16 @@ impl RHelp { } }, HelpBackendRequest::SearchHelp(search) => { - let shown = r_task(|| { - RFunction::from(".ps.help.searchHelp") - .add(search.query) - .call()? - .to::() - })?; + let shown = RFunction::from(".ps.help.searchHelp") + .add(search.query) + .call()? + .to::()?; Ok(HelpBackendReply::SearchHelpReply(shown)) }, HelpBackendRequest::GetHelpTopics => { - let topics = r_task(|| { - RFunction::from(".ps.help.getHelpTopics") - .call()? - .to::>() - })?; + let topics = RFunction::from(".ps.help.getHelpTopics") + .call()? + .to::>()?; let suggestions = topics .into_iter() .filter_map(|entry| { diff --git a/crates/ark/tests/integration/help.rs b/crates/ark/tests/integration/help.rs index 90fcb87eee..8bb26ef21e 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -52,10 +52,7 @@ impl TestRHelp { Self { iopub_tx, iopub_rx } } - fn test_topic(&self, topic: &str, id: &str) { - let request = HelpBackendRequest::ShowHelpTopic(ShowHelpTopicParams { - topic: String::from(topic), - }); + fn request(&self, request: HelpBackendRequest, id: &str) -> HelpBackendReply { let data = serde_json::to_value(request).unwrap(); let request_id = String::from(id); let msg = CommMsg::Rpc { @@ -77,70 +74,35 @@ impl TestRHelp { }); let response = self.iopub_rx.recv_comm_msg(); - match response { - CommMsg::Rpc { id, data: val, .. } => { - let response = serde_json::from_value::(val).unwrap(); - match response { - HelpBackendReply::ShowHelpTopicReply(found) => { - assert!(found); - assert_eq!(id, request_id); - }, - reply => panic!("Unexpected help reply: {reply:?}"), - } - }, - _ => { - panic!("Unexpected response from help comm: {:?}", response); - }, - } + let CommMsg::Rpc { id, data, .. } = response else { + panic!("Unexpected response from help comm: {response:?}"); + }; + assert_eq!(id, request_id); + serde_json::from_value(data).unwrap() + } + + fn test_topic(&self, topic: &str, id: &str) { + let request = HelpBackendRequest::ShowHelpTopic(ShowHelpTopicParams { + topic: String::from(topic), + }); + assert_eq!( + self.request(request, id), + HelpBackendReply::ShowHelpTopicReply(true) + ); } fn test_search(&self, query: &str, id: &str) { let request = HelpBackendRequest::SearchHelp(SearchHelpParams { query: String::from(query), }); - let data = serde_json::to_value(request).unwrap(); - let request_id = String::from(id); - self.comm - .incoming_tx - .send(CommMsg::Rpc { - id: request_id.clone(), - parent_header: dummy_jupyter_header(), - data, - }) - .unwrap(); - - // Search results can be emitted before the RPC response. Consume the - // navigation event first when that happens. - let mut response = self.iopub_rx.recv_comm_msg(); - while let CommMsg::Data(_) = response { - response = self.iopub_rx.recv_comm_msg(); - } - let CommMsg::Rpc { id, data, .. } = response else { - panic!("Unexpected response from help comm: {response:?}"); - }; - assert_eq!(id, request_id); assert_eq!( - serde_json::from_value::(data).unwrap(), + self.request(request, id), HelpBackendReply::SearchHelpReply(true) ); } fn get_topics(&self, id: &str) -> Vec { - let data = serde_json::to_value(HelpBackendRequest::GetHelpTopics).unwrap(); - self.comm - .incoming_tx - .send(CommMsg::Rpc { - id: String::from(id), - parent_header: dummy_jupyter_header(), - data, - }) - .unwrap(); - - let response = self.iopub_rx.recv_comm_msg(); - let CommMsg::Rpc { data, .. } = response else { - panic!("Unexpected response from help comm: {response:?}"); - }; - match serde_json::from_value::(data).unwrap() { + match self.request(HelpBackendRequest::GetHelpTopics, id) { HelpBackendReply::GetHelpTopicsReply(topics) => topics, reply => panic!("Unexpected help reply: {reply:?}"), } @@ -183,7 +145,7 @@ fn test_help_comm() { #[test] fn test_help_search_comm() { - let r_help = TestRHelp::new(String::from("test-help-search-comm-id")); + let r_help = TestRHelp::new(); r_help.test_search("linear model", "help-search-test-id"); let topics = r_help.get_topics("help-topics-test-id"); From be67a08cd24ed3def4f8088693bc6382c7796d09 Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Fri, 4 Sep 2026 02:26:27 +0800 Subject: [PATCH 04/10] Return R help aliases in topic suggestions --- crates/ark/src/modules/positron/help.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ark/src/modules/positron/help.R b/crates/ark/src/modules/positron/help.R index 52201fbb4b..d975fdc88b 100644 --- a/crates/ark/src/modules/positron/help.R +++ b/crates/ark/src/modules/positron/help.R @@ -77,7 +77,7 @@ help <- function(topic, package = NULL) { .ps.help.getHelpTopics <- function() { matches <- utils::help.search(".", fields = "alias", package = NULL)$matches matches <- matches[matches[, "Type"] == "help", , drop = FALSE] - unique(paste(matches[, "Package"], matches[, "Topic"], sep = "\u001f")) + unique(paste(matches[, "Package"], matches[, "Entry"], sep = "\u001f")) } # Resolve the package specifier, if there is one From dba99fc0cb30fb09b6ec76975f328b3c06388173 Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Tue, 29 Sep 2026 13:13:05 +0800 Subject: [PATCH 05/10] Update Help protocol for bounded suggestions and search correlation --- crates/amalthea/src/comm/help_comm.rs | 30 ++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/amalthea/src/comm/help_comm.rs b/crates/amalthea/src/comm/help_comm.rs index f7af6c1379..77c97b61ca 100644 --- a/crates/amalthea/src/comm/help_comm.rs +++ b/crates/amalthea/src/comm/help_comm.rs @@ -52,6 +52,20 @@ pub struct ShowHelpTopicParams { pub struct SearchHelpParams { /// The help query to search for pub query: String, + + /// Opaque identifier supplied by the frontend for this UI search. Echo it + /// in the resulting Show Help notification. + pub search_id: String, +} + +/// Parameters for the GetHelpTopics method. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct GetHelpTopicsParams { + /// The text to match against help topic labels. + pub query: String, + + /// Maximum number of suggestions to return, from 1 to 50. + pub limit: i64, } /// Parameters for the ShowHelp method. @@ -65,6 +79,11 @@ pub struct ShowHelpParams { /// Whether to focus the Help pane when the content is displayed. pub focus: bool, + + /// Identifier of the UI search that requested this navigation, if any. + /// Omit for console help and other help navigation. The frontend ignores + /// identifiers that are no longer current. + pub search_id: Option, } /** @@ -89,12 +108,12 @@ pub enum HelpBackendRequest { #[serde(rename = "search_help")] SearchHelp(SearchHelpParams), - /// List help topics for autocomplete. + /// Find help topics for autocomplete. /// - /// Returns interpreter-wide help topics that can be offered as search - /// suggestions. + /// Returns at most limit matching help topic suggestions, filtered and + /// ranked by the backend. An empty query returns no suggestions. #[serde(rename = "get_help_topics")] - GetHelpTopics, + GetHelpTopics(GetHelpTopicsParams), } @@ -108,7 +127,8 @@ pub enum HelpBackendReply { /// Help notification. ShowHelpTopicReply(bool), - /// Whether the search results page was shown. + /// Whether the search results navigation was requested. This does not + /// confirm that the frontend displayed or finished loading the page. SearchHelpReply(bool), /// Help topic suggestions. From cece6c954960d56a77117889b31c5b8ae8e9b269 Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Tue, 29 Sep 2026 13:25:30 +0800 Subject: [PATCH 06/10] Bound Help suggestions and correlate native search navigation --- crates/ark/src/help/r_help.rs | 60 ++++++++++++++++- crates/ark/src/modules/positron/help.R | 58 ++++++++++++++-- crates/ark/tests/integration/help.rs | 91 +++++++++++++++++++++++++- 3 files changed, 200 insertions(+), 9 deletions(-) diff --git a/crates/ark/src/help/r_help.rs b/crates/ark/src/help/r_help.rs index 86b04ac3c1..06052219a6 100644 --- a/crates/ark/src/help/r_help.rs +++ b/crates/ark/src/help/r_help.rs @@ -5,6 +5,8 @@ // // +use std::cell::RefCell; + use amalthea::comm::comm_channel::CommMsg; use amalthea::comm::help_comm::HelpBackendReply; use amalthea::comm::help_comm::HelpBackendRequest; @@ -32,6 +34,26 @@ use crate::methods::ArkGenerics; pub const HELP_COMM_NAME: &str = "positron.help"; +thread_local! { + // Browser callbacks run synchronously on the R thread while a search is printed. + // Keep this separate from RHelp, which is already borrowed during RPC dispatch. + static SEARCH_ID: RefCell> = const { RefCell::new(None) }; +} + +struct SearchContext(Option); + +impl SearchContext { + fn enter(id: String) -> Self { + Self(SEARCH_ID.replace(Some(id))) + } +} + +impl Drop for SearchContext { + fn drop(&mut self) { + SEARCH_ID.set(self.0.take()); + } +} + /// Ports for the R help server and our proxy, recorded on `Console` once both /// are running. #[derive(Clone, Copy)] @@ -77,14 +99,20 @@ impl RHelp { } }, HelpBackendRequest::SearchHelp(search) => { + let _search_context = SearchContext::enter(search.search_id); let shown = RFunction::from(".ps.help.searchHelp") .add(search.query) .call()? .to::()?; Ok(HelpBackendReply::SearchHelpReply(shown)) }, - HelpBackendRequest::GetHelpTopics => { + HelpBackendRequest::GetHelpTopics(params) => { + if !(1..=50).contains(¶ms.limit) { + return Err(anyhow!("Help suggestion limit must be between 1 and 50.")); + } let topics = RFunction::from(".ps.help.getHelpTopics") + .add(params.query) + .add(params.limit as i32) .call()? .to::>()?; let suggestions = topics @@ -161,6 +189,7 @@ impl RHelp { content: url, kind: ShowHelpKind::Url, focus: true, + search_id: SEARCH_ID.with(|id| id.borrow().clone()), }); ctx.send_event(&msg); @@ -307,3 +336,32 @@ pub unsafe extern "C-unwind" fn ps_help_browse_external_url( Ok(R_NilValue) } + +#[cfg(test)] +mod tests { + use super::SearchContext; + use super::SEARCH_ID; + + #[test] + fn search_context_restores_after_error() { + fn fail() -> anyhow::Result<()> { + let _context = SearchContext::enter(String::from("inner")); + assert_eq!( + SEARCH_ID.with(|id| id.borrow().clone()), + Some(String::from("inner")) + ); + Err(anyhow::anyhow!("search failed")) + } + + assert_eq!(SEARCH_ID.with(|id| id.borrow().clone()), None); + { + let _context = SearchContext::enter(String::from("outer")); + assert!(fail().is_err()); + assert_eq!( + SEARCH_ID.with(|id| id.borrow().clone()), + Some(String::from("outer")) + ); + } + assert_eq!(SEARCH_ID.with(|id| id.borrow().clone()), None); + } +} diff --git a/crates/ark/src/modules/positron/help.R b/crates/ark/src/modules/positron/help.R index d975fdc88b..c22238f350 100644 --- a/crates/ark/src/modules/positron/help.R +++ b/crates/ark/src/modules/positron/help.R @@ -63,6 +63,7 @@ help <- function(topic, package = NULL) { # Search all installed help documentation and show R's native HTML results page. #' @export .ps.help.searchHelp <- function(query) { + .ps.help.searchIndex() results <- utils::help.search(query, package = NULL) if (!in_ark_tests()) { @@ -72,12 +73,59 @@ help <- function(topic, package = NULL) { TRUE } -# Return package-qualified help aliases for frontend autocomplete. +# Cache sorted aliases, rebuilding native R's search database when library metadata +# changes. Checking metadata also catches in-place updates that do not change the +# library directory's mtime (which is what help.search() checks itself). +.ps.help.searchIndex <- local({ + signature <- NULL + index <- NULL + + function() { + libraries <- .libPaths() + packages <- unlist(lapply(libraries, list.dirs, recursive = FALSE), use.names = FALSE) + metadata <- unlist(lapply( + c("package.rds", "hsearch.rds", "vignette.rds", "demo.rds"), + function(name) file.path(packages, "Meta", name) + ), use.names = FALSE) + current <- list( + libraries = libraries, + files = file.info(c(libraries, metadata))[, c("size", "mtime", "ctime"), drop = FALSE], + locale = Sys.getlocale("LC_CTYPE"), + collation = Sys.getlocale("LC_COLLATE"), + types = getOption("help.search.types") + ) + if (!identical(current, signature)) { + matches <- utils::help.search(".", fields = "alias", package = NULL, rebuild = TRUE)$matches + matches <- matches[matches[, "Type"] == "help", , drop = FALSE] + topics <- unique(data.frame(package = matches[, "Package"], label = matches[, "Entry"])) + labels <- tolower(topics$label) + sorted <- order(labels, topics$package) + index <<- list( + labels = labels[sorted], + entries = paste(topics$package[sorted], topics$label[sorted], sep = "\u001f") + ) + signature <<- current + } + index + } +}) + +# Return only matching package-qualified aliases, ranked as in the search box: +# exact label, prefix, then substring; alphabetical within each group. #' @export -.ps.help.getHelpTopics <- function() { - matches <- utils::help.search(".", fields = "alias", package = NULL)$matches - matches <- matches[matches[, "Type"] == "help", , drop = FALSE] - unique(paste(matches[, "Package"], matches[, "Entry"], sep = "\u001f")) +.ps.help.getHelpTopics <- function(query, limit) { + if (length(limit) != 1L || is.na(limit) || limit < 1L || limit > 50L || limit != as.integer(limit)) { + stop("Help suggestion limit must be between 1 and 50.") + } + query <- tolower(trimws(query)) + if (!nzchar(query)) { + return(character()) + } + index <- .ps.help.searchIndex() + hits <- which(grepl(query, index$labels, fixed = TRUE)) + labels <- index$labels[hits] + rank <- ifelse(labels == query, 0L, ifelse(startsWith(labels, query), 1L, 2L)) + index$entries[utils::head(hits[order(rank)], limit)] } # Resolve the package specifier, if there is one diff --git a/crates/ark/tests/integration/help.rs b/crates/ark/tests/integration/help.rs index 8bb26ef21e..baf7cb912f 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -11,6 +11,7 @@ use std::time::Duration; use amalthea::comm::comm_channel::CommMsg; use amalthea::comm::event::CommEvent; +use amalthea::comm::help_comm::GetHelpTopicsParams; use amalthea::comm::help_comm::HelpBackendReply; use amalthea::comm::help_comm::HelpBackendRequest; use amalthea::comm::help_comm::HelpTopicSuggestion; @@ -19,11 +20,13 @@ use amalthea::comm::help_comm::ShowHelpTopicParams; use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; use amalthea::socket::comm::CommOutgoingTx; use amalthea::socket::iopub::IOPubMessage; +use amalthea::wire::comm_msg::CommWireMsg; use amalthea::wire::comm_open::CommOpen; use ark::comm_handler::CommHandler; use ark::comm_handler::CommHandlerContext; use ark::help::r_help::RHelp; use ark::help_proxy; +use ark::modules::ARK_ENVS; use ark::r_task::r_task; use ark_test::dummy_jupyter_header; use ark_test::DummyArkFrontend; @@ -94,6 +97,7 @@ impl TestRHelp { fn test_search(&self, query: &str, id: &str) { let request = HelpBackendRequest::SearchHelp(SearchHelpParams { query: String::from(query), + search_id: String::from(id), }); assert_eq!( self.request(request, id), @@ -101,8 +105,14 @@ impl TestRHelp { ); } - fn get_topics(&self, id: &str) -> Vec { - match self.request(HelpBackendRequest::GetHelpTopics, id) { + fn get_topics(&self, query: &str, limit: i64, id: &str) -> Vec { + match self.request( + HelpBackendRequest::GetHelpTopics(GetHelpTopicsParams { + query: String::from(query), + limit, + }), + id, + ) { HelpBackendReply::GetHelpTopicsReply(topics) => topics, reply => panic!("Unexpected help reply: {reply:?}"), } @@ -148,7 +158,11 @@ fn test_help_search_comm() { let r_help = TestRHelp::new(); r_help.test_search("linear model", "help-search-test-id"); - let topics = r_help.get_topics("help-topics-test-id"); + let topics = r_help.get_topics("plot", 50, "help-topics-test-id"); + assert!(topics.len() <= 50); + assert_eq!(topics[0].label, "plot"); + assert!(r_help.get_topics("", 50, "empty-help-topics").is_empty()); + assert_eq!(r_help.get_topics("plot", 1, "limited-help-topics").len(), 1); assert!(topics .iter() .any(|topic| { topic.label == "plot" && topic.topic == "graphics::plot" })); @@ -222,6 +236,7 @@ fn test_help_show_help_event() { Some("show_help") ); assert_eq!(msg.data["params"]["kind"], "url"); + assert!(msg.data["params"]["search_id"].is_null()); let content = msg.data["params"]["content"].as_str().unwrap(); assert!(content.starts_with("http://127.0.0.1:")); assert!(content.contains("plot")); @@ -314,3 +329,73 @@ fn wait_until_proxy_stops(port: u16) { } panic!("Proxy on port {port} is still accepting connections after teardown"); } + +#[test] +fn test_help_search_navigation_correlation() { + let frontend = DummyArkFrontend::lock(); + let comm_id = open_help_comm(&frontend); + frontend.send_shell(CommWireMsg { + comm_id: comm_id.clone(), + data: serde_json::json!({ + "jsonrpc": "2.0", + "method": "search_help", + "params": { "query": "linear model", "search_id": "ui-search" } + }), + }); + frontend.recv_iopub_busy(); + let event = frontend.recv_iopub_comm_msg(); + assert_eq!(event.comm_id, comm_id); + assert_eq!(event.data["method"], "show_help"); + assert_eq!(event.data["params"]["search_id"], "ui-search"); + let reply = frontend.recv_iopub_comm_msg(); + assert_eq!(reply.data["result"], true); + frontend.recv_iopub_idle(); + + // The scope has ended: console search must remain ordinary native Help. + frontend.send_execute_request("??plot", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + let event = frontend.recv_iopub_comm_msg(); + assert_eq!(event.data["method"], "show_help"); + assert!(event.data["params"]["search_id"].is_null()); + frontend.recv_iopub_idle(); + frontend.recv_shell_execute_reply(); +} + +#[test] +fn test_help_index_freshness() { + assert!(r_task(|| { + harp::parse_eval0(r#" +local({ + old_paths <- .libPaths() + library <- tempfile("ark-help-index-") + dir.create(library) + on.exit({ .libPaths(old_paths); unlink(library, recursive = TRUE) }) + dir.create(file.path(library, "stats")) + stopifnot(file.copy(file.path(find.package("stats"), "Meta"), file.path(library, "stats"), recursive = TRUE)) + .libPaths(c(library, old_paths)) + stopifnot(length(.ps.help.getHelpTopics("", 50L)) == 0L) + stopifnot(length(.ps.help.getHelpTopics("lm", 1L)) == 1L) + stopifnot(identical(.ps.help.getHelpTopics(" LM ", 5L), .ps.help.getHelpTopics("lm", 5L))) + stopifnot(inherits(try(.ps.help.getHelpTopics("lm", 0L), silent = TRUE), "try-error")) + marker <- "zzzz_ark_help_cache_alias" + stopifnot(length(.ps.help.getHelpTopics(marker, 50L)) == 0L) + metadata <- file.path(library, "stats", "Meta", "hsearch.rds") + db <- readRDS(metadata) + alias <- db[[2L]][1L, , drop = FALSE] + alias[1L, "Alias"] <- marker + db[[2L]] <- rbind(db[[2L]], alias) + saveRDS(db, metadata) + # Force a distinguishable metadata time even on low-resolution filesystems. + # The library directory itself has not changed. + Sys.setFileTime(metadata, Sys.time() + 2) + stopifnot(identical(.ps.help.getHelpTopics(marker, 50L), paste("stats", marker, sep = "\u001f"))) + stopifnot(nrow(utils::help.search(marker, agrep = FALSE)$matches) > 0L) + unlink(file.path(library, "stats"), recursive = TRUE) + stopifnot(length(.ps.help.getHelpTopics(marker, 50L)) == 0L) + TRUE +}) + +"#, ARK_ENVS.positron_ns).unwrap().to::().unwrap() + })); +} From 51c72d8348a6b53f3041712719efd884fd74b6e7 Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Tue, 29 Sep 2026 15:09:38 +0800 Subject: [PATCH 07/10] Fix help correlation test for interleaved IOPub messages --- crates/ark/tests/integration/help.rs | 25 ++++++++++++++++++------- crates/ark_test/src/dummy_frontend.rs | 3 +++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/ark/tests/integration/help.rs b/crates/ark/tests/integration/help.rs index baf7cb912f..1317253dd6 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -22,12 +22,14 @@ use amalthea::socket::comm::CommOutgoingTx; use amalthea::socket::iopub::IOPubMessage; use amalthea::wire::comm_msg::CommWireMsg; use amalthea::wire::comm_open::CommOpen; +use amalthea::wire::jupyter_message::Message; use ark::comm_handler::CommHandler; use ark::comm_handler::CommHandlerContext; use ark::help::r_help::RHelp; use ark::help_proxy; use ark::modules::ARK_ENVS; use ark::r_task::r_task; +use ark_test::dummy_frontend::IopubExpectation; use ark_test::dummy_jupyter_header; use ark_test::DummyArkFrontend; use ark_test::IOPubReceiverExt; @@ -343,13 +345,22 @@ fn test_help_search_navigation_correlation() { }), }); frontend.recv_iopub_busy(); - let event = frontend.recv_iopub_comm_msg(); - assert_eq!(event.comm_id, comm_id); - assert_eq!(event.data["method"], "show_help"); - assert_eq!(event.data["params"]["search_id"], "ui-search"); - let reply = frontend.recv_iopub_comm_msg(); - assert_eq!(reply.data["result"], true); - frontend.recv_iopub_idle(); + // Shell idle and comm delivery come from different threads and may interleave. + let messages = frontend.recv_iopub_interleaved(&[&[IopubExpectation::Idle], &[ + IopubExpectation::CommMsg, + IopubExpectation::CommMsg, + ]]); + let comms: Vec<_> = messages + .into_iter() + .filter_map(|message| match message { + Message::CommMsg(message) => Some(message.content), + _ => None, + }) + .collect(); + assert_eq!(comms[0].comm_id, comm_id); + assert_eq!(comms[0].data["method"], "show_help"); + assert_eq!(comms[0].data["params"]["search_id"], "ui-search"); + assert_eq!(comms[1].data["result"], true); // The scope has ended: console search must remain ordinary native Help. frontend.send_execute_request("??plot", ExecuteRequestOptions::default()); diff --git a/crates/ark_test/src/dummy_frontend.rs b/crates/ark_test/src/dummy_frontend.rs index b5816220b3..2f3ba287aa 100644 --- a/crates/ark_test/src/dummy_frontend.rs +++ b/crates/ark_test/src/dummy_frontend.rs @@ -174,6 +174,8 @@ pub enum IopubExpectation { /// `Status(Idle)` whose parent is a shell-channel message /// (`execute_request`). IdleShell, + /// A `CommMsg` message. + CommMsg, /// A `DebugEvent` message. DebugEvent, /// An `ExecuteInput` message. @@ -223,6 +225,7 @@ fn matches_expectation(msg: &Message, expected: &IopubExpectation) -> bool { Message::Status(data) if data.content.execution_state == ExecutionState::Idle ) && is_shell_parent }, + IopubExpectation::CommMsg => matches!(msg, Message::CommMsg(_)), IopubExpectation::DebugEvent => matches!(msg, Message::DebugEvent(_)), IopubExpectation::ExecuteInput => matches!(msg, Message::ExecuteInput(_)), IopubExpectation::ExecuteResult => matches!(msg, Message::ExecuteResult(_)), From 32d10e3cd23f352402464235e180b9dfe82fb0cf Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Tue, 29 Sep 2026 16:58:13 +0800 Subject: [PATCH 08/10] Format R Help code and exercise real search RPC navigation --- crates/ark/src/modules/positron/help.R | 53 ++++++++++++++++++++------ crates/ark/tests/integration/help.rs | 3 ++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/ark/src/modules/positron/help.R b/crates/ark/src/modules/positron/help.R index c22238f350..cb5b063db4 100644 --- a/crates/ark/src/modules/positron/help.R +++ b/crates/ark/src/modules/positron/help.R @@ -82,27 +82,48 @@ help <- function(topic, package = NULL) { function() { libraries <- .libPaths() - packages <- unlist(lapply(libraries, list.dirs, recursive = FALSE), use.names = FALSE) - metadata <- unlist(lapply( - c("package.rds", "hsearch.rds", "vignette.rds", "demo.rds"), - function(name) file.path(packages, "Meta", name) - ), use.names = FALSE) + packages <- unlist( + lapply(libraries, list.dirs, recursive = FALSE), + use.names = FALSE + ) + metadata <- unlist( + lapply( + c("package.rds", "hsearch.rds", "vignette.rds", "demo.rds"), + function(name) file.path(packages, "Meta", name) + ), + use.names = FALSE + ) current <- list( libraries = libraries, - files = file.info(c(libraries, metadata))[, c("size", "mtime", "ctime"), drop = FALSE], + files = file.info(c(libraries, metadata))[, + c("size", "mtime", "ctime"), + drop = FALSE + ], locale = Sys.getlocale("LC_CTYPE"), collation = Sys.getlocale("LC_COLLATE"), types = getOption("help.search.types") ) if (!identical(current, signature)) { - matches <- utils::help.search(".", fields = "alias", package = NULL, rebuild = TRUE)$matches + matches <- utils::help.search( + ".", + fields = "alias", + package = NULL, + rebuild = TRUE + )$matches matches <- matches[matches[, "Type"] == "help", , drop = FALSE] - topics <- unique(data.frame(package = matches[, "Package"], label = matches[, "Entry"])) + topics <- unique(data.frame( + package = matches[, "Package"], + label = matches[, "Entry"] + )) labels <- tolower(topics$label) sorted <- order(labels, topics$package) index <<- list( labels = labels[sorted], - entries = paste(topics$package[sorted], topics$label[sorted], sep = "\u001f") + entries = paste( + topics$package[sorted], + topics$label[sorted], + sep = "\u001f" + ) ) signature <<- current } @@ -114,7 +135,13 @@ help <- function(topic, package = NULL) { # exact label, prefix, then substring; alphabetical within each group. #' @export .ps.help.getHelpTopics <- function(query, limit) { - if (length(limit) != 1L || is.na(limit) || limit < 1L || limit > 50L || limit != as.integer(limit)) { + if ( + length(limit) != 1L || + is.na(limit) || + limit < 1L || + limit > 50L || + limit != as.integer(limit) + ) { stop("Help suggestion limit must be between 1 and 50.") } query <- tolower(trimws(query)) @@ -124,7 +151,11 @@ help <- function(topic, package = NULL) { index <- .ps.help.searchIndex() hits <- which(grepl(query, index$labels, fixed = TRUE)) labels <- index$labels[hits] - rank <- ifelse(labels == query, 0L, ifelse(startsWith(labels, query), 1L, 2L)) + rank <- ifelse( + labels == query, + 0L, + ifelse(startsWith(labels, query), 1L, 2L) + ) index$entries[utils::head(hits[order(rank)], limit)] } diff --git a/crates/ark/tests/integration/help.rs b/crates/ark/tests/integration/help.rs index 1317253dd6..fe9d4a55ff 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -335,11 +335,14 @@ fn wait_until_proxy_stops(port: u16) { #[test] fn test_help_search_navigation_correlation() { let frontend = DummyArkFrontend::lock(); + // This full kernel test needs HTML navigation; unit-test mode suppresses it. + frontend.execute_request_invisibly("options(ark.testing = FALSE)"); let comm_id = open_help_comm(&frontend); frontend.send_shell(CommWireMsg { comm_id: comm_id.clone(), data: serde_json::json!({ "jsonrpc": "2.0", + "id": "help-search-request", "method": "search_help", "params": { "query": "linear model", "search_id": "ui-search" } }), From 764632392cefc689b7501135a238aa03d6f103a0 Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Fri, 2 Oct 2026 14:28:02 +0800 Subject: [PATCH 09/10] Handle incomplete regexes in R Help search (#7) Escape invalid regular expressions for literal R Help search while preserving valid regex and native fuzzy matching. Add coverage for query edge cases and navigation correlation. --- crates/ark/src/modules/positron/help.R | 19 ++++++- crates/ark/tests/integration/help.rs | 68 +++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/ark/src/modules/positron/help.R b/crates/ark/src/modules/positron/help.R index cb5b063db4..8681f5ac0d 100644 --- a/crates/ark/src/modules/positron/help.R +++ b/crates/ark/src/modules/positron/help.R @@ -64,7 +64,7 @@ help <- function(topic, package = NULL) { #' @export .ps.help.searchHelp <- function(query) { .ps.help.searchIndex() - results <- utils::help.search(query, package = NULL) + results <- .ps.help.searchResults(query) if (!in_ark_tests()) { print(results) @@ -73,6 +73,23 @@ help <- function(topic, package = NULL) { TRUE } +# Keep native regexp and fuzzy matching for valid patterns, but treat incomplete +# regexps (including R aliases such as `[.data.frame`) as literal text. Validate +# separately so an unrelated help.search() error is not mistaken for a bad query. +.ps.help.searchResults <- function(query) { + valid <- suppressWarnings(tryCatch( + { + grepl(query, "", ignore.case = TRUE) + TRUE + }, + error = function(err) FALSE + )) + if (!valid) { + query <- gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", query) + } + utils::help.search(query, package = NULL) +} + # Cache sorted aliases, rebuilding native R's search database when library metadata # changes. Checking metadata also catches in-place updates that do not change the # library directory's mtime (which is what help.search() checks itself). diff --git a/crates/ark/tests/integration/help.rs b/crates/ark/tests/integration/help.rs index fe9d4a55ff..4da2379228 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -170,6 +170,72 @@ fn test_help_search_comm() { .any(|topic| { topic.label == "plot" && topic.topic == "graphics::plot" })); } +#[test] +fn test_help_search_query_edge_cases() { + let r_help = TestRHelp::new(); + for query in [ + "[.data.frame", + "c(", + "^lm$", + "regresion", + "zzzz_ark_no_help_match", + ] { + r_help.test_search(query, "help-search-edge-case"); + } + let topics = r_help.get_topics("[.data.frame", 50, "help-literal-topics"); + assert_eq!(topics[0].label, "[.data.frame"); + assert_eq!(topics[0].topic, "base::[.data.frame"); + + assert!(r_task(|| { + harp::parse_eval0( + r#" +local({ + # Invalid regexps fall back to literal matching, with no warning escaping. + old <- options(warn = 2) + on.exit(options(old)) + for (query in c("[.data.frame", "c(", "\\", "[.^$|?*+(){}\\")) { + results <- .ps.help.searchResults(query) + stopifnot(inherits(results, "hsearch")) + stopifnot(grepl(results$pattern, query)) + stopifnot(identical(results$type, "regexp")) + } + results <- .ps.help.searchResults("[.data.frame") + stopifnot(any(results$matches[, "Entry"] == "[.data.frame")) + stopifnot(identical( + results, + utils::help.search("\\[\\.data\\.frame", package = NULL) + )) + stopifnot(identical( + .ps.help.searchResults("c("), + utils::help.search("c\\(", package = NULL) + )) + + # Valid regexps, native fuzzy matching, and no-match results are unchanged. + for (query in c("^lm$", "regresion", "zzzz_ark_no_help_match")) { + stopifnot(identical( + .ps.help.searchResults(query), + utils::help.search(query, package = NULL) + )) + } + stopifnot(identical(.ps.help.searchResults("regresion")$type, "fuzzy")) + stopifnot(nrow(.ps.help.searchResults("^lm$")$matches) > 0L) + stopifnot(nrow(.ps.help.searchResults("zzzz_ark_no_help_match")$matches) == 0L) + + # Unrelated native search errors must still reach the caller. + old_types <- options(help.search.types = "invalid-type") + on.exit(options(old_types), add = TRUE) + stopifnot(inherits(try(.ps.help.searchResults("[.data.frame"), silent = TRUE), "try-error")) + TRUE +}) +"#, + ARK_ENVS.positron_ns, + ) + .unwrap() + .to::() + .unwrap() + })); +} + #[test] fn test_custom_help_handlers() { let r_help = TestRHelp::new(); @@ -344,7 +410,7 @@ fn test_help_search_navigation_correlation() { "jsonrpc": "2.0", "id": "help-search-request", "method": "search_help", - "params": { "query": "linear model", "search_id": "ui-search" } + "params": { "query": "[.data.frame", "search_id": "ui-search" } }), }); frontend.recv_iopub_busy(); From 59e1479b1672053da9f89725eb504ad585b888bd Mon Sep 17 00:00:00 2001 From: Dianyi Yang Date: Tue, 6 Oct 2026 12:16:29 +0800 Subject: [PATCH 10/10] Fix qualified punctuation aliases in R Help (#8) * Resolve qualified punctuation help aliases before expressions * Preserve literal colon and unqualified namespace aliases --- crates/ark/src/help/r_help.rs | 14 ++- crates/ark/src/modules/positron/help.R | 43 ++++--- crates/ark/tests/integration/help.rs | 163 +++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 16 deletions(-) diff --git a/crates/ark/src/help/r_help.rs b/crates/ark/src/help/r_help.rs index 06052219a6..85f2594a4e 100644 --- a/crates/ark/src/help/r_help.rs +++ b/crates/ark/src/help/r_help.rs @@ -198,6 +198,18 @@ impl RHelp { #[tracing::instrument(level = "trace", skip(self))] fn show_help_topic(&self, topic: String) -> anyhow::Result { + // Suggestions contain literal package-qualified aliases, including `$` and + // `@`. Prefer their documentation before trying a custom expression handler. + if topic.contains("::") && + RFunction::from(".ps.help.showHelpTopic") + .add(topic.clone()) + .param("qualified_only", true) + .call()? + .to::()? + { + return Ok(true); + } + let topic = HelpTopic::parse(topic); let found = match topic { @@ -311,7 +323,7 @@ enum HelpTopic { // no obvious expression syntax — e.g. "abs", "base::abs" Simple(String), // contains expression syntax — e.g. "tensorflow::tf$abs", "model@coef" - // such that there will never exist a help topic with that name + // after checking for a literal package-qualified help alias Expression(String), } diff --git a/crates/ark/src/modules/positron/help.R b/crates/ark/src/modules/positron/help.R index 8681f5ac0d..ae6e5ccd69 100644 --- a/crates/ark/src/modules/positron/help.R +++ b/crates/ark/src/modules/positron/help.R @@ -41,13 +41,25 @@ help <- function(topic, package = NULL) { # Show help on a topic. Returns a logical value indicating whether help was # found. #' @export -.ps.help.showHelpTopic <- function(topic) { +.ps.help.showHelpTopic <- function(topic, qualified_only = FALSE) { info <- split_topic(topic) topic <- info$topic package <- info$package # Try to find help on the topic. - results <- help(topic, package) + if (qualified_only) { + if (is.null(package)) { + return(FALSE) + } + # A qualified expression may not be an alias (or name an installed package). + # Leave it to the custom handler when literal help cannot be found. + results <- tryCatch( + suppressWarnings(help(topic, package)), + error = function(e) character() + ) + } else { + results <- help(topic, package) + } # If we found results of any kind, show them. # If we are running ark tests, don't show the results as this requires @@ -178,19 +190,20 @@ help <- function(topic, package = NULL) { # Resolve the package specifier, if there is one split_topic <- function(topic) { - # Try `:::` first, as `::` will match both - components <- strsplit(topic, ":::")[[1L]] - if (length(components) > 1L) { - package <- components[[1L]] - topic <- components[[2L]] - return(list(topic = topic, package = package)) - } - - components <- strsplit(topic, "::")[[1L]] - if (length(components) > 1L) { - package <- components[[1L]] - topic <- components[[2L]] - return(list(topic = topic, package = package)) + separator <- regexpr("::", topic, fixed = TRUE)[[1L]] + if (separator > 1L) { + package <- substr(topic, 1L, separator - 1L) + alias <- substring(topic, separator + 2L) + # Preserve internal `pkg:::name` lookups, but keep literal `:` and `::` + # aliases intact in `pkg:::` and `pkg::::`. + if ( + nchar(alias) > 1L && + startsWith(alias, ":") && + !startsWith(alias, "::") + ) { + alias <- substring(alias, 2L) + } + return(list(topic = alias, package = package)) } list(topic = topic, package = NULL) diff --git a/crates/ark/tests/integration/help.rs b/crates/ark/tests/integration/help.rs index 4da2379228..53bce071ee 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -261,6 +261,14 @@ fn test_custom_help_handlers() { r_help.test_topic("obj$hello", "help-test-id-4"); assert!(r_task(|| harp::parse_eval_global("called").unwrap().to::()).unwrap()); + r_task(|| { + harp::parse_eval_global("called <- FALSE").unwrap(); + }); + r_help.test_topic( + "base::list(hello = obj$hello)$hello", + "qualified-expression", + ); + assert!(r_task(|| harp::parse_eval_global("called").unwrap().to::()).unwrap()); } /// End-to-end test that a help URL browsed from R reaches the frontend as a @@ -479,3 +487,158 @@ local({ "#, ARK_ENVS.positron_ns).unwrap().to::().unwrap() })); } + +#[test] +fn test_help_punctuation_suggestions_open_exact_target() { + let frontend = DummyArkFrontend::lock(); + frontend.execute_request_invisibly("options(ark.testing = FALSE)"); + let comm_id = open_help_comm(&frontend); + // R versions differ in whether `$.data.frame` is documented. Add a fixture + // alias to a copied help database so its suggestion always round-trips. + frontend.execute_request_invisibly( + r#" + alias_library <- tempfile("ark-help-aliases-") + dir.create(alias_library) + alias_package <- file.path(alias_library, "arkhelpaliases") + dir.create(alias_package) + writeLines(c("Package: arkhelpaliases", "Version: 1.0"), + file.path(alias_package, "DESCRIPTION")) + stopifnot(all(file.copy(file.path(find.package("base"), c("Meta", "help")), + alias_package, recursive = TRUE))) + for (extension in c("rdb", "rdx")) { + stopifnot(file.rename( + file.path(alias_package, "help", paste0("base.", extension)), + file.path(alias_package, "help", paste0("arkhelpaliases.", extension)))) + } + metadata <- file.path(alias_package, "Meta", "package.rds") + description <- readRDS(metadata) + description$DESCRIPTION["Package"] <- "arkhelpaliases" + saveRDS(description, metadata) + metadata <- file.path(alias_package, "Meta", "hsearch.rds") + db <- readRDS(metadata) + for (i in seq_along(db)) db[[i]][, "Package"] <- "arkhelpaliases" + db[[2L]][db[[2L]][, "Alias"] == "$", "Alias"] <- "$.data.frame" + saveRDS(db, metadata) + metadata <- file.path(alias_package, "help", "aliases.rds") + aliases <- readRDS(metadata) + aliases["$.data.frame"] <- "Extract" + saveRDS(aliases, metadata) + old_alias_paths <- .libPaths() + .libPaths(c(alias_library, old_alias_paths)) + "#, + ); + + for (package, alias, target) in [ + ("arkhelpaliases", "$.data.frame", "Extract"), + ("base", "$", "Extract"), + ("base", "$<-", "Extract"), + ("base", "@", "slotOp"), + ("base", "@<-", "slotOp"), + ("base", ":", "Colon"), + ("base", "::", "ns-dblcolon"), + ("base", ":::", "ns-dblcolon"), + ("methods", "$<-,envRefClass-method", "stdRefClass"), + ("methods", "$<-,localRefClass-method", "localRefClass"), + ] { + // Select the exact suggestion and round-trip it through the Help RPC. + frontend.send_shell(CommWireMsg { + comm_id: comm_id.clone(), + data: serde_json::json!({ + "jsonrpc": "2.0", "id": "aliases", "method": "get_help_topics", + "params": { "query": alias, "limit": 50 } + }), + }); + frontend.recv_iopub_busy(); + let messages = frontend + .recv_iopub_interleaved(&[&[IopubExpectation::Idle], &[IopubExpectation::CommMsg]]); + let reply = messages + .into_iter() + .find_map(|message| match message { + Message::CommMsg(message) => Some(message.content.data), + _ => None, + }) + .unwrap(); + let suggestions: Vec = + serde_json::from_value(reply["result"].clone()).unwrap(); + let suggestion = suggestions + .iter() + .find(|suggestion| { + suggestion.label == alias && suggestion.detail.as_deref() == Some(package) + }) + .unwrap(); + assert_eq!(suggestion.topic, format!("{package}::{alias}")); + assert_help_topic_target(&frontend, &comm_id, &suggestion.topic, package, target); + } + for (topic, target) in [ + (":", "Colon"), + ("::", "ns-dblcolon"), + (":::", "ns-dblcolon"), + ] { + assert_help_topic_target(&frontend, &comm_id, topic, "base", target); + } + frontend.execute_request_invisibly( + ".libPaths(old_alias_paths); unlink(alias_library, recursive = TRUE)", + ); +} + +fn assert_help_topic_target( + frontend: &DummyArkFrontend, + comm_id: &str, + topic: &str, + package: &str, + target: &str, +) { + frontend.send_shell(CommWireMsg { + comm_id: String::from(comm_id), + data: serde_json::json!({ + "jsonrpc": "2.0", "id": "open-alias", "method": "show_help_topic", + "params": { "topic": topic } + }), + }); + frontend.recv_iopub_busy(); + let messages = frontend.recv_iopub_interleaved(&[&[IopubExpectation::Idle], &[ + IopubExpectation::CommMsg, + IopubExpectation::CommMsg, + ]]); + let comms: Vec<_> = messages + .into_iter() + .filter_map(|message| match message { + Message::CommMsg(message) => Some(message.content), + _ => None, + }) + .collect(); + assert_eq!(comms[0].data["method"], "show_help"); + let url = comms[0].data["params"]["content"].as_str().unwrap(); + assert!(url.ends_with(&format!("/library/{package}/html/{target}.html"))); + assert_eq!(comms[1].data["result"], true); +} + +#[test] +fn test_help_qualified_topic_splitting() { + assert!(r_task(|| { + harp::parse_eval0( + r#" +local({ + for (alias in c("$", "$<-", "$.data.frame", "@", "@<-", ":", "::", ":::")) { + stopifnot(identical(split_topic(paste0("base::", alias)), + list(topic = alias, package = "base"))) + } + for (alias in c(":", "::", ":::")) { + stopifnot(identical(split_topic(alias), list(topic = alias, package = NULL))) + } + stopifnot(identical(split_topic("utils:::find"), + list(topic = "find", package = "utils"))) + stopifnot(identical(split_topic("tensorflow::tf$abs"), + list(topic = "tf$abs", package = "tensorflow"))) + stopifnot(identical(split_topic("obj$hello"), + list(topic = "obj$hello", package = NULL))) + TRUE +}) +"#, + ARK_ENVS.positron_ns, + ) + .unwrap() + .to::() + .unwrap() + })); +}