diff --git a/crates/amalthea/src/comm/help_comm.rs b/crates/amalthea/src/comm/help_comm.rs index ff4ea415f..77c97b61c 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,27 @@ 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, + + /// 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. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ShowHelpParams { @@ -45,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, } /** @@ -62,6 +101,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), + + /// Find help topics for autocomplete. + /// + /// 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(GetHelpTopicsParams), + } /** @@ -74,6 +127,13 @@ pub enum HelpBackendReply { /// Help notification. ShowHelpTopicReply(bool), + /// 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. + GetHelpTopicsReply(Vec), + } /** diff --git a/crates/ark/src/help/r_help.rs b/crates/ark/src/help/r_help.rs index b302df9e6..85f2594a4 100644 --- a/crates/ark/src/help/r_help.rs +++ b/crates/ark/src/help/r_help.rs @@ -5,10 +5,13 @@ // // +use std::cell::RefCell; + 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; @@ -31,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)] @@ -75,6 +98,36 @@ impl RHelp { Err(err) => Err(err), } }, + 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(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 + .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)) + }, } } @@ -136,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); @@ -144,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 { @@ -257,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), } @@ -282,3 +348,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 ea28385f0..ae6e5ccd6 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 @@ -60,21 +72,138 @@ help <- function(topic, package = NULL) { length(results) > 0 } -# 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)) +# Search all installed help documentation and show R's native HTML results page. +#' @export +.ps.help.searchHelp <- function(query) { + .ps.help.searchIndex() + results <- .ps.help.searchResults(query) + + if (!in_ark_tests()) { + print(results) } - components <- strsplit(topic, "::")[[1L]] - if (length(components) > 1L) { - package <- components[[1L]] - topic <- components[[2L]] - return(list(topic = topic, package = package)) + 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). +.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(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 +split_topic <- function(topic) { + 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 cd751c4e7..53bce071e 100644 --- a/crates/ark/tests/integration/help.rs +++ b/crates/ark/tests/integration/help.rs @@ -11,18 +11,25 @@ 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; +use amalthea::comm::help_comm::SearchHelpParams; 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 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; @@ -50,10 +57,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 { @@ -75,19 +79,44 @@ 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); - }, - } - }, - _ => { - 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), + search_id: String::from(id), + }); + assert_eq!( + self.request(request, id), + HelpBackendReply::SearchHelpReply(true) + ); + } + + 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:?}"), } } } @@ -126,6 +155,87 @@ 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(); + + r_help.test_search("linear model", "help-search-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" })); +} + +#[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(); @@ -151,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 @@ -194,6 +312,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")); @@ -286,3 +405,240 @@ 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(); + // 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": "[.data.frame", "search_id": "ui-search" } + }), + }); + frontend.recv_iopub_busy(); + // 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()); + 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() + })); +} + +#[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() + })); +} diff --git a/crates/ark_test/src/dummy_frontend.rs b/crates/ark_test/src/dummy_frontend.rs index b5816220b..2f3ba287a 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(_)),