use super::text_utils::{
display_width, exceeds_width, pad_to_width, scroll_display, truncate_to_width,
};
use super::{
MinimumSize, TextScrollState, check_terminal_too_small, render_size_warning,
with_alternate_screen,
};
use crate::fuzzy::fuzzy_match_with_case_preference;
use arf_harp::HarpResult;
use arf_harp::help::{
HelpTargetResolver, HelpTopic, get_help_topics_from_paths, get_vignette_text,
};
use arf_harp::help_bridge::{PreparedHelpPage, PreparedHelpRequest};
use arf_harp::lib_paths::{cached_lib_paths, refresh_lib_paths_from_r};
use crossterm::{
ExecutableCommand, cursor,
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind},
queue,
style::Stylize,
terminal::{self, BeginSynchronizedUpdate, EndSynchronizedUpdate},
};
use std::io::{self, Write};
use std::time::Duration;
const MAX_FILTERED_RESULTS: usize = 500;
const MIN_SIZE: MinimumSize = MinimumSize { cols: 30, rows: 8 };
pub fn run_help_browser(query: &str) -> io::Result<()> {
let libraries =
match help_library_paths_after_refresh(refresh_lib_paths_from_r(), cached_lib_paths) {
Ok(paths) => paths,
Err(error) => {
println!("# Error loading help database: {error}");
return Ok(());
}
};
let topics = get_help_topics_from_paths(&libraries);
if topics.is_empty() {
println!("# No help topics found. Make sure R packages are installed.");
return Ok(());
}
let mut browser = HelpBrowser::new(topics, libraries, query);
browser.run()
}
fn help_library_paths_after_refresh(
refresh: HarpResult<Vec<String>>,
cached: impl FnOnce() -> Vec<String>,
) -> HarpResult<Vec<String>> {
match refresh {
Ok(paths) => Ok(paths),
Err(error) => {
let paths = cached();
if paths.is_empty() {
return Err(error);
}
log::warn!(
"Could not refresh R library paths for help; using the previous snapshot: {error}"
);
Ok(paths)
}
}
}
struct HelpBrowser {
library_paths: Vec<String>,
topics: Vec<HelpTopic>,
query: String,
cursor_pos: usize,
filtered: Vec<(HelpTopic, u32)>,
selected: usize,
scroll_offset: usize,
text_scroll: TextScrollState,
}
impl HelpBrowser {
fn new(topics: Vec<HelpTopic>, library_paths: Vec<String>, query: &str) -> Self {
let mut browser = HelpBrowser {
library_paths,
topics,
query: query.to_string(),
cursor_pos: query.chars().count(),
filtered: Vec::new(),
selected: 0,
scroll_offset: 0,
text_scroll: TextScrollState::new(),
};
browser.update_filter();
browser
}
fn update_filter(&mut self) {
if self.query.is_empty() {
self.filtered = self.topics.iter().map(|t| (t.clone(), 0)).collect();
self.filtered.truncate(MAX_FILTERED_RESULTS);
} else {
self.filtered = fuzzy_search_topics(&self.topics, &self.query);
}
self.selected = 0;
self.scroll_offset = 0;
let query_len = self.query.chars().count();
if self.cursor_pos > query_len {
self.cursor_pos = query_len;
}
}
fn run(&mut self) -> io::Result<()> {
with_alternate_screen(|| self.run_inner())
}
fn run_inner(&mut self) -> io::Result<()> {
let mut stdout = io::stdout();
let poll_timeout = Duration::from_millis(50); let mut needs_redraw = true;
let mut too_small;
loop {
if self.update_text_scroll() {
needs_redraw = true;
}
too_small = check_terminal_too_small(&MIN_SIZE).is_some();
if needs_redraw {
self.render(&mut stdout)?;
needs_redraw = false;
}
if event::poll(poll_timeout)? {
let ev = event::read()?;
log::debug!("help_browser: received event: {:?}", ev);
match ev {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
log::debug!(
"help_browser: ignoring non-press key event: {:?}",
key.kind
);
continue;
}
needs_redraw = true;
log::debug!(
"help_browser: key event: code={:?}, modifiers={:?}",
key.code,
key.modifiers
);
if too_small {
match (key.code, key.modifiers) {
(KeyCode::Esc, _)
| (KeyCode::Char('q'), KeyModifiers::NONE)
| (KeyCode::Char('c'), KeyModifiers::CONTROL)
| (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
break;
}
_ => continue,
}
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _)
| (KeyCode::Char('c'), KeyModifiers::CONTROL)
| (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
break;
}
(KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL)
if self.selected > 0 =>
{
self.selected -= 1;
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
}
(KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
let visible_rows = visible_result_rows();
if self.selected + 1 < self.filtered.len() {
self.selected += 1;
if self.selected >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected - visible_rows + 1;
}
}
}
(KeyCode::Enter, _) | (KeyCode::Tab, _) => {
if let Some((topic, _)) = self.filtered.get(self.selected) {
let title = topic.qualified_name();
match topic.entry_type.as_str() {
"vignette" => {
match get_vignette_text(&topic.topic, &topic.package) {
Ok(text) => {
if let Err(e) =
display_help_pager(&title, &text, false)
{
log::error!(
"help_browser: pager error: {}",
e
);
}
}
Err(e) => {
let msg = format!("{}", e);
if let Err(e) =
display_help_pager(&title, &msg, false)
{
log::error!(
"help_browser: pager error: {}",
e
);
}
}
}
}
"demo" => {
let msg = format!(
r#"This is a demo entry.
To run the demo, execute in R:
demo("{name}", package = "{pkg}")"#,
name = topic.topic,
pkg = topic.package,
);
if let Err(e) = display_help_pager(&title, &msg, false)
{
log::error!("help_browser: pager error: {}", e);
}
}
_ => {
let result =
HelpTargetResolver::prepare_candidate(topic)
.map_err(io::Error::other)
.and_then(|page| {
display_help_pages(
vec![page],
self.library_paths.clone(),
false,
)
});
if let Err(e) = result {
let message = help_page_load_error_message(&e);
if let Err(pager_error) =
display_help_pager(&title, &message, false)
{
log::error!(
"help_browser: failed to display help error: {pager_error}"
);
}
}
}
}
needs_redraw = true;
}
}
(KeyCode::Backspace, _) if self.cursor_pos > 0 => {
let byte_pos = self
.query
.char_indices()
.nth(self.cursor_pos - 1)
.map(|(i, _)| i)
.unwrap_or(0);
self.query.remove(byte_pos);
self.cursor_pos -= 1;
self.update_filter();
}
(KeyCode::Delete, _)
if self.cursor_pos < self.query.chars().count() =>
{
let byte_pos = self
.query
.char_indices()
.nth(self.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(self.query.len());
self.query.remove(byte_pos);
self.update_filter();
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
self.query.clear();
self.cursor_pos = 0;
self.update_filter();
}
(KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
let byte_pos = self
.query
.char_indices()
.nth(self.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(self.query.len());
self.query.insert(byte_pos, c);
self.cursor_pos += 1;
self.update_filter();
}
(KeyCode::Left, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL)
if self.cursor_pos > 0 =>
{
self.cursor_pos -= 1;
}
(KeyCode::Right, _) | (KeyCode::Char('f'), KeyModifiers::CONTROL)
if self.cursor_pos < self.query.chars().count() =>
{
self.cursor_pos += 1;
}
(KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
self.cursor_pos = 0;
}
(KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.cursor_pos = self.query.chars().count();
}
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => {
needs_redraw = true;
if self.selected > 0 {
self.selected -= 1;
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
}
}
MouseEventKind::ScrollDown => {
needs_redraw = true;
let visible_rows = visible_result_rows();
if self.selected + 1 < self.filtered.len() {
self.selected += 1;
if self.selected >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected - visible_rows + 1;
}
}
}
_ => {}
},
Event::Resize(_, _) => {
needs_redraw = true;
}
_ => {}
}
}
}
Ok(())
}
fn update_text_scroll(&mut self) -> bool {
self.text_scroll.update(self.selected)
}
fn render(&self, stdout: &mut io::Stdout) -> io::Result<()> {
if let Some((cols, rows)) = check_terminal_too_small(&MIN_SIZE) {
return render_size_warning(stdout, cols, rows, &MIN_SIZE);
}
queue!(stdout, BeginSynchronizedUpdate)?;
stdout.execute(cursor::MoveTo(0, 0))?;
stdout.execute(cursor::Hide)?;
let (cols, _rows) = terminal::size().unwrap_or((80, 24));
let width = cols as usize;
let header = format!("─ Help Search [{} topics] ─", self.filtered.len());
let padded_header = format!("{:─<width$}", header, width = width);
println!("\r{}", padded_header.dark_grey());
let before_cursor: String = self.query.chars().take(self.cursor_pos).collect();
let after_cursor: String = self.query.chars().skip(self.cursor_pos).collect();
let query_line = format!(" Filter: {}_{}", before_cursor, after_cursor);
println!("\r{}", pad_to_width(&query_line, width));
println!("\r{}", "─".repeat(width).dark_grey());
let (name_width, title_width) = calculate_layout(width);
let visible_rows = visible_result_rows();
for i in 0..visible_rows {
let idx = self.scroll_offset + i;
if idx < self.filtered.len() {
let (topic, _score) = &self.filtered[idx];
let prefix = if idx == self.selected { " > " } else { " " };
let name = topic.qualified_name();
let (display_name, display_title) = if idx == self.selected {
let name_truncated = exceeds_width(&name, name_width);
let title_truncated = exceeds_width(&topic.title, title_width);
if name_truncated || title_truncated {
let (scrolled_name, _) =
scroll_display(&name, name_width, self.text_scroll.scroll_pos);
let (scrolled_title, _) =
scroll_display(&topic.title, title_width, self.text_scroll.scroll_pos);
(scrolled_name, scrolled_title)
} else {
(name.clone(), topic.title.clone())
}
} else {
(
truncate_to_width(&name, name_width),
truncate_to_width(&topic.title, title_width),
)
};
let padded_name = pad_to_width(&display_name, name_width);
let content = format!("{}{} {}", prefix, padded_name, display_title);
let line = pad_to_width(&content, width);
if idx == self.selected {
println!("\r{}", line.reverse());
} else {
let name_part = format!("{}{} ", prefix, padded_name);
let title_part = truncate_to_width(
&display_title,
width.saturating_sub(display_width(&name_part)),
);
let padding_len = width
.saturating_sub(display_width(&name_part) + display_width(&title_part));
print!(
"\r{}{}{}\n",
name_part,
title_part.dark_grey(),
" ".repeat(padding_len)
);
}
} else {
println!("\r{}", " ".repeat(width));
}
}
println!("\r{}", "─".repeat(width).dark_grey());
let footer_plain = " ↑↓ navigate Tab/Enter select Esc exit";
println!("\r{}", pad_to_width(footer_plain, width).dark_grey());
queue!(stdout, EndSynchronizedUpdate)?;
stdout.flush()?;
Ok(())
}
}
fn fuzzy_search_topics(topics: &[HelpTopic], query: &str) -> Vec<(HelpTopic, u32)> {
let mut results: Vec<(HelpTopic, bool, u32)> = topics
.iter()
.filter_map(|topic| {
let name = topic.qualified_name();
let mut best_rank = None;
let mut consider_candidate = |candidate: &str, weight: u32| {
if let Some(matched) = fuzzy_match_with_case_preference(query, candidate) {
let score = matched.fuzzy_match.score / weight;
let rank = (matched.case_preferred, score);
best_rank = Some(best_rank.map_or(rank, |best: (bool, u32)| best.max(rank)));
}
};
consider_candidate(&name, 1);
consider_candidate(&topic.topic, 1);
let max_candidate_len = topic
.aliases
.iter()
.map(String::len)
.chain(topic.help_key.iter().map(String::len))
.max()
.unwrap_or(0);
let mut qualified_candidate =
String::with_capacity(topic.package.len() + 2 + max_candidate_len);
for candidate in topic
.aliases
.iter()
.map(String::as_str)
.chain(topic.help_key.as_deref())
{
consider_candidate(candidate, 1);
qualified_candidate.clear();
qualified_candidate.push_str(&topic.package);
qualified_candidate.push_str("::");
qualified_candidate.push_str(candidate);
consider_candidate(&qualified_candidate, 1);
}
consider_candidate(&topic.title, 2);
best_rank.map(|(case_preferred, score)| (topic.clone(), case_preferred, score))
})
.collect();
results.sort_by_key(|entry| std::cmp::Reverse((entry.1, entry.2)));
results.truncate(MAX_FILTERED_RESULTS);
results
.into_iter()
.map(|(topic, _, score)| (topic, score))
.collect()
}
fn calculate_layout(cols: usize) -> (usize, usize) {
let prefix_width = 3; let spacing = 1; let name_width = (cols / 3).max(20); let title_width = cols.saturating_sub(prefix_width + name_width + spacing + 1);
(name_width, title_width)
}
fn visible_result_rows() -> usize {
let (_, rows) = terminal::size().unwrap_or((80, 24));
(rows as usize).saturating_sub(5).max(3)
}
fn display_help_pager(title: &str, content: &str, manage_alternate_screen: bool) -> io::Result<()> {
use super::help_content::HelpContent;
use super::{PagerConfig, run};
let (cols, rows) = terminal::size().unwrap_or((80, 24));
let mut content = HelpContent::new(content, cols as usize, rows as usize);
let config = PagerConfig {
title,
footer_hint: "↑↓/jk scroll / search q/Esc back",
manage_alternate_screen,
};
run(&mut content, &config)
}
pub(crate) fn display_prepared_help_request(request: &PreparedHelpRequest) -> io::Result<()> {
if request.pages.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"prepared help request has no pages",
));
}
display_help_pages(request.pages.clone(), cached_lib_paths(), true)
}
fn display_help_pages(
pages: Vec<PreparedHelpPage>,
libraries: Vec<String>,
manage_alternate_screen: bool,
) -> io::Result<()> {
use super::help_session::HelpViewer;
use super::{PagerConfig, run};
let (cols, rows) = terminal::size().unwrap_or((80, 24));
let mut viewer = HelpViewer::new(pages, libraries, cols as usize, rows as usize);
let config = PagerConfig {
manage_alternate_screen,
..PagerConfig::default()
};
run(&mut viewer, &config)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct HelpPageSelectorState {
page_count: usize,
pub(super) selected: Option<usize>,
confirmed: Option<usize>,
}
impl HelpPageSelectorState {
pub(super) fn new(page_count: usize) -> Self {
Self {
page_count,
selected: None,
confirmed: None,
}
}
pub(super) fn move_up(&mut self) -> bool {
let Some(selected) = self.selected else {
self.selected = self.page_count.checked_sub(1);
return self.selected.is_some();
};
if selected == 0 {
self.selected = None;
return true;
}
self.selected = Some(selected - 1);
true
}
pub(super) fn move_down(&mut self) -> Option<bool> {
match self.selected {
None if self.page_count > 0 => {
self.selected = Some(0);
Some(true)
}
Some(selected) if selected + 1 < self.page_count => {
self.selected = Some(selected + 1);
Some(false)
}
_ => None,
}
}
pub(super) fn confirm(&mut self) -> Option<usize> {
let selected = self.selected?;
self.confirmed = Some(selected);
Some(selected)
}
}
pub(super) fn help_page_title(package: &str, display_topic: &str) -> String {
format!("{package}::{display_topic}")
}
fn help_page_load_error_message(error: &io::Error) -> String {
format!("Unable to load this help topic.\n\n{error}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn help_library_snapshot_refresh_and_stale_fallback_policy() {
use std::cell::Cell;
let cached_read = Cell::new(false);
let refreshed = help_library_paths_after_refresh(Ok(vec!["library-a".into()]), || {
cached_read.set(true);
vec!["library-b".into()]
})
.unwrap();
assert_eq!(refreshed, ["library-a"]);
assert!(
!cached_read.get(),
"successful refresh should be authoritative"
);
let previous = help_library_paths_after_refresh(
Err(arf_harp::HarpError::TypeMismatch {
expected: "library path snapshot".into(),
actual: "refresh failed".into(),
}),
|| vec!["library-b".into()],
)
.unwrap();
assert_eq!(previous, ["library-b"]);
let error = help_library_paths_after_refresh(
Err(arf_harp::HarpError::TypeMismatch {
expected: "library path snapshot".into(),
actual: "refresh failed".into(),
}),
Vec::new,
)
.expect_err("an empty cache cannot support :help metadata discovery");
match error {
arf_harp::HarpError::TypeMismatch { expected, actual } => {
assert_eq!(expected, "library path snapshot");
assert_eq!(actual, "refresh failed");
}
other => panic!("refresh error should retain its type: {other}"),
}
}
fn navigation_fixture(name: &str) -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/help_navigation")
.join(name)
}
fn install_navigation_package(library: &std::path::Path, name: &str) -> std::path::PathBuf {
let package_dir = library.join(name);
std::fs::create_dir_all(package_dir.join("Meta")).unwrap();
std::fs::create_dir_all(package_dir.join("help")).unwrap();
for extension in ["rdx", "rdb"] {
std::fs::copy(
navigation_fixture(&format!("resolverpkg.{extension}")),
package_dir.join(format!("help/{name}.{extension}")),
)
.unwrap();
}
std::fs::copy(
navigation_fixture("aliases.rds"),
package_dir.join("help/aliases.rds"),
)
.unwrap();
std::fs::copy(
navigation_fixture("Rd.rds"),
package_dir.join("Meta/Rd.rds"),
)
.unwrap();
std::fs::write(package_dir.join("Meta/package.rds"), []).unwrap();
package_dir
}
#[test]
fn help_snapshot_flows_from_metadata_through_browser_page_to_viewer_resolver() {
use crate::pager::PagerContent;
use crossterm::event::{KeyCode, KeyModifiers};
let temp = tempfile::tempdir().unwrap();
let library_a = temp.path().join("library-a");
let library_b = temp.path().join("library-b");
let source_a = install_navigation_package(&library_a, "resolverpkg");
install_navigation_package(&library_b, "resolverpkg");
let target_a = install_navigation_package(&library_a, "targetpkg");
let target_b = install_navigation_package(&library_b, "targetpkg");
std::fs::write(target_b.join("help/targetpkg.rdx"), b"broken copy").unwrap();
let snapshot = vec![
library_a.to_string_lossy().into_owned(),
library_b.to_string_lossy().into_owned(),
];
let topics = get_help_topics_from_paths(&snapshot);
let mut browser = HelpBrowser::new(topics, snapshot, "resolverpkg");
let selected_index = browser
.filtered
.iter()
.position(|(topic, _)| {
topic.package == "resolverpkg"
&& topic.package_dir == source_a
&& topic.help_key.is_some()
})
.expect("the first installed package copy should appear in metadata results");
browser.selected = selected_index;
let topic = &browser.filtered[browser.selected].0;
let mut page = HelpTargetResolver::prepare_candidate(topic).unwrap();
assert_eq!(page.package_dir, source_a);
page.markdown = "[target](x-r-help:targetpkg/lm)".to_owned();
let mut viewer = super::super::help_session::HelpViewer::new(
vec![page],
browser.library_paths.clone(),
40,
8,
);
assert_eq!(viewer.title(), Some("resolverpkg::mean"));
assert_eq!(
PagerContent::handle_key(&mut viewer, KeyCode::Tab, KeyModifiers::NONE),
Some(super::super::PagerAction::Redraw)
);
assert!(matches!(
PagerContent::handle_key(&mut viewer, KeyCode::Enter, KeyModifiers::NONE),
Some(super::super::PagerAction::ScrollTo(0))
));
assert_eq!(viewer.title(), Some("targetpkg::lm"));
assert_ne!(target_a, target_b);
assert!(
!PagerContent::feedback_message(&viewer)
.unwrap_or_default()
.contains("Unable to open")
);
}
#[test]
fn prepared_help_selector_requires_an_explicit_selection_and_confirmation() {
let mut selector = HelpPageSelectorState::new(2);
assert_eq!(selector.selected, None);
assert_eq!(selector.confirm(), None);
assert_eq!(selector.confirmed, None);
assert_eq!(selector.move_down(), Some(true));
assert_eq!(selector.selected, Some(0));
assert_eq!(selector.move_down(), Some(false));
assert_eq!(selector.selected, Some(1));
assert_eq!(selector.confirm(), Some(1));
assert_eq!(selector.confirmed, Some(1));
}
#[test]
fn prepared_help_selector_navigation_stays_within_candidate_bounds() {
let mut empty = HelpPageSelectorState::new(0);
assert!(!empty.move_up());
assert_eq!(empty.selected, None);
let mut selector = HelpPageSelectorState::new(2);
assert!(selector.move_up());
assert_eq!(selector.selected, Some(1));
assert!(selector.move_up());
assert_eq!(selector.selected, Some(0));
assert!(selector.move_up());
assert_eq!(selector.selected, None);
assert_eq!(selector.move_down(), Some(true));
assert!(selector.move_up());
assert_eq!(selector.selected, None);
assert_eq!(selector.move_down(), Some(true));
assert_eq!(selector.move_down(), Some(false));
assert_eq!(selector.move_down(), None);
assert_eq!(selector.selected, Some(1));
}
#[test]
fn help_page_title_uses_package_and_display_topic() {
assert_eq!(
help_page_title("base", "[.data.frame"),
"base::[.data.frame"
);
}
#[test]
fn help_page_load_error_message_includes_a_concise_context_and_detail() {
let error = io::Error::other("compiled topic was not found");
assert_eq!(
help_page_load_error_message(&error),
"Unable to load this help topic.\n\ncompiled topic was not found"
);
}
#[test]
fn test_truncate_to_width_no_truncation() {
assert_eq!(truncate_to_width("Hello", 10), "Hello");
assert_eq!(truncate_to_width("Hello", 5), "Hello");
}
#[test]
fn test_truncate_to_width_with_truncation() {
assert_eq!(truncate_to_width("Hello World", 8), "Hello W…");
assert_eq!(truncate_to_width("Hello World", 6), "Hello…");
}
#[test]
fn test_truncate_to_width_edge_cases() {
assert_eq!(truncate_to_width("Hi", 1), "…");
assert_eq!(truncate_to_width("Hi", 0), "");
assert_eq!(truncate_to_width("", 5), "");
}
#[test]
fn test_truncate_to_width_unicode() {
assert_eq!(truncate_to_width("日本語テスト", 7), "日本語…");
assert_eq!(truncate_to_width("日本語", 10), "日本語");
}
#[test]
fn test_calculate_layout_standard() {
let (name_width, title_width) = calculate_layout(80);
assert_eq!(name_width, 26);
assert_eq!(title_width, 49);
}
#[test]
fn test_calculate_layout_wide() {
let (name_width, title_width) = calculate_layout(120);
assert_eq!(name_width, 40);
assert_eq!(title_width, 75);
}
#[test]
fn test_calculate_layout_narrow() {
let (name_width, title_width) = calculate_layout(60);
assert_eq!(name_width, 20);
assert_eq!(title_width, 35);
}
#[test]
fn test_calculate_layout_very_narrow() {
let (name_width, title_width) = calculate_layout(40);
assert_eq!(name_width, 20);
assert_eq!(title_width, 15);
}
#[test]
fn test_fuzzy_search_topics() {
let topics = vec![
HelpTopic {
package: "base".to_string(),
package_dir: std::path::PathBuf::from("/base"),
topic: "print".to_string(),
aliases: vec!["print.default".to_string()],
help_key: Some("print".to_string()),
title: "Print Values".to_string(),
entry_type: "help".to_string(),
},
HelpTopic {
package: "dplyr".to_string(),
package_dir: std::path::PathBuf::from("/dplyr"),
topic: "mutate".to_string(),
aliases: vec![],
help_key: None,
title: "Create, modify, and delete columns".to_string(),
entry_type: "help".to_string(),
},
];
let results = fuzzy_search_topics(&topics, "print");
assert!(!results.is_empty());
assert_eq!(results[0].0.topic, "print");
let results = fuzzy_search_topics(&topics, "mut");
assert!(!results.is_empty());
assert_eq!(results[0].0.topic, "mutate");
}
#[test]
fn fuzzy_search_prefers_matching_case_without_filtering_variants() {
let topics = vec![
HelpTopic {
package: "base".to_string(),
package_dir: std::path::PathBuf::from("/base"),
topic: "foo_bar".to_string(),
aliases: vec![],
help_key: None,
title: "Lowercase topic".to_string(),
entry_type: "help".to_string(),
},
HelpTopic {
package: "base".to_string(),
package_dir: std::path::PathBuf::from("/base"),
topic: "Foo".to_string(),
aliases: vec![],
help_key: None,
title: "Capitalized topic".to_string(),
entry_type: "help".to_string(),
},
];
let lowercase_results = fuzzy_search_topics(&topics, "foo");
assert!(
lowercase_results
.iter()
.any(|(topic, _)| topic.topic == "foo_bar")
);
assert!(
lowercase_results
.iter()
.any(|(topic, _)| topic.topic == "Foo")
);
let uppercase_results = fuzzy_search_topics(&topics, "Foo");
assert_eq!(uppercase_results.len(), 2);
assert_eq!(uppercase_results[0].0.topic, "Foo");
assert_eq!(uppercase_results[1].0.topic, "foo_bar");
}
#[test]
fn test_fuzzy_search_topics_empty_query() {
let topics = vec![HelpTopic {
package: "base".to_string(),
package_dir: std::path::PathBuf::from("/base"),
topic: "print".to_string(),
aliases: vec![],
help_key: None,
title: "Print Values".to_string(),
entry_type: "help".to_string(),
}];
let results = fuzzy_search_topics(&topics, "");
assert!(!results.is_empty());
}
#[test]
fn test_fuzzy_search_topics_no_match() {
let topics = vec![HelpTopic {
package: "base".to_string(),
package_dir: std::path::PathBuf::from("/base"),
topic: "print".to_string(),
aliases: vec![],
help_key: None,
title: "Print Values".to_string(),
entry_type: "help".to_string(),
}];
let results = fuzzy_search_topics(&topics, "xyz123");
assert!(results.is_empty());
}
#[test]
fn fuzzy_search_matches_aliases_without_duplicating_topic_results() {
let topic = HelpTopic {
package: "base".to_string(),
package_dir: std::path::PathBuf::from("/base"),
topic: "print".to_string(),
aliases: vec!["print.default".to_string(), "print.value".to_string()],
help_key: Some("print".to_string()),
title: "Print Values".to_string(),
entry_type: "help".to_string(),
};
let results = fuzzy_search_topics(std::slice::from_ref(&topic), "print.value");
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.topic, "print");
let results = fuzzy_search_topics(&[topic], "base::print.value");
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.qualified_name(), "base::print");
}
#[test]
fn fuzzy_search_matches_help_key_bare_and_qualified_without_changing_display_topic() {
let topic = HelpTopic {
package: "base".to_string(),
package_dir: std::path::PathBuf::from("/base"),
topic: "[.data.frame".to_string(),
aliases: vec!["[.data.frame".to_string()],
help_key: Some("Extract.data.frame".to_string()),
title: "Extract or Replace Parts of an Object".to_string(),
entry_type: "help".to_string(),
};
for query in ["Extract.data.frame", "base::Extract.data.frame"] {
let results = fuzzy_search_topics(std::slice::from_ref(&topic), query);
assert_eq!(results.len(), 1, "query {query:?}");
assert_eq!(results[0].0.topic, "[.data.frame");
}
}
#[test]
fn test_exceeds_width() {
assert!(!exceeds_width("Hello", 10));
assert!(!exceeds_width("Hello", 5));
assert!(exceeds_width("Hello World", 8));
assert!(exceeds_width("Hello", 4));
}
#[test]
fn test_scroll_display_no_truncation() {
let (result, max_scroll) = scroll_display("Hello", 10, 0);
assert_eq!(result, "Hello");
assert_eq!(max_scroll, 0);
}
#[test]
fn test_scroll_display_at_start() {
let (result, max_scroll) = scroll_display("Hello World", 8, 0);
assert_eq!(result, "Hello W…");
assert_eq!(max_scroll, 4);
}
#[test]
fn test_scroll_display_at_end() {
let (result, _) = scroll_display("Hello World", 8, 10);
assert_eq!(result, "…o World");
}
#[test]
fn test_scroll_display_in_middle() {
let (result, _) = scroll_display("Hello World", 8, 2);
assert_eq!(result, "…llo Wo…");
}
#[test]
fn test_scroll_display_unicode() {
let (result, max_scroll) = scroll_display("日本語テスト", 7, 0);
assert_eq!(result, "日本語…");
assert_eq!(max_scroll, 6);
let (result, _) = scroll_display("日本語テスト", 7, 100);
assert_eq!(result, "…テスト");
}
}