Skip to main content

lattice_help/
topics.rs

1//! Help topic registry (DESIGN.md §5.11).
2//!
3//! Hand-written, free-form help docs (the `:help <topic>` surface,
4//! distinct from the introspection-driven `:describe-*` views) live
5//! here. The built-in set is generated from `docs/user/**/*.md` by
6//! `build.rs` and embedded **deflate-compressed**, so `:help` works
7//! offline with no filesystem layout assumption beyond the binary
8//! itself, without the binary paying raw markdown volume.
9//!
10//! The registry is intentionally indirection-friendly:
11//!
12//! - [`HelpTopicBody::Compressed`] is what every builtin uses —
13//!   inflated on first open and cached, so a session that never opens
14//!   `:help` never decompresses anything.
15//! - [`HelpTopicBody::Static`] embeds compile-time markdown directly.
16//! - [`HelpTopicBody::Owned`] holds runtime markdown — what a plugin's
17//!   pages land as, having crossed the WASM boundary as a `String`.
18//! - [`HelpTopicBody::Dynamic`] takes a closure that produces text
19//!   on demand -- this is the seam for LSP-driven topics
20//!   (`:help symbol::Foo`) and in-process introspection that can't be
21//!   captured at compile time.
22//!
23//! **Runtime-writable (CR.1).** The host holds this as a
24//! [`HelpTopicRegistryHandle`] — copy-on-write RCU behind an
25//! `ArcSwap`, the same idiom the command / picker / compilation-parser
26//! registries use. Reads are wait-free snapshots taken once per
27//! `:help` invocation; writes happen only on plugin load and unload.
28//! That is what lets a plugin ship a `:help` page: its markdown is
29//! baked into its own component and registered through the `help` WIT
30//! seam (CR.3). See
31//! `docs/dev/architecture/contributable-registries.md`.
32//!
33//! Topics also carry an optional list of substring patterns that
34//! match command names; `:describe-command` walks these to emit a
35//! `See also: [topic](help:topic)` cross-link when a primitive
36//! covered by a topic is described.
37
38use std::collections::HashMap;
39use std::sync::{Arc, OnceLock};
40
41use lattice_completion::candidate::{CandidateData, CandidateKind, RawCandidate};
42use lattice_completion::traits::{CandidateGenerator, GenerateContext};
43
44/// One free-form help topic.
45pub struct HelpTopic {
46    pub name: String,
47    pub summary: String,
48    pub body: HelpTopicBody,
49    /// Substring patterns matched against command names by
50    /// `:describe-command`. When any pattern is a substring of the
51    /// command name (case-sensitive), the describe view emits a
52    /// "See also" link to this topic.
53    pub related_command_patterns: Vec<String>,
54    /// CR.1: the host-issued plugin id that contributed this topic,
55    /// `None` for builtins. Provenance IS the teardown token — unload
56    /// is [`HelpTopicRegistry::unregister_plugin`], so there is no
57    /// per-load list to record and therefore none to forget.
58    pub plugin_id: Option<u64>,
59}
60
61impl HelpTopic {
62    /// A builtin topic: no plugin provenance, no related-command
63    /// patterns. The shape most callers want; the struct literal stays
64    /// available for the two that need more.
65    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
100/// Where a topic's body comes from.
101///
102/// - `Static` — a compile-time `&'static str`. Used by tests and by
103///   any caller registering a topic from a string it already holds.
104/// - `Owned` — runtime markdown the registry owns. What a plugin's
105///   pages land as (CR.3): the body crossed the WASM boundary as an
106///   owned `String` and there is no `'static` to borrow from. Chosen
107///   over leaking the string — a leak would survive unload, and a
108///   plugin reloaded repeatedly would accumulate a copy of its manual
109///   each time.
110/// - `Compressed` — deflate-compressed markdown embedded at build
111///   time. Every builtin topic is one of these. Decompressed on first
112///   render and cached, so a session that never opens `:help` never
113///   decompresses anything and the second open of a topic is free.
114/// - `Dynamic` — a closure invoked on every open (the seam for LSP /
115///   introspection). NOT what plugin pages use: it re-invokes on every
116///   open, and a help plugin's guest is dropped once its bodies are
117///   across.
118pub enum HelpTopicBody {
119    Static(&'static str),
120    Owned(String),
121    Compressed {
122        /// Raw deflate stream (no zlib/gzip wrapper).
123        packed: &'static [u8],
124        /// Decompressed length, used to size the output buffer exactly.
125        raw_len: usize,
126        /// Decompressed body, filled on first [`HelpTopicBody::render`].
127        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
147/// Decompress an embedded topic body.
148///
149/// A failure here means the build script and this decoder disagree,
150/// which is a build-time bug rather than anything the user did — so it
151/// surfaces as visible text in the help buffer instead of a panic that
152/// takes the editor down, or a silent empty page that looks like a
153/// missing doc.
154fn 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/// Catalogue of every registered topic, keyed by name. Plugins and
163/// future LSP integrations register through
164/// [`register`](HelpTopicRegistry::register).
165///
166/// Topics are held behind `Arc` so the registry is cheap to `Clone`,
167/// which is what the [`HelpTopicRegistryHandle`] RCU write path needs
168/// (clone → mutate → store). The `Arc` earns its place beyond clone
169/// cost: [`HelpTopicBody::Compressed`] carries a `OnceLock`
170/// decompression cache, and sharing the topic means an RCU write does
171/// not throw away every already-inflated body.
172#[derive(Debug, Default, Clone)]
173pub struct HelpTopicRegistry {
174    by_name: HashMap<String, Arc<HelpTopic>>,
175    /// Insertion order so the index can list topics in the order
176    /// the host registered them (built-ins first).
177    order: Vec<String>,
178}
179
180/// The runtime-mutable handle, registered as a boot service under this
181/// exact alias (the `ServiceRegistry` Arc/TypeId convention).
182///
183/// Copy-on-write RCU: reads are wait-free `.load()` snapshots taken
184/// once per `:help` invocation, writes happen only on plugin load and
185/// unload. A plugin registering mid-lookup affects the *next* lookup,
186/// never half of this one.
187pub type HelpTopicRegistryHandle = Arc<arc_swap::ArcSwap<HelpTopicRegistry>>;
188
189impl HelpTopicRegistry {
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// Wrap this registry in a fresh [`HelpTopicRegistryHandle`].
195    ///
196    /// Exists so consumers — the host's boot, the loader's drain, their
197    /// tests — do not each have to name `arc_swap` just to build the
198    /// handle this crate defines.
199    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    /// Drop every topic contributed by `plugin_id`, returning how many
211    /// were removed. Idempotent: a second call reports zero, which is
212    /// what the teardown contract requires of a double-unload.
213    ///
214    /// Builtins carry `plugin_id: None` and are therefore untouchable
215    /// through this path — no plugin's unload can remove a core page.
216    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    /// Find every topic whose `related_command_patterns` contains
247    /// a substring of `command_name`. Used by
248    /// `:describe-command` to emit cross-links. Stable insertion
249    /// order; multiple topics can match.
250    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
262/// `gen:help-topics`. Returns one `RawCandidate` per registered
263/// help topic so `:help <Tab>` enumerates available topics. The
264/// payload is `CandidateData::Plain` -- topic name alone is enough
265/// for the v1 popup; future polish can introduce a richer
266/// `HelpTopic` variant if summaries need to flow through the
267/// matcher / annotator pipeline.
268///
269/// CR.1: holds the **handle**, not a snapshot. A snapshot taken at boot
270/// would enumerate the builtin set forever, so a plugin's pages would
271/// exist and open by name but never appear in `:help <Tab>` — the same
272/// gap one level down, and a quieter one.
273pub 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
297// Generated by `build.rs` from `docs/user/**/*.md` (module scope so
298// the emitted `static` is an item). Shape:
299//   static HELP_TOPICS: &[(name, summary, related, body)]
300// `README.md` maps to the `index` topic (reached by bare `:help`).
301include!(concat!(env!("OUT_DIR"), "/help_topics.rs"));
302
303/// The built-in topic set, generated at build time by `build.rs`
304/// from every `docs/user/**/*.md` (recursively, skipping `tutor/`).
305/// Each doc's `---` YAML frontmatter supplies `summary` + `related`
306/// (see the build script); bodies are embedded as string literals
307/// so the binary stays self-contained — no runtime filesystem
308/// dependency. Adding a doc requires **no change here**: drop the
309/// `.md` into `docs/user/` and it registers automatically.
310/// CR.1: returns the registry by value. The caller wraps it in a
311/// [`HelpTopicRegistryHandle`] via [`HelpTopicRegistry::into_handle`] —
312/// the builtin set is the *initial* contents of a runtime-writable
313/// registry now, not the whole of it.
314pub 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        // The launch dashboard's help-topics section links
352        // `:help getting-started` (topic:getting-started). Pin that the
353        // topic actually resolves — a dashboard link with no backing doc
354        // is a dead `<CR>`.
355        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    /// Soft binary-size budget for the **embedded** (compressed) user
370    /// docs — the bytes that actually land in the binary.
371    ///
372    /// It used to measure raw markdown, because that *was* what got
373    /// embedded. Docs are deflate-compressed at build time now, so raw
374    /// size is no longer the cost: measuring it would fire the alarm on
375    /// volume the binary never pays for.
376    ///
377    /// The budget is deliberately generous relative to today's usage
378    /// (see the report the test prints) because the whole point of
379    /// compressing was to buy room for the doc set to grow.
380    ///
381    /// **Action when this test fails:** do NOT just bump the number.
382    /// Compression is already spent; the next lever is moving docs out
383    /// of the binary entirely into a runtime directory — the model
384    /// every editor in this class uses (Vim's `$VIMRUNTIME/doc`,
385    /// Helix's `runtime/`, Kakoune's `share/kak/doc`), and the one that
386    /// also lets plugins ship their own `:help` pages. See
387    /// `docs/dev/operations/embedded-docs-budget.md`.
388    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    /// Compression has to actually be doing something. If a future
421    /// change accidentally embeds bodies raw (a build-script regression,
422    /// or a `Static` fallback), the budget test above would still pass
423    /// while the binary quietly grew — this catches that directly.
424    #[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    /// Every embedded body must round-trip. A topic that fails to
439    /// inflate renders an error string rather than panicking, which is
440    /// the right runtime behaviour but would be invisible without this.
441    #[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    /// The cache means a topic decompresses once per process, not once
463    /// per `:help`. Pins the lazy half of the design.
464    #[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        // Regression for the gap discovered post-3c.final.E.cleanup:
482        // `docs/user/lsp.md` and the then-new filetree/oil doc existed
483        // on disk but weren't in the registry, so `:help lsp` failed at
484        // runtime even though the user doc shipped with the source
485        // tree. (Registration is generated from the directory now, so
486        // this guards the generator rather than a hand-written list.)
487        //
488        // This test walks every top-level `docs/user/*.md` and asserts
489        // each is registered as a topic. README.md is the "index"
490        // topic (registered under that name); every other file's
491        // topic name is its stem.
492        //
493        // The walk uses the workspace root via CARGO_MANIFEST_DIR ↦
494        // `<root>/crates/lattice-help` ↦ join `../../docs/user`.
495        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        // Registration and *discoverability* are different properties,
529        // and only the first was guarded. A doc that ships and
530        // registers is reachable by `:help <exact-name>` — which helps
531        // only the reader who already knows the name. The reader who
532        // does not opens `:help` with no argument, gets README, and
533        // finds the feature simply absent.
534        //
535        // That is the failure this guards: not a broken link, but a
536        // silently unlisted one. It cannot be caught by reading the
537        // index (an absent row looks like nothing) and it grows by one
538        // every time a doc lands without an index row — which is the
539        // normal way to add a doc.
540        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            // README is the index; it does not link itself.
553            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    /// Blank out fenced blocks and inline code spans so the link
574    /// scanners below see only *live* links.
575    ///
576    /// Docs legitimately show link syntax as an example —
577    /// `buffers.md` explains the index format with a literal
578    /// `` `[name](help:name)` ``. That is documentation of the form,
579    /// not a link to a topic called `name`, and flagging it would
580    /// push authors into not documenting the syntax at all. Replacing
581    /// the spans with spaces (rather than deleting them) keeps byte
582    /// offsets stable, so any future line/column reporting stays
583    /// honest.
584    #[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            // Fenced block: ``` … ``` (also covers ~~~ via the same
591            // shape when authors use it — checked separately).
592            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    /// HD.1 — every `](help:topic)` link in a user doc must resolve.
624    ///
625    /// This is the test whose absence let 210 dead links accumulate.
626    /// Cross-doc links used to be written `](magit-status.md)`, which
627    /// `classify_link_url` classifies as `Unresolved` — pressing
628    /// `<CR>` on one echoed ``no handler for `magit-status.md` ``.
629    /// They rendered correctly on GitHub, so nothing surfaced it. The
630    /// docs now use `](help:topic)` throughout, and this pins that
631    /// every target is a real topic so a rename cannot silently
632    /// orphan a link again.
633    #[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                // `help:topic#anchor` — the topic is the part before
652                // the anchor (`do_open_help_topic` splits the same way).
653                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    /// HD.2 — an anchored link (`help:magit#options`) must name a
674    /// heading that exists in the target topic.
675    ///
676    /// `do_open_help_topic` treats a missing anchor as "open the topic
677    /// unscrolled" rather than an error — the page is still the right
678    /// answer, so failing loudly at the user would be worse than
679    /// landing at the top. That leniency is what makes this test
680    /// necessary: a renamed heading silently degrades every link
681    /// pointing at it, and nothing at runtime would say so.
682    ///
683    /// Slugs follow `generate_heading_anchors` (GitHub-style:
684    /// lowercase, non-alphanumeric runs collapsed to `-`, edges
685    /// trimmed), with inline code backticks stripped first so a
686    /// heading like `` ## `C-c g` — dispatch `` slugs the way its
687    /// rendered text reads.
688    #[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    /// HD.1 — the index lists every topic.
758    ///
759    /// `README.md` is the `index` topic, the page bare `:help` opens
760    /// and the only browsable catalogue of what documentation exists.
761    /// A topic missing from it is discoverable only by already knowing
762    /// its name. `surround-mode` and `terminal-mode` were both absent
763    /// when this test was written.
764    #[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            // README is the index itself.
786            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    /// The other half: a *sibling* markdown link (`](compilation-mode.md)`)
801    /// is dead inside `:help` because nothing resolves a bare `.md`
802    /// path to a topic. Links to `../dev/**` are exempt — those are
803    /// developer docs that are deliberately not help topics, and they
804    /// stay plain markdown so they still resolve on disk and on
805    /// GitHub.
806    #[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                // Only bare siblings: no scheme, no parent-dir escape.
823                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        // Documentation overhaul (workstream 4): `modal-editing`
843        // covers the keymap-side; `ex-commands` covers the `:`
844        // surface; `modes` is the major/minor counterpart.
845        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        // `:describe-command operator:delete` surfaces a See
863        // also: link to modal-editing via the `operator:` pattern.
864        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    // ── CR.1: the runtime-writable handle ────────────────────────────
921
922    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    /// The property the whole handle exists for: a holder that captured
930    /// the handle before a plugin loaded still sees the plugin's topic.
931    #[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    /// Coherence: a snapshot taken before the write keeps reading the
954    /// set it was taken from. A `:help` render mid-load must not see
955    /// half a plugin.
956    #[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        // A builtin is untouchable through this path — no plugin's
988        // unload can remove a core page.
989        assert!(r.lookup("buffers").is_some());
990        // Idempotent: the teardown contract's double-unload case.
991        assert_eq!(r.unregister_plugin(1), 0);
992    }
993
994    /// `order` drives `iter()` / `names()`, so a stale entry left behind
995    /// by an unload would resurrect a removed topic in `:help <Tab>`
996    /// while `lookup` reported it gone.
997    #[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    /// The reason topics live behind `Arc`: an RCU write clones the
1013    /// registry, and a clone that duplicated the `OnceLock` would make
1014    /// every plugin load throw away every already-inflated body.
1015    #[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}