1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3
4const BUILTIN_HELP_JSON: &str = include_str!("../assets/help.json");
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
7pub enum HelpTopicKind {
8 Branch,
9 #[default]
10 Command,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct HelpExample {
15 pub command: String,
16 pub description: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct HelpEntry {
21 pub id: String,
22 pub command: String,
23 pub title: String,
24 pub summary: String,
25 #[serde(default = "default_plugin_category")]
26 pub category: String,
27 #[serde(default)]
28 pub topic: HelpTopicKind,
29 #[serde(default)]
30 pub protected: bool,
31 #[serde(default)]
32 pub common: bool,
33 #[serde(default)]
34 pub aliases: Vec<String>,
35 #[serde(default)]
36 pub keywords: Vec<String>,
37 #[serde(default)]
38 pub lines: Vec<String>,
39 #[serde(default)]
40 pub usage: Option<String>,
41 #[serde(default)]
42 pub examples: Vec<HelpExample>,
43 #[serde(default)]
44 pub related: Vec<String>,
45 #[serde(default)]
46 pub source: Option<String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct HelpRegistry {
51 entries: Vec<HelpEntry>,
52}
53
54#[derive(Debug, Clone)]
55pub struct HelpFindState {
56 entries: Vec<HelpEntry>,
57 filter: String,
58 cursor: usize,
59 scroll: usize,
60 visible_height: usize,
61 detail_idx: Option<usize>,
62 recently_opened: Vec<String>,
63 help_commands_only: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum HelpFindRow<'a> {
68 Category(&'a str),
69 Entry(&'a HelpEntry),
70}
71
72impl<'a> HelpFindRow<'a> {
73 pub fn category(&self) -> Option<&'a str> {
74 match self {
75 Self::Category(category) => Some(category),
76 Self::Entry(_) => None,
77 }
78 }
79
80 pub fn entry(&self) -> Option<&'a HelpEntry> {
81 match self {
82 Self::Category(_) => None,
83 Self::Entry(entry) => Some(entry),
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct HighlightSegment {
90 pub text: String,
91 pub matched: bool,
92}
93
94impl HelpFindState {
95 pub fn new(entries: Vec<HelpEntry>, query: &str) -> Self {
96 Self::new_with_scope(entries, query, false)
97 }
98
99 pub fn new_help_commands(entries: Vec<HelpEntry>, query: &str) -> Self {
100 Self::new_with_scope(entries, query, true)
101 }
102
103 fn new_with_scope(entries: Vec<HelpEntry>, query: &str, help_commands_only: bool) -> Self {
104 let mut state = Self {
105 entries,
106 filter: query.trim().to_string(),
107 cursor: 0,
108 scroll: 0,
109 visible_height: 10,
110 detail_idx: None,
111 recently_opened: Vec::new(),
112 help_commands_only,
113 };
114 state.reset_position();
115 state
116 }
117
118 pub fn filter(&self) -> &str {
119 &self.filter
120 }
121
122 pub fn cursor(&self) -> usize {
123 self.cursor
124 }
125
126 pub fn result_cursor(&self) -> usize {
127 self.filtered_rows()
128 .iter()
129 .take(self.cursor + 1)
130 .filter(|row| row.entry().is_some())
131 .count()
132 .saturating_sub(1)
133 }
134
135 pub fn scroll(&self) -> usize {
136 self.scroll
137 }
138
139 pub fn set_visible_height(&mut self, height: usize) {
140 self.visible_height = height.max(1);
141 self.scroll_to_cursor();
142 }
143
144 pub fn filtered_entries(&self) -> Vec<&HelpEntry> {
145 ranked_entries_with_mru(&self.entries, &self.filter, &self.recently_opened)
146 .into_iter()
147 .filter(|entry| !self.help_commands_only || is_help_command(entry))
148 .collect()
149 }
150
151 pub fn filtered_rows(&self) -> Vec<HelpFindRow<'_>> {
152 let entries = self.filtered_entries();
153 if !self.filter.trim().is_empty() {
154 return entries.into_iter().map(HelpFindRow::Entry).collect();
155 }
156
157 let mut category_names: Vec<&str> = entries.iter().map(|entry| display_category(entry)).collect();
158 category_names.sort_by(|a, b| {
159 category_sort_key(a)
160 .cmp(&category_sort_key(b))
161 .then_with(|| {
162 category_best_score(&entries, b)
163 .cmp(&category_best_score(&entries, a))
164 })
165 .then_with(|| a.cmp(b))
166 });
167 category_names.dedup();
168
169 let mut rows = Vec::new();
170 for category in category_names {
171 rows.push(HelpFindRow::Category(category));
172 let mut category_entries = entries
173 .iter()
174 .copied()
175 .filter(|entry| display_category(entry) == category)
176 .collect::<Vec<_>>();
177 category_entries.sort_by(|a, b| {
178 help_parent_sort_key(a)
179 .cmp(&help_parent_sort_key(b))
180 .then_with(|| a.command.cmp(&b.command))
181 });
182 rows.extend(category_entries.into_iter().map(HelpFindRow::Entry));
183 }
184 rows
185 }
186
187 pub fn no_results_message(&self) -> String {
188 let query = self.filter.trim();
189 if query.is_empty() {
190 "No help topics available. Try: model, settings, plugins, sessions, doctor".to_string()
191 } else {
192 format!(
193 "No help matches for '{}'. Try: model, settings, plugins, sessions, doctor",
194 query
195 )
196 }
197 }
198
199 pub fn selected(&self) -> Option<&HelpEntry> {
200 self.filtered_rows().get(self.cursor).and_then(HelpFindRow::entry)
201 }
202
203 pub fn open_selected(&mut self) {
204 let selected_command = self.selected().map(|entry| entry.command.clone());
205 if let Some(command) = selected_command.as_ref() {
206 self.remember_opened(command);
207 }
208 self.detail_idx = selected_command
209 .and_then(|command| self.entries.iter().position(|entry| entry.command == command));
210 }
211
212 pub fn close_detail(&mut self) {
213 self.detail_idx = None;
214 }
215
216 pub fn detail_entry(&self) -> Option<&HelpEntry> {
217 self.detail_idx.and_then(|idx| self.entries.get(idx))
218 }
219
220 pub fn move_down(&mut self) {
221 let rows = self.filtered_rows();
222 if rows.is_empty() {
223 self.cursor = 0;
224 self.scroll = 0;
225 return;
226 }
227 let mut next = (self.cursor + 1).min(rows.len() - 1);
228 while next < rows.len() && rows[next].entry().is_none() {
229 if next == rows.len() - 1 {
230 break;
231 }
232 next += 1;
233 }
234 if rows[next].entry().is_some() {
235 self.cursor = next;
236 }
237 self.scroll_to_cursor();
238 }
239
240 pub fn move_up(&mut self) {
241 let rows = self.filtered_rows();
242 if rows.is_empty() {
243 self.cursor = 0;
244 self.scroll = 0;
245 return;
246 }
247 let mut next = self.cursor.saturating_sub(1);
248 while next > 0 && rows[next].entry().is_none() {
249 next = next.saturating_sub(1);
250 }
251 if rows[next].entry().is_some() {
252 self.cursor = next;
253 }
254 self.scroll_to_cursor();
255 }
256
257 pub fn push_char(&mut self, ch: char) {
258 self.filter.push(ch);
259 self.reset_position();
260 }
261
262 pub fn backspace(&mut self) {
263 self.filter.pop();
264 self.reset_position();
265 }
266
267 pub fn clear_filter(&mut self) {
268 self.filter.clear();
269 self.reset_position();
270 }
271
272 fn reset_position(&mut self) {
273 self.cursor = self.first_entry_row_index();
274 self.scroll = 0;
275 }
276
277 fn first_entry_row_index(&self) -> usize {
278 self.filtered_rows()
279 .iter()
280 .position(|row| row.entry().is_some())
281 .unwrap_or(0)
282 }
283
284 fn remember_opened(&mut self, command: &str) {
285 self.recently_opened.retain(|existing| existing != command);
286 self.recently_opened.insert(0, command.to_string());
287 self.recently_opened.truncate(10);
288 }
289
290 fn scroll_to_cursor(&mut self) {
291 if self.cursor < self.scroll {
292 self.scroll = self.cursor;
293 }
294 let bottom = self.scroll + self.visible_height;
295 if self.cursor >= bottom {
296 self.scroll = self.cursor + 1 - self.visible_height;
297 }
298 let len = self.filtered_rows().len();
299 if self.cursor >= len {
300 self.cursor = self.first_entry_row_index();
301 self.scroll = 0;
302 }
303 }
304}
305
306impl HelpRegistry {
307 pub fn new(core_entries: Vec<HelpEntry>, plugin_entries: Vec<HelpEntry>) -> Self {
308 let protected = protected_commands(&core_entries);
309 let mut seen = HashSet::new();
310 let mut entries = Vec::new();
311
312 for mut entry in core_entries {
313 normalize_command(&mut entry);
314 if seen.insert(entry.command.clone()) {
315 entries.push(entry);
316 }
317 }
318
319 for mut entry in plugin_entries {
320 normalize_command(&mut entry);
321 if protected.contains(&entry.command)
322 || protected.contains(&entry.id)
323 || entry.aliases.iter().any(|alias| protected.contains(alias))
324 {
325 tracing::warn!(
326 command = %entry.command,
327 id = %entry.id,
328 "plugin help entry conflicts with protected namespace; ignoring"
329 );
330 continue;
331 }
332 if entry.protected {
333 entry.protected = false;
334 }
335 entry.source = Some(plugin_source_label(entry.source.as_deref()));
336 if entry.category.trim().is_empty() || entry.category.eq_ignore_ascii_case("plugin") {
337 entry.category = extension_help_category(entry.source.as_deref());
338 }
339 if seen.insert(entry.command.clone()) {
340 entries.push(entry);
341 }
342 }
343
344 entries.sort_by(|a, b| a.command.cmp(&b.command));
345 Self { entries }
346 }
347
348 pub fn entries(&self) -> &[HelpEntry] {
349 &self.entries
350 }
351
352 pub fn entry_by_command(&self, command: &str) -> Option<&HelpEntry> {
353 let needle = normalize_query_command(command);
354 self.entries.iter().find(|entry| {
355 entry.command == needle || entry.aliases.iter().any(|alias| normalize_query_command(alias) == needle)
356 })
357 }
358
359 pub fn branch(&self, topic: &str) -> Option<&HelpEntry> {
360 let normalized = topic.trim().trim_start_matches("/help").trim().trim_start_matches('/');
361 self.entries.iter().find(|entry| {
362 entry.topic == HelpTopicKind::Branch
363 && (entry.id == normalized
364 || entry.command == format!("/help {}", normalized)
365 || entry.aliases.iter().any(|alias| alias.trim_start_matches("/help ") == normalized))
366 })
367 }
368
369 pub fn search(&self, query: &str) -> Vec<&HelpEntry> {
370 ranked_entries(&self.entries, query)
371 }
372
373 pub fn entry_for_help_topic(&self, topic: &str) -> Option<&HelpEntry> {
374 let normalized = normalize_help_topic(topic);
375 if normalized.is_empty() {
376 return None;
377 }
378
379 self.entry_by_command_exact(&format!("/help {}", normalized))
380 .or_else(|| self.entry_by_command_exact(&normalized))
381 .or_else(|| self.entry_by_command_exact(&format!("/{}", normalized)))
382 .or_else(|| self.entry_by_command(&format!("/{}", normalized)))
383 .or_else(|| self.branch(&normalized))
384 }
385
386 pub fn command_prefix_match_count(&self, partial: &str) -> usize {
387 let needle = partial.trim().trim_start_matches('/').to_ascii_lowercase();
388 if needle.is_empty() {
389 return 0;
390 }
391 self.entries
392 .iter()
393 .filter(|entry| {
394 entry.command.trim_start_matches('/').to_ascii_lowercase().starts_with(&needle)
395 || entry.aliases.iter().any(|alias| {
396 alias.trim_start_matches('/').to_ascii_lowercase().starts_with(&needle)
397 })
398 })
399 .count()
400 }
401
402 fn entry_by_command_exact(&self, command: &str) -> Option<&HelpEntry> {
403 let needle = normalize_query_command(command);
404 self.entries.iter().find(|entry| entry.command == needle)
405 }
406}
407
408pub fn builtin_entries() -> Vec<HelpEntry> {
409 serde_json::from_str(BUILTIN_HELP_JSON).expect("assets/help.json must be valid help JSON")
410}
411
412fn default_plugin_category() -> String {
413 "Plugin".to_string()
414}
415
416fn category_sort_key(category: &str) -> u8 {
417 if category == "Help commands" {
418 1
419 } else if is_extension_help_category(category) {
420 2
421 } else {
422 0
423 }
424}
425
426fn help_parent_sort_key(entry: &HelpEntry) -> usize {
427 if is_help_command(entry) {
428 entry.command.matches(' ').count()
429 } else {
430 0
431 }
432}
433
434fn display_category(entry: &HelpEntry) -> &str {
435 if is_help_command(entry) {
436 "Help commands"
437 } else {
438 entry.category.as_str()
439 }
440}
441
442fn category_best_score(entries: &[&HelpEntry], category: &str) -> i32 {
443 entries
444 .iter()
445 .filter(|entry| display_category(entry) == category)
446 .map(|entry| empty_query_score(entry))
447 .max()
448 .unwrap_or(0)
449}
450
451fn is_help_command(entry: &HelpEntry) -> bool {
452 entry.command == "/help" || entry.command.starts_with("/help ")
453}
454
455pub fn prefilter_query_for_slash_command(input: &str) -> Option<String> {
456 let trimmed = input.trim();
457 let partial = trimmed.strip_prefix('/')?.trim();
458 if partial.is_empty() {
459 return None;
460 }
461 Some(partial.to_string())
462}
463
464pub fn render_help(registry: &HelpRegistry, branch: Option<&str>) -> Option<String> {
465 match branch.map(str::trim).filter(|s| !s.is_empty()) {
466 None => registry.entry_by_command("/help").map(render_entry),
467 Some("find") => registry.entry_by_command("/help find").map(render_entry),
468 Some("topics") => Some(render_topics(registry)),
469 Some("reference") => Some(render_reference(registry)),
470 Some(topic) => registry
471 .entry_for_help_topic(topic)
472 .map(render_entry)
473 .or_else(|| Some(render_unknown_topic(registry, topic))),
474 }
475}
476
477pub fn render_entry(entry: &HelpEntry) -> String {
478 let mut lines = vec![entry.title.clone(), String::new(), entry.summary.clone()];
479
480 if !entry.lines.is_empty() {
481 lines.push(String::new());
482 lines.extend(body_lines_without_structured_related(entry));
483 }
484
485 append_usage_examples_related(&mut lines, entry);
486 lines.join("\n")
487}
488
489fn body_lines_without_structured_related(entry: &HelpEntry) -> Vec<String> {
490 if entry.related.is_empty() {
491 return entry.lines.clone();
492 }
493 let mut lines = entry.lines.clone();
494 while lines.last().is_some_and(|line| line.trim().is_empty()) {
495 lines.pop();
496 }
497 if lines
498 .last()
499 .is_some_and(|line| line.trim_start().starts_with("Related:"))
500 {
501 lines.pop();
502 while lines.last().is_some_and(|line| line.trim().is_empty()) {
503 lines.pop();
504 }
505 }
506 lines
507}
508
509fn render_topics(registry: &HelpRegistry) -> String {
510 let mut entries: Vec<&HelpEntry> = registry
511 .entries()
512 .iter()
513 .filter(|entry| entry.topic == HelpTopicKind::Branch)
514 .collect();
515 entries.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.command.cmp(&b.command)));
516
517 let mut lines = vec![
518 "Help topics".to_string(),
519 String::new(),
520 "Conceptual guides and discovery paths. Use /help <topic> for details.".to_string(),
521 String::new(),
522 ];
523
524 for entry in entries {
525 lines.push(format!(" {} — {}", entry.command, entry.summary));
526 }
527
528 lines.join("\n")
529}
530
531fn render_reference(registry: &HelpRegistry) -> String {
532 let mut entries: Vec<&HelpEntry> = registry.entries().iter().collect();
533 entries.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.command.cmp(&b.command)));
534
535 let mut lines = vec![
536 "Help reference".to_string(),
537 String::new(),
538 "All help entries grouped by category.".to_string(),
539 ];
540 let mut current_category: Option<&str> = None;
541
542 for entry in entries {
543 if current_category != Some(entry.category.as_str()) {
544 current_category = Some(entry.category.as_str());
545 lines.push(String::new());
546 lines.push(entry.category.clone());
547 }
548 lines.push(format!(" {} — {}", entry.command, entry.summary));
549 }
550
551 lines.join("\n")
552}
553
554pub fn source_display(entry: &HelpEntry) -> Option<String> {
555 match entry.source.as_deref() {
556 Some(source) if !source.trim().is_empty() => Some(plugin_source_label(Some(source))),
557 _ => None,
558 }
559}
560
561fn append_usage_examples_related(lines: &mut Vec<String>, entry: &HelpEntry) {
562 if let Some(usage) = entry.usage.as_ref().filter(|usage| !usage.trim().is_empty()) {
563 lines.push(String::new());
564 lines.push("Usage".to_string());
565 lines.push(format!(" {}", usage));
566 }
567
568 if !entry.examples.is_empty() {
569 lines.push(String::new());
570 lines.push("Examples".to_string());
571 for example in &entry.examples {
572 if example.description.trim().is_empty() {
573 lines.push(format!(" {}", example.command));
574 } else {
575 lines.push(format!(" {:<16} {}", example.command, example.description));
576 }
577 }
578 }
579
580 if !entry.related.is_empty() {
581 lines.push(String::new());
582 lines.push(format!("Related: {}", entry.related.join(", ")));
583 }
584}
585
586fn normalize_command(entry: &mut HelpEntry) {
587 entry.command = normalize_query_command(&entry.command);
588 entry.aliases = entry.aliases.iter().map(|alias| normalize_query_command(alias)).collect();
589}
590
591fn normalize_query_command(command: &str) -> String {
592 let trimmed = command.trim();
593 if trimmed.starts_with('/') {
594 trimmed.to_string()
595 } else {
596 format!("/{}", trimmed)
597 }
598}
599
600fn normalize_help_topic(topic: &str) -> String {
601 let mut normalized = topic.trim();
602 if let Some(rest) = normalized.strip_prefix("/help") {
603 normalized = rest.trim();
604 }
605 normalized.trim_start_matches('/').trim().to_ascii_lowercase()
606}
607
608fn render_unknown_topic(registry: &HelpRegistry, topic: &str) -> String {
609 let mut lines = vec![
610 format!("No help topic for '{}'.", topic),
611 String::new(),
612 "Try /help find to search every topic.".to_string(),
613 ];
614 let suggestions = closest_help_matches(registry, topic, 3);
615 if !suggestions.is_empty() {
616 lines.push(String::new());
617 lines.push(format!("Closest matches: {}", suggestions.join(", ")));
618 }
619 lines.join("\n")
620}
621
622fn closest_help_matches(registry: &HelpRegistry, topic: &str, limit: usize) -> Vec<String> {
623 let needle = normalize_help_topic(topic);
624 if needle.is_empty() {
625 return Vec::new();
626 }
627
628 let mut scored: Vec<(&HelpEntry, usize)> = registry
629 .entries()
630 .iter()
631 .filter_map(|entry| {
632 suggestion_distance(entry, &needle)
633 .filter(|distance| *distance <= 3)
634 .map(|distance| (entry, distance))
635 })
636 .collect();
637
638 scored.sort_by(|(entry_a, distance_a), (entry_b, distance_b)| {
639 distance_a
640 .cmp(distance_b)
641 .then_with(|| entry_b.common.cmp(&entry_a.common))
642 .then_with(|| entry_a.command.len().cmp(&entry_b.command.len()))
643 .then_with(|| entry_a.command.cmp(&entry_b.command))
644 });
645 scored
646 .into_iter()
647 .map(|(entry, _)| entry.command.clone())
648 .take(limit)
649 .collect()
650}
651
652fn suggestion_distance(entry: &HelpEntry, needle: &str) -> Option<usize> {
653 entry
654 .aliases
655 .iter()
656 .map(|alias| normalize_help_topic(alias))
657 .chain(std::iter::once(normalize_help_topic(&entry.command)))
658 .chain(std::iter::once(entry.id.to_ascii_lowercase()))
659 .chain(std::iter::once(entry.title.to_ascii_lowercase()))
660 .map(|candidate| levenshtein(needle, candidate.trim_start_matches("help ")))
661 .min()
662}
663
664fn levenshtein(a: &str, b: &str) -> usize {
665 let b_chars: Vec<char> = b.chars().collect();
666 let mut costs: Vec<usize> = (0..=b_chars.len()).collect();
667
668 for (i, ca) in a.chars().enumerate() {
669 let mut previous = costs[0];
670 costs[0] = i + 1;
671 for (j, cb) in b_chars.iter().enumerate() {
672 let current = costs[j + 1];
673 costs[j + 1] = if ca == *cb {
674 previous
675 } else {
676 1 + previous.min(current).min(costs[j])
677 };
678 previous = current;
679 }
680 }
681
682 costs[b_chars.len()]
683}
684
685fn plugin_source_label(source: Option<&str>) -> String {
686 match source.map(str::trim).filter(|source| !source.is_empty()) {
687 None => "plugin".to_string(),
688 Some(source) if source.eq_ignore_ascii_case("plugin") => "plugin".to_string(),
689 Some(source) if source.to_ascii_lowercase().starts_with("plugin ") => source.to_string(),
690 Some(source) => format!("plugin {}", source),
691 }
692}
693
694fn extension_help_category(source: Option<&str>) -> String {
695 let Some(source) = source.map(str::trim).filter(|source| !source.is_empty()) else {
696 return "Extensions".to_string();
697 };
698 let plugin_name = source
699 .strip_prefix("plugin ")
700 .unwrap_or(source)
701 .trim();
702 if plugin_name.is_empty() || plugin_name.eq_ignore_ascii_case("plugin") {
703 return "Extensions".to_string();
704 }
705 plugin_name
706 .split(['-', '_', ' '])
707 .filter(|word| !word.is_empty())
708 .map(title_case_word)
709 .collect::<Vec<_>>()
710 .join(" ")
711}
712
713fn title_case_word(word: &str) -> String {
714 let mut chars = word.chars();
715 match chars.next() {
716 None => String::new(),
717 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_ascii_lowercase(),
718 }
719}
720
721fn is_extension_help_category(category: &str) -> bool {
722 !category.is_empty()
723 && category != "Help commands"
724 && !matches!(
725 category,
726 "Advanced"
727 | "Core"
728 | "Diagnostics"
729 | "Extensions"
730 | "Models"
731 | "Plugins"
732 | "Sessions"
733 | "Settings"
734 | "Tools"
735 )
736}
737
738fn protected_commands(entries: &[HelpEntry]) -> HashSet<String> {
739 let mut protected = HashSet::new();
740 for entry in entries.iter().filter(|entry| entry.protected) {
741 protected.insert(normalize_query_command(&entry.command));
742 protected.insert(entry.id.clone());
743 for alias in &entry.aliases {
744 protected.insert(normalize_query_command(alias));
745 }
746 }
747 for builtin in crate::skills::BUILTIN_COMMANDS {
748 protected.insert(format!("/{}", builtin));
749 protected.insert((*builtin).to_string());
750 }
751 protected
752}
753
754fn ranked_entries<'a>(entries: &'a [HelpEntry], query: &str) -> Vec<&'a HelpEntry> {
755 ranked_entries_with_mru(entries, query, &[])
756}
757
758fn ranked_entries_with_mru<'a>(entries: &'a [HelpEntry], query: &str, recently_opened: &[String]) -> Vec<&'a HelpEntry> {
759 let needle = query.trim().to_ascii_lowercase();
760 let mut scored: Vec<(&HelpEntry, i32)> = entries
761 .iter()
762 .filter_map(|entry| {
763 if needle.is_empty() {
764 Some((entry, empty_query_score(entry)))
765 } else {
766 match_score(entry, &needle).map(|score| (entry, score + mru_bonus(entry, recently_opened)))
767 }
768 })
769 .collect();
770
771 scored.sort_by(|(a, score_a), (b, score_b)| {
772 score_b
773 .cmp(score_a)
774 .then_with(|| a.category.cmp(&b.category))
775 .then_with(|| a.command.cmp(&b.command))
776 });
777 scored.into_iter().map(|(entry, _)| entry).collect()
778}
779
780fn empty_query_score(entry: &HelpEntry) -> i32 {
781 let mut score = 0;
782 if entry.common {
783 score += 2_000;
784 }
785 if entry.category.eq_ignore_ascii_case("core") {
786 score += 1_000;
787 }
788 score
789}
790
791fn match_score(entry: &HelpEntry, needle: &str) -> Option<i32> {
792 let command = entry.command.to_ascii_lowercase();
793 let title = entry.title.to_ascii_lowercase();
794 if command == needle || command.trim_start_matches('/') == needle {
795 return Some(11_000 + common_bonus(entry));
796 }
797 if title == needle {
798 return Some(10_000 + common_bonus(entry));
799 }
800 if command.starts_with(needle)
801 || command.trim_start_matches('/').starts_with(needle.trim_start_matches('/'))
802 {
803 return Some(8_500 + common_bonus(entry));
804 }
805 if title.starts_with(needle) {
806 return Some(8_000 + common_bonus(entry));
807 }
808 if entry
809 .aliases
810 .iter()
811 .any(|alias| field_matches(alias, needle))
812 {
813 return Some(6_000 + common_bonus(entry));
814 }
815 if entry
816 .keywords
817 .iter()
818 .any(|keyword| field_matches(keyword, needle))
819 {
820 return Some(5_000 + common_bonus(entry));
821 }
822 if field_matches(&entry.summary, needle) {
823 return Some(4_000 + common_bonus(entry));
824 }
825 if entry.lines.iter().any(|line| field_matches(line, needle))
826 || entry.usage.as_ref().is_some_and(|usage| field_matches(usage, needle))
827 || entry.examples.iter().any(|example| {
828 field_matches(&example.command, needle) || field_matches(&example.description, needle)
829 })
830 {
831 return Some(3_000 + common_bonus(entry));
832 }
833 None
834}
835
836fn field_matches(value: &str, needle: &str) -> bool {
837 value.to_ascii_lowercase().contains(needle)
838}
839
840fn common_bonus(entry: &HelpEntry) -> i32 {
841 if entry.common { 100 } else { 0 }
842}
843
844fn mru_bonus(entry: &HelpEntry, recently_opened: &[String]) -> i32 {
845 recently_opened
846 .iter()
847 .position(|command| command == &entry.command)
848 .map(|idx| 50 - (idx as i32).min(49))
849 .unwrap_or(0)
850}
851
852pub fn highlight_segments(text: &str, query: &str) -> Vec<HighlightSegment> {
853 let needle = query.trim().to_ascii_lowercase();
854 if needle.is_empty() {
855 return vec![HighlightSegment { text: text.to_string(), matched: false }];
856 }
857
858 let lower = text.to_ascii_lowercase();
859 let mut segments = Vec::new();
860 let mut start = 0;
861 while let Some(relative) = lower[start..].find(&needle) {
862 let match_start = start + relative;
863 let match_end = match_start + needle.len();
864 if match_start > start {
865 segments.push(HighlightSegment { text: text[start..match_start].to_string(), matched: false });
866 }
867 segments.push(HighlightSegment { text: text[match_start..match_end].to_string(), matched: true });
868 start = match_end;
869 }
870 if start < text.len() {
871 segments.push(HighlightSegment { text: text[start..].to_string(), matched: false });
872 }
873 if segments.is_empty() {
874 segments.push(HighlightSegment { text: text.to_string(), matched: false });
875 }
876 segments
877}
878
879pub fn wrap_help_text(text: &str, width: usize) -> Vec<String> {
880 if width == 0 || text.len() <= width {
881 return vec![text.to_string()];
882 }
883
884 let indent: String = text.chars().take_while(|ch| ch.is_whitespace()).collect();
885 let content = text.trim_start();
886 if content.is_empty() {
887 return vec![text.to_string()];
888 }
889
890 let mut lines = Vec::new();
891 let mut current = indent.clone();
892 for word in content.split_whitespace() {
893 let separator = if current.trim().is_empty() { "" } else { " " };
894 let candidate_len = current.len() + separator.len() + word.len();
895 if candidate_len > width && current.trim().len() > 0 {
896 lines.push(current);
897 current = format!("{}{}", indent, word);
898 } else {
899 if !separator.is_empty() {
900 current.push(' ');
901 }
902 current.push_str(word);
903 }
904 }
905 if !current.trim().is_empty() {
906 lines.push(current);
907 }
908 if lines.is_empty() {
909 vec![text.to_string()]
910 } else {
911 lines
912 }
913}
914
915pub fn visible_help_find_window(row_heights: &[usize], cursor: usize, scroll: usize, visible_height: usize) -> usize {
916 if row_heights.is_empty() || visible_height == 0 {
917 return 0;
918 }
919 let cursor = cursor.min(row_heights.len() - 1);
920 let mut start = scroll.min(cursor);
921 while start < cursor && visual_height_between(row_heights, start, cursor) > visible_height {
922 start += 1;
923 }
924 start
925}
926
927pub fn wrap_help_find_entry_lines(command: &str, summary: &str, selected: bool, width: usize) -> Vec<(String, String)> {
928 let marker = if selected { "›" } else { " " };
929 let first_prefix = format!("{} ", marker);
930 let continuation_prefix = " ";
931 let first_command_width = width.saturating_sub(first_prefix.chars().count()).min(22).max(8);
932 let command_lines = wrap_help_find_token(command, first_command_width);
933 let mut lines = Vec::new();
934
935 for (idx, command_part) in command_lines.iter().enumerate() {
936 if idx == 0 {
937 lines.push((format!("{}{}", first_prefix, command_part), String::new()));
938 } else {
939 lines.push((format!("{}{}", continuation_prefix, command_part), String::new()));
940 }
941 }
942
943 let summary_indent = " ";
944 let summary_width = width.saturating_sub(summary_indent.len()).max(8);
945 let summary_lines = wrap_help_text(summary, summary_width);
946 for line in summary_lines {
947 lines.push((summary_indent.to_string(), line.trim_start().to_string()));
948 }
949
950 if lines.is_empty() {
951 vec![(first_prefix, String::new())]
952 } else {
953 lines
954 }
955}
956
957fn wrap_help_find_token(text: &str, width: usize) -> Vec<String> {
958 if width == 0 || text.chars().count() <= width {
959 return vec![text.to_string()];
960 }
961 let chars = text.chars().collect::<Vec<_>>();
962 let mut lines = Vec::new();
963 let mut start = 0;
964 while start < chars.len() {
965 let mut end = (start + width).min(chars.len());
966 if end < chars.len() {
967 if let Some(split) = chars[start..end]
968 .iter()
969 .rposition(|ch| matches!(ch, ':' | '-' | '_' | '/'))
970 .filter(|split| *split > 0)
971 {
972 end = start + split + 1;
973 }
974 }
975 lines.push(chars[start..end].iter().collect::<String>());
976 start = end;
977 }
978 lines
979}
980
981fn visual_height_between(row_heights: &[usize], start: usize, end_inclusive: usize) -> usize {
982 row_heights[start..=end_inclusive]
983 .iter()
984 .map(|height| (*height).max(1))
985 .sum()
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991
992 #[test]
993 fn builtin_help_json_loads() {
994 let entries = builtin_entries();
995 assert!(entries.iter().any(|entry| entry.command == "/help"));
996 assert!(entries.iter().any(|entry| entry.command == "/help find"));
997 }
998
999 #[test]
1000 fn wrap_help_text_wraps_words_to_width_and_preserves_indent() {
1001 let lines = wrap_help_text(" summary text should wrap neatly", 18);
1002
1003 assert_eq!(lines, vec![" summary text", " should wrap", " neatly"]);
1004 assert!(lines.iter().all(|line| line.len() <= 18));
1005 }
1006
1007 #[test]
1008 fn wrap_help_text_returns_original_line_when_width_is_too_small() {
1009 let lines = wrap_help_text("abc def", 0);
1010
1011 assert_eq!(lines, vec!["abc def"]);
1012 }
1013
1014 #[test]
1015 fn visible_help_find_window_scrolls_by_visual_row_height_to_keep_cursor_visible() {
1016 let heights = vec![1, 1, 3, 1, 1];
1017
1018 assert_eq!(visible_help_find_window(&heights, 2, 0, 3), 2);
1019 assert_eq!(visible_help_find_window(&heights, 4, 2, 4), 3);
1020 }
1021
1022 #[test]
1023 fn wrap_help_find_entry_lines_wraps_commands_and_indents_wrapped_descriptions_lightly() {
1024 let lines = wrap_help_find_entry_lines(
1025 "/extension-showcase:very-long-demo-command",
1026 "description wraps onto a second visual line",
1027 false,
1028 34,
1029 );
1030
1031 assert!(lines.len() > 1);
1032 assert_eq!(lines[0].0, " /extension-showcase:");
1033 assert!(lines[1].0.starts_with(" very-long-demo"), "wrapped command should indent slightly: {:?}", lines);
1034 assert_eq!(lines[2].0, " ", "description should start on its own lightly indented line: {:?}", lines);
1035 assert_eq!(lines[2].1, "description wraps onto a");
1036 assert_eq!(lines[3].0, " ", "wrapped description should keep small indent: {:?}", lines);
1037 assert_eq!(lines[3].1, "second visual line");
1038 }
1039}