1use std::collections::HashMap;
39use std::sync::{Arc, OnceLock};
40
41use lattice_completion::candidate::{CandidateData, CandidateKind, RawCandidate};
42use lattice_completion::traits::{CandidateGenerator, GenerateContext};
43
44pub struct HelpTopic {
46 pub name: String,
47 pub summary: String,
48 pub body: HelpTopicBody,
49 pub related_command_patterns: Vec<String>,
54 pub plugin_id: Option<u64>,
59}
60
61impl HelpTopic {
62 pub fn builtin(
66 name: impl Into<String>,
67 summary: impl Into<String>,
68 body: HelpTopicBody,
69 ) -> Self {
70 Self {
71 name: name.into(),
72 summary: summary.into(),
73 body,
74 related_command_patterns: Vec::new(),
75 plugin_id: None,
76 }
77 }
78}
79
80impl std::fmt::Debug for HelpTopic {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.debug_struct("HelpTopic")
83 .field("name", &self.name)
84 .field("summary", &self.summary)
85 .field("related_command_patterns", &self.related_command_patterns)
86 .field("plugin_id", &self.plugin_id)
87 .field(
88 "body_kind",
89 &match &self.body {
90 HelpTopicBody::Static(_) => "static",
91 HelpTopicBody::Owned(_) => "owned",
92 HelpTopicBody::Compressed { .. } => "compressed",
93 HelpTopicBody::Dynamic(_) => "dynamic",
94 },
95 )
96 .finish()
97 }
98}
99
100pub enum HelpTopicBody {
119 Static(&'static str),
120 Owned(String),
121 Compressed {
122 packed: &'static [u8],
124 raw_len: usize,
126 cache: OnceLock<String>,
128 },
129 Dynamic(Box<dyn Fn() -> String + Send + Sync>),
130}
131
132impl HelpTopicBody {
133 pub fn render(&self) -> String {
134 match self {
135 HelpTopicBody::Static(s) => s.to_string(),
136 HelpTopicBody::Owned(s) => s.clone(),
137 HelpTopicBody::Compressed {
138 packed,
139 raw_len,
140 cache,
141 } => cache.get_or_init(|| inflate(packed, *raw_len)).clone(),
142 HelpTopicBody::Dynamic(f) => f(),
143 }
144 }
145}
146
147fn inflate(packed: &[u8], raw_len: usize) -> String {
155 match miniz_oxide::inflate::decompress_to_vec_with_limit(packed, raw_len.max(1)) {
156 Ok(bytes) => String::from_utf8(bytes)
157 .unwrap_or_else(|e| format!("help: embedded topic is not valid UTF-8 ({e})")),
158 Err(e) => format!("help: could not decompress embedded topic ({e:?})"),
159 }
160}
161
162#[derive(Debug, Default, Clone)]
173pub struct HelpTopicRegistry {
174 by_name: HashMap<String, Arc<HelpTopic>>,
175 order: Vec<String>,
178}
179
180pub type HelpTopicRegistryHandle = Arc<arc_swap::ArcSwap<HelpTopicRegistry>>;
188
189impl HelpTopicRegistry {
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 pub fn into_handle(self) -> HelpTopicRegistryHandle {
200 Arc::new(arc_swap::ArcSwap::from_pointee(self))
201 }
202
203 pub fn register(&mut self, topic: HelpTopic) {
204 if !self.by_name.contains_key(&topic.name) {
205 self.order.push(topic.name.clone());
206 }
207 self.by_name.insert(topic.name.clone(), Arc::new(topic));
208 }
209
210 pub fn unregister_plugin(&mut self, plugin_id: u64) -> usize {
217 let before = self.order.len();
218 self.by_name.retain(|_, t| t.plugin_id != Some(plugin_id));
219 self.order.retain(|n| self.by_name.contains_key(n));
220 before - self.order.len()
221 }
222
223 pub fn lookup(&self, name: &str) -> Option<&HelpTopic> {
224 self.by_name.get(name).map(|t| &**t)
225 }
226
227 pub fn names(&self) -> impl Iterator<Item = &str> {
228 self.order.iter().map(String::as_str)
229 }
230
231 pub fn iter(&self) -> impl Iterator<Item = &HelpTopic> {
232 self.order
233 .iter()
234 .filter_map(|n| self.by_name.get(n))
235 .map(|t| &**t)
236 }
237
238 pub fn len(&self) -> usize {
239 self.order.len()
240 }
241
242 pub fn is_empty(&self) -> bool {
243 self.order.is_empty()
244 }
245
246 pub fn topics_for_command<'a>(
251 &'a self,
252 command_name: &'a str,
253 ) -> impl Iterator<Item = &'a HelpTopic> + 'a {
254 self.iter().filter(move |t| {
255 t.related_command_patterns
256 .iter()
257 .any(|p| command_name.contains(p))
258 })
259 }
260}
261
262pub struct HelpTopicsGenerator {
274 pub topics: HelpTopicRegistryHandle,
275}
276
277impl CandidateGenerator for HelpTopicsGenerator {
278 fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
279 self.topics
280 .load()
281 .iter()
282 .map(|t| RawCandidate {
283 insert_text: None,
284 text: t.name.clone(),
285 display: t.name.clone(),
286 kind: CandidateKind::Plain,
287 data: CandidateData::Plain,
288 source: None,
289 accept_action: None,
290 annotations: Vec::new(),
291 display_spans: Vec::new(),
292 })
293 .collect()
294 }
295}
296
297include!(concat!(env!("OUT_DIR"), "/help_topics.rs"));
302
303pub fn builtin_topics() -> HelpTopicRegistry {
315 let mut r = HelpTopicRegistry::new();
316 for &(name, summary, related, packed, raw_len) in HELP_TOPICS {
317 r.register(HelpTopic {
318 name: name.to_string(),
319 summary: summary.to_string(),
320 body: HelpTopicBody::Compressed {
321 packed,
322 raw_len,
323 cache: OnceLock::new(),
324 },
325 related_command_patterns: related.iter().map(|s| (*s).to_string()).collect(),
326 plugin_id: None,
327 });
328 }
329 r
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn builtin_topics_include_the_index() {
338 let r = builtin_topics();
339 assert!(r.lookup("index").is_some());
340 }
341
342 #[test]
343 fn builtin_topics_include_folding_and_buffers() {
344 let r = builtin_topics();
345 assert!(r.lookup("folding").is_some());
346 assert!(r.lookup("buffers").is_some());
347 }
348
349 #[test]
350 fn getting_started_topic_is_registered_for_the_dashboard_link() {
351 let r = builtin_topics();
356 let topic = r
357 .lookup("getting-started")
358 .expect("getting-started topic must exist for the dashboard link");
359 assert!(
360 !topic.summary.is_empty(),
361 "getting-started should carry a frontmatter summary"
362 );
363 assert!(
364 topic.body.render().contains("modal"),
365 "getting-started body should cover the modal loop"
366 );
367 }
368
369 const EMBEDDED_DOCS_BUDGET_BYTES: usize = 384 * 1024;
389
390 #[test]
391 fn embedded_user_docs_stay_under_size_budget() {
392 let mut packed_total = 0usize;
393 let mut raw_total = 0usize;
394 let mut per_topic: Vec<(&str, usize, usize)> = Vec::new();
395 for &(name, _summary, _related, packed, raw_len) in HELP_TOPICS {
396 packed_total += packed.len();
397 raw_total += raw_len;
398 per_topic.push((name, packed.len(), raw_len));
399 }
400 per_topic.sort_by(|a, b| b.1.cmp(&a.1));
401 let detail = per_topic
402 .iter()
403 .take(10)
404 .map(|(n, p, r)| format!(" {p:>7} B packed ({r:>7} B raw) {n}"))
405 .collect::<Vec<_>>()
406 .join("\n");
407 let ratio = raw_total as f64 / packed_total.max(1) as f64;
408
409 assert!(
410 packed_total <= EMBEDDED_DOCS_BUDGET_BYTES,
411 "embedded user docs total {packed_total} B compressed \
412 ({raw_total} B raw, {ratio:.1}x) — over the {budget} B budget. \
413 Compression is already spent, so the next step is a runtime \
414 directory, NOT a bigger number here. See \
415 `docs/dev/operations/embedded-docs-budget.md`. Largest:\n{detail}",
416 budget = EMBEDDED_DOCS_BUDGET_BYTES,
417 );
418 }
419
420 #[test]
425 fn embedded_bodies_are_actually_compressed() {
426 let (packed, raw): (usize, usize) = HELP_TOPICS
427 .iter()
428 .fold((0, 0), |(p, r), &(_, _, _, packed, raw_len)| {
429 (p + packed.len(), r + raw_len)
430 });
431 assert!(
432 raw > packed * 2,
433 "expected embedded docs to compress at least 2x; got {raw} B raw \
434 -> {packed} B packed. Are bodies being embedded uncompressed?"
435 );
436 }
437
438 #[test]
442 fn every_embedded_topic_decompresses_to_its_original_length() {
443 let r = builtin_topics();
444 let mut bad: Vec<String> = Vec::new();
445 for &(name, _summary, _related, _packed, raw_len) in HELP_TOPICS {
446 let body = r.lookup(name).expect("registered").body.render();
447 if body.len() != raw_len {
448 bad.push(format!(
449 "{name}: inflated to {} B, expected {raw_len} B — {}",
450 body.len(),
451 body.chars().take(80).collect::<String>()
452 ));
453 }
454 }
455 assert!(
456 bad.is_empty(),
457 "topics that failed to round-trip:\n {}",
458 bad.join("\n ")
459 );
460 }
461
462 #[test]
465 fn a_topic_body_is_decompressed_once_and_cached() {
466 let r = builtin_topics();
467 let topic = r.lookup("index").expect("index topic");
468 let first = topic.body.render();
469 let second = topic.body.render();
470 assert_eq!(first, second);
471 match &topic.body {
472 HelpTopicBody::Compressed { cache, .. } => {
473 assert!(cache.get().is_some(), "render must populate the cache");
474 }
475 _ => panic!("builtin topics must be compressed"),
476 }
477 }
478
479 #[test]
480 fn every_user_doc_in_docs_user_is_registered_as_a_topic() {
481 let docs_user = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/user");
496 let r = builtin_topics();
497 let names: std::collections::HashSet<&str> = r.names().collect();
498 let mut missing: Vec<String> = Vec::new();
499 for entry in std::fs::read_dir(&docs_user).expect("docs/user readable") {
500 let entry = entry.expect("dir entry");
501 let path = entry.path();
502 if path.extension().and_then(|s| s.to_str()) != Some("md") {
503 continue;
504 }
505 let stem = path
506 .file_stem()
507 .and_then(|s| s.to_str())
508 .expect("md filename");
509 let topic_name = if stem == "README" { "index" } else { stem };
510 if !names.contains(topic_name) {
511 missing.push(format!(
512 "{} (expected topic `{}`)",
513 path.display(),
514 topic_name
515 ));
516 }
517 }
518 assert!(
519 missing.is_empty(),
520 "user docs not registered as help topics — \
521 add a `r.register(...)` for each in `builtin_topics()`:\n {}",
522 missing.join("\n ")
523 );
524 }
525
526 #[test]
527 fn every_user_doc_is_linked_from_the_index() {
528 let docs_user = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/user");
541 let index = std::fs::read_to_string(docs_user.join("README.md")).expect("README readable");
542 let mut missing: Vec<String> = Vec::new();
543 for entry in std::fs::read_dir(&docs_user).expect("docs/user readable") {
544 let path = entry.expect("dir entry").path();
545 if path.extension().and_then(|s| s.to_str()) != Some("md") {
546 continue;
547 }
548 let stem = path
549 .file_stem()
550 .and_then(|s| s.to_str())
551 .expect("md filename");
552 if stem == "README" {
554 continue;
555 }
556 if !index.contains(&format!("help:{stem})")) {
557 missing.push(stem.to_string());
558 }
559 }
560 missing.sort();
561 assert!(
562 missing.is_empty(),
563 "user docs registered as `:help` topics but absent from the \
564 index — a reader who does not already know the name cannot \
565 find them. Add a row to the topic table in \
566 `docs/user/README.md` linking `[{}](help:{})` for each:\n {}",
567 missing.first().map(String::as_str).unwrap_or("name"),
568 missing.first().map(String::as_str).unwrap_or("name"),
569 missing.join("\n "),
570 );
571 }
572
573 #[cfg(test)]
585 fn strip_code(text: &str) -> String {
586 let bytes = text.as_bytes();
587 let mut out = String::with_capacity(text.len());
588 let mut i = 0;
589 while i < bytes.len() {
590 let fence = if text[i..].starts_with("```") {
593 Some("```")
594 } else if text[i..].starts_with("~~~") {
595 Some("~~~")
596 } else {
597 None
598 };
599 if let Some(f) = fence {
600 let end = text[i + 3..]
601 .find(f)
602 .map(|p| i + 3 + p + 3)
603 .unwrap_or(bytes.len());
604 out.extend(std::iter::repeat_n(' ', end - i));
605 i = end;
606 continue;
607 }
608 if bytes[i] == b'`' {
609 let end = text[i + 1..]
610 .find('`')
611 .map(|p| i + 1 + p + 1)
612 .unwrap_or(bytes.len());
613 out.extend(std::iter::repeat_n(' ', end - i));
614 i = end;
615 continue;
616 }
617 out.push(text[i..].chars().next().unwrap());
618 i += text[i..].chars().next().unwrap().len_utf8();
619 }
620 out
621 }
622
623 #[test]
634 fn every_help_link_in_a_user_doc_resolves_to_a_registered_topic() {
635 let docs_user = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/user");
636 let r = builtin_topics();
637 let mut dangling: Vec<String> = Vec::new();
638 let mut checked = 0usize;
639
640 for entry in std::fs::read_dir(&docs_user).expect("docs/user readable") {
641 let path = entry.expect("dir entry").path();
642 if path.extension().and_then(|s| s.to_str()) != Some("md") {
643 continue;
644 }
645 let text = strip_code(&std::fs::read_to_string(&path).expect("read md"));
646 let file = path.file_name().unwrap().to_string_lossy().into_owned();
647 for (idx, _) in text.match_indices("](help:") {
648 let rest = &text[idx + "](help:".len()..];
649 let Some(end) = rest.find(')') else { continue };
650 let target = &rest[..end];
651 let name = target.split('#').next().unwrap_or(target);
654 checked += 1;
655 if r.lookup(name).is_none() {
656 dangling.push(format!("{file}: `help:{target}` — no such topic"));
657 }
658 }
659 }
660
661 assert!(
662 checked > 50,
663 "expected the docs to be densely cross-linked; only found {checked} help: links — did the link form change?"
664 );
665 assert!(
666 dangling.is_empty(),
667 "dangling help links ({} of {checked}):\n {}",
668 dangling.len(),
669 dangling.join("\n ")
670 );
671 }
672
673 #[test]
689 fn every_anchored_help_link_names_a_heading_that_exists() {
690 fn slug(heading: &str) -> String {
691 let plain = heading.replace('`', "");
692 let mut out = String::new();
693 let mut pending_dash = false;
694 for c in plain.chars() {
695 if c.is_ascii_alphanumeric() {
696 if pending_dash && !out.is_empty() {
697 out.push('-');
698 }
699 pending_dash = false;
700 out.extend(c.to_lowercase());
701 } else {
702 pending_dash = true;
703 }
704 }
705 out
706 }
707
708 let docs_user = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/user");
709 let mut headings: std::collections::HashMap<String, std::collections::HashSet<String>> =
710 std::collections::HashMap::new();
711 let mut bodies: Vec<(String, String)> = Vec::new();
712
713 for entry in std::fs::read_dir(&docs_user).expect("docs/user readable") {
714 let path = entry.expect("dir entry").path();
715 if path.extension().and_then(|s| s.to_str()) != Some("md") {
716 continue;
717 }
718 let stem = path.file_stem().and_then(|s| s.to_str()).expect("stem");
719 let topic = if stem == "README" { "index" } else { stem }.to_string();
720 let text = std::fs::read_to_string(&path).expect("read md");
721 let set: std::collections::HashSet<String> = text
722 .lines()
723 .filter_map(|l| l.strip_prefix('#'))
724 .map(|l| slug(l.trim_start_matches('#').trim()))
725 .collect();
726 headings.insert(topic.clone(), set);
727 bodies.push((topic, strip_code(&text)));
728 }
729
730 let mut broken: Vec<String> = Vec::new();
731 for (from, text) in &bodies {
732 for (idx, _) in text.match_indices("](help:") {
733 let rest = &text[idx + "](help:".len()..];
734 let Some(end) = rest.find(')') else { continue };
735 let Some((topic, anchor)) = rest[..end].split_once('#') else {
736 continue;
737 };
738 if let Some(known) = headings.get(topic)
739 && !known.contains(anchor)
740 {
741 broken.push(format!(
742 "{from}.md: `help:{topic}#{anchor}` — `{topic}` has no such heading"
743 ));
744 }
745 }
746 }
747
748 assert!(
749 broken.is_empty(),
750 "anchored help links pointing at headings that no longer exist \
751 (they open the topic unscrolled, so nothing reports this at \
752 runtime):\n {}",
753 broken.join("\n ")
754 );
755 }
756
757 #[test]
765 fn the_index_lists_every_registered_topic() {
766 let docs_user = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/user");
767 let index = std::fs::read_to_string(docs_user.join("README.md")).expect("read index");
768 let index = strip_code(&index);
769
770 let mut listed: std::collections::HashSet<String> = std::collections::HashSet::new();
771 for (idx, _) in index.match_indices("](help:") {
772 let rest = &index[idx + "](help:".len()..];
773 if let Some(end) = rest.find(')') {
774 listed.insert(rest[..end].split('#').next().unwrap_or("").to_string());
775 }
776 }
777
778 let mut missing: Vec<String> = Vec::new();
779 for entry in std::fs::read_dir(&docs_user).expect("docs/user readable") {
780 let path = entry.expect("dir entry").path();
781 if path.extension().and_then(|s| s.to_str()) != Some("md") {
782 continue;
783 }
784 let stem = path.file_stem().and_then(|s| s.to_str()).expect("stem");
785 if stem == "README" || listed.contains(stem) {
787 continue;
788 }
789 missing.push(stem.to_string());
790 }
791 missing.sort();
792 assert!(
793 missing.is_empty(),
794 "topics missing from the `:help` index (docs/user/README.md) — \
795 they exist but nothing links to them:\n {}",
796 missing.join("\n ")
797 );
798 }
799
800 #[test]
807 fn no_user_doc_links_to_a_sibling_doc_by_filename() {
808 let docs_user = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/user");
809 let mut offenders: Vec<String> = Vec::new();
810
811 for entry in std::fs::read_dir(&docs_user).expect("docs/user readable") {
812 let path = entry.expect("dir entry").path();
813 if path.extension().and_then(|s| s.to_str()) != Some("md") {
814 continue;
815 }
816 let text = strip_code(&std::fs::read_to_string(&path).expect("read md"));
817 let file = path.file_name().unwrap().to_string_lossy().into_owned();
818 for (idx, _) in text.match_indices("](") {
819 let rest = &text[idx + 2..];
820 let Some(end) = rest.find(')') else { continue };
821 let url = &rest[..end];
822 if url.ends_with(".md") && !url.contains('/') && !url.contains(':') {
824 offenders.push(format!(
825 "{file}: `{url}` — use `help:{}`",
826 &url[..url.len() - 3]
827 ));
828 }
829 }
830 }
831
832 assert!(
833 offenders.is_empty(),
834 "sibling `.md` links are dead inside `:help` (they classify as \
835 `Unresolved`); use the `help:` form:\n {}",
836 offenders.join("\n ")
837 );
838 }
839
840 #[test]
841 fn builtin_topics_include_new_foundational_topics() {
842 let r = builtin_topics();
846 assert!(
847 r.lookup("modal-editing").is_some(),
848 "modal-editing topic should be registered",
849 );
850 assert!(
851 r.lookup("ex-commands").is_some(),
852 "ex-commands topic should be registered",
853 );
854 assert!(
855 r.lookup("modes").is_some(),
856 "modes topic should be registered",
857 );
858 }
859
860 #[test]
861 fn topics_for_command_routes_describe_to_modal_editing() {
862 let r = builtin_topics();
865 let hits: Vec<&str> = r
866 .topics_for_command("operator:delete")
867 .map(|t| t.name.as_str())
868 .collect();
869 assert!(hits.contains(&"modal-editing"), "got {hits:?}");
870 }
871
872 #[test]
873 fn topics_for_command_routes_ex_commands_for_ex_prefixed() {
874 let r = builtin_topics();
875 let hits: Vec<&str> = r
876 .topics_for_command("ex:write")
877 .map(|t| t.name.as_str())
878 .collect();
879 assert!(hits.contains(&"ex-commands"), "got {hits:?}");
880 }
881
882 #[test]
883 fn topics_for_command_matches_pattern_substring() {
884 let r = builtin_topics();
885 let hits: Vec<&str> = r
886 .topics_for_command("operator:fold-create")
887 .map(|t| t.name.as_str())
888 .collect();
889 assert!(hits.contains(&"folding"));
890 }
891
892 #[test]
893 fn dynamic_body_invokes_closure_on_each_render() {
894 let mut r = HelpTopicRegistry::new();
895 let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
896 let counter_clone = counter.clone();
897 r.register(HelpTopic::builtin(
898 "dynamic",
899 "test",
900 HelpTopicBody::Dynamic(Box::new(move || {
901 counter_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
902 "rendered".to_string()
903 })),
904 ));
905 let t = r.lookup("dynamic").expect("dynamic topic");
906 let _ = t.body.render();
907 let _ = t.body.render();
908 assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 2);
909 }
910
911 #[test]
912 fn registering_same_name_replaces_without_dup_in_order() {
913 let mut r = HelpTopicRegistry::new();
914 r.register(HelpTopic::builtin("x", "one", HelpTopicBody::Static("")));
915 r.register(HelpTopic::builtin("x", "two", HelpTopicBody::Static("")));
916 assert_eq!(r.len(), 1);
917 assert_eq!(r.lookup("x").expect("x").summary, "two");
918 }
919
920 fn plugin_topic(name: &str, summary: &str, plugin_id: u64) -> HelpTopic {
923 HelpTopic {
924 plugin_id: Some(plugin_id),
925 ..HelpTopic::builtin(name, summary, HelpTopicBody::Static("body"))
926 }
927 }
928
929 #[test]
932 fn an_rcu_write_is_visible_through_a_handle_captured_beforehand() {
933 let handle = builtin_topics().into_handle();
934 let captured = handle.clone();
935 assert!(captured.load().lookup("plug.usage").is_none());
936
937 handle.rcu(|current| {
938 let mut next = (**current).clone();
939 next.register(plugin_topic("plug.usage", "how to plug", 7));
940 Arc::new(next)
941 });
942
943 assert_eq!(
944 captured
945 .load()
946 .lookup("plug.usage")
947 .expect("registered")
948 .summary,
949 "how to plug"
950 );
951 }
952
953 #[test]
957 fn a_snapshot_taken_before_a_write_still_reads_the_old_set() {
958 let handle = builtin_topics().into_handle();
959 let before = handle.load_full();
960
961 handle.rcu(|current| {
962 let mut next = (**current).clone();
963 next.register(plugin_topic("plug.usage", "how to plug", 7));
964 Arc::new(next)
965 });
966
967 assert!(before.lookup("plug.usage").is_none());
968 assert!(handle.load().lookup("plug.usage").is_some());
969 }
970
971 #[test]
972 fn unregister_plugin_removes_only_that_plugins_topics() {
973 let mut r = HelpTopicRegistry::new();
974 r.register(HelpTopic::builtin(
975 "buffers",
976 "core",
977 HelpTopicBody::Static(""),
978 ));
979 r.register(plugin_topic("a.one", "a1", 1));
980 r.register(plugin_topic("a.two", "a2", 1));
981 r.register(plugin_topic("b.one", "b1", 2));
982
983 assert_eq!(r.unregister_plugin(1), 2);
984 assert_eq!(r.len(), 2);
985 assert!(r.lookup("a.one").is_none());
986 assert!(r.lookup("b.one").is_some());
987 assert!(r.lookup("buffers").is_some());
990 assert_eq!(r.unregister_plugin(1), 0);
992 }
993
994 #[test]
998 fn unregister_plugin_leaves_no_orphan_in_the_display_order() {
999 let mut r = HelpTopicRegistry::new();
1000 r.register(HelpTopic::builtin(
1001 "buffers",
1002 "core",
1003 HelpTopicBody::Static(""),
1004 ));
1005 r.register(plugin_topic("a.one", "a1", 1));
1006 r.unregister_plugin(1);
1007
1008 assert_eq!(r.names().collect::<Vec<_>>(), vec!["buffers"]);
1009 assert_eq!(r.iter().count(), 1);
1010 }
1011
1012 #[test]
1016 fn cloning_the_registry_shares_the_decompression_cache() {
1017 let r = builtin_topics();
1018 let name = r.names().next().expect("at least one builtin").to_string();
1019 let inflated = r.lookup(&name).expect("topic").body.render();
1020
1021 let clone = r.clone();
1022 let topic = clone.lookup(&name).expect("topic in the clone");
1023 match &topic.body {
1024 HelpTopicBody::Compressed { cache, .. } => assert_eq!(
1025 cache.get().map(String::as_str),
1026 Some(inflated.as_str()),
1027 "the clone must share the already-filled cache, not a fresh OnceLock"
1028 ),
1029 _ => panic!("builtin bodies are compressed"),
1030 }
1031 }
1032}