Skip to main content

lattice_help/
lib.rs

1//! Buffer-backed help model (DESIGN.md §5.11).
2//!
3//! Help is a *buffer* with introspection-collected content -- the
4//! same underlying type that holds source code. The popup overlay we
5//! render today is just one display strategy for this buffer; when
6//! multi-buffer support lands the same content can be shown in a
7//! split, tab, or window per a user preference (see
8//! `lattice_core::ui::display::BufferDisplay`). This is the emacs model: `*Help*` is a
9//! buffer; its content is queryable, navigable with normal motions,
10//! and its links are followable.
11//!
12//! Three architectural commitments are baked in here even though the
13//! v1 surface only renders the popup:
14//!
15//! 1. **Content is a `lattice_core::Buffer`** -- rope-backed, the
16//!    same shape as a code buffer. When the help-major-mode + tree-
17//!    sitter grammar lands (Phase 6+8), motions and the highlighter
18//!    work over this content with no special-casing.
19//!
20//! 2. **Links are first-class, in standard markdown form** -- the
21//!    formatter emits `[label](scheme:value)` markdown links and we
22//!    extract a `Vec<HelpLink>` listing every reference's byte range
23//!    (the LABEL, what the user sees) plus its target ([command,
24//!    chord, source-location]). Standard markdown link syntax means
25//!    a help body renders correctly in any markdown viewer (GitHub,
26//!    docs.rs, this editor's markdown highlighter); navigation
27//!    inside the editor dispatches on the URL's scheme.
28//!
29//! 3. **Display target is a user preference** -- `BufferDisplay`
30//!    enumerates the surfaces a help buffer can be shown in. v1
31//!    implements `Popup` only; `Split` / `Tab` / `Window` arrive
32//!    behind multi-buffer.
33//!
34//! Markup convention for links inside a help body
35//! (`[label](url)` -- standard markdown):
36//!
37//! - `[ex:write](command:ex:write)` -> [`HelpLinkTarget::Command`]
38//! - `[zo](key:zo)`                 -> [`HelpLinkTarget::Chord`]
39//! - `[src/foo.rs:42](file:src/foo.rs:42)` -> [`HelpLinkTarget::Source`]
40//!
41//! Anything else (`scheme:value` with an unrecognized scheme) parses
42//! as an unresolved link with the raw URL preserved -- forward-compat
43//! for future targets (option, event, mode, ...).
44
45use std::path::PathBuf;
46
47use lattice_core::Buffer;
48use lattice_protocol::edit::Edit;
49use lattice_protocol::position::{Position, Range as ProtoRange};
50
51use lattice_core::BufferId;
52
53pub mod topics;
54
55// Display strategy for help-flavoured buffers (popup / split /
56// active-pane / ...) lives in `lattice_core::ui::display` as
57// [`BufferDisplay`] / [`BufferDisplayCategory`] -- one enum
58// covers every dedicated-buffer producer (LSP status, hover,
59// signature, the various help / describe / apropos surfaces),
60// not just help. Callers in the App route through
61// [`App::display_buffer`].
62
63// `PopupPlacement` lives in `crate::popup`. The popup is a
64// generic rendering surface (a rect drawn over the buffer area
65// inside which any buffer can render); placement / anchoring is
66// a property of the popup itself, not of whatever buffer happens
67// to be inside it.
68
69/// One open help buffer. The content is a real [`Buffer`] (rope-
70/// backed), so it composes with everything else that consumes
71/// `Buffer` -- search, motions, syntax highlighting (once a help
72/// major mode + tree-sitter grammar lands).
73///
74/// M.3.2.c.5: per-buffer help metadata (`links`, `anchors`,
75/// `highlights`) lives on the adjacent [`HelpMetadata`] -- the
76/// App seeds it into `buffer_locals[id]` at popup-open time so
77/// help-mode-owned per-buffer state has a single source of
78/// truth. `HelpBuffer` is now the slim viewport + cursor state;
79/// the metadata travels alongside it inside [`HelpContent`].
80#[derive(Clone)]
81pub struct HelpBuffer {
82    /// Stable id assigned at creation. Position-history entries
83    /// (§5.1.1) carry this so `<C-o>` / `<C-i>` can route back to
84    /// the originating buffer when multiple Help buffers coexist
85    /// (Phase B.1.c). v1 only ever holds one Help buffer at a
86    /// time -- the id still matters because the position history
87    /// outlives any one Help session and a stale entry must not
88    /// land on a freshly-opened, unrelated Help.
89    pub id: BufferId,
90    pub title: String,
91    pub content: Buffer,
92    /// First visible line index (the popup renderer uses this; a
93    /// future split/tab/window renderer would use the buffer's own
94    /// scroll state instead).
95    pub scroll: usize,
96    /// Cursor position inside the help content. The help overlay
97    /// behaves like any other buffer -- motions move this cursor
98    /// and `scroll` auto-adjusts to keep it in view. The terminal
99    /// cursor is rendered at the screen translation of this
100    /// position.
101    pub cursor: Position,
102}
103
104/// Named scroll target inside a help buffer's content.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct HelpAnchor {
107    pub name: String,
108    /// Line index within `HelpBuffer::content`.
109    pub line: u32,
110}
111
112/// M.3.2.c.5: parsed-out metadata that travels alongside a
113/// freshly-constructed [`HelpBuffer`]. Bundles the data the
114/// help-mode owns per-buffer so the App can seed it into
115/// `buffer_locals[id]` at popup-open time. Replaces the
116/// `links` / `anchors` / `highlights` fields that used to live
117/// directly on `HelpBuffer`.
118#[derive(Debug, Clone, Default)]
119pub struct HelpMetadata {
120    /// `[label](url)` links extracted by the from_lines parser,
121    /// indexed against the cleaned (post-link-strip) text.
122    pub links: Vec<HelpLink>,
123    /// Named anchors -- heading slugs auto-generated by
124    /// [`generate_heading_anchors`], plus any explicit anchors
125    /// supplied by the introspection renderer.
126    pub anchors: Vec<HelpAnchor>,
127}
128
129/// Renderer-agnostic snapshot of a help popup's content + view +
130/// metadata, pushed onto the `<C-o>` back-stack when following a help
131/// link swaps the popup's content in place. PU-A.2: moved here from
132/// `lattice-host` — this is help's back-stack history, not generic popup
133/// state, so it lives with the rest of the help model.
134#[derive(Debug, Clone)]
135pub struct PopupSnapshot {
136    pub title: String,
137    pub content: lattice_core::Buffer,
138    pub cursor: lattice_protocol::position::Position,
139    pub scroll: u32,
140    pub metadata: HelpMetadata,
141    pub placement: lattice_core::ui::popup::PopupPlacement,
142}
143
144/// M.3.2.c.5: pair of (slim help buffer, parsed metadata) returned
145/// from every help factory. The App splits this into:
146/// - `buffer` -> `App.popup_buffer` (the popup hot-path slot)
147/// - `metadata` -> `App.buffer_locals[buffer.id]` via
148///   `seed_help_metadata_locals` at popup-open time.
149#[derive(Debug, Clone)]
150pub struct HelpContent {
151    pub buffer: HelpBuffer,
152    pub metadata: HelpMetadata,
153}
154
155impl HelpContent {
156    /// Scroll to a named anchor in the metadata. Returns true if
157    /// the anchor was found and the buffer's `scroll` advanced.
158    /// Reads `metadata.anchors` (the canonical owner of help
159    /// per-buffer state per M.3.2.c.5); production code paths
160    /// scroll through this method or, when working from a registry
161    /// slot, by looking up `HelpAnchors` in `buffer_locals`.
162    pub fn scroll_to_anchor(&mut self, name: &str) -> bool {
163        if let Some(line) = anchor_line(&self.metadata.anchors, name) {
164            self.buffer.scroll = line as usize;
165            true
166        } else {
167            false
168        }
169    }
170}
171
172// Deref pattern: `HelpContent` is a transient construction value
173// composed of (slim buffer, parsed metadata). Call sites that work
174// with the buffer state (`content.cursor`, `content.line_count()`,
175// `content.move_cursor(...)`, ...) forward through `Deref` to
176// `HelpBuffer`. Methods that need *metadata* (`scroll_to_anchor`)
177// live on `HelpContent` directly so they can read
178// `self.metadata.anchors` -- the canonical owner per M.3.2.c.5.
179// Production callers reach the metadata via `content.metadata`
180// directly; the App's `open_popup` consumes the whole struct by
181// value and seeds the metadata into `buffer_locals[id]`.
182impl std::ops::Deref for HelpContent {
183    type Target = HelpBuffer;
184    fn deref(&self) -> &HelpBuffer {
185        &self.buffer
186    }
187}
188
189impl std::ops::DerefMut for HelpContent {
190    fn deref_mut(&mut self) -> &mut HelpBuffer {
191        &mut self.buffer
192    }
193}
194
195/// Parse `lines` into a help buffer + metadata. Walks the joined
196/// text once, stripping `[label](url)` markdown links down to
197/// their visible labels and indexing each link's range against
198/// the cleaned text. The buffer's content is the cleaned text;
199/// links land on the metadata.
200pub fn parse_help_lines(title: impl Into<String>, lines: Vec<String>) -> HelpContent {
201    parse_help_lines_and_anchors(title, lines, Vec::new())
202}
203
204/// Parse `lines` + explicit `anchors` into a help buffer + metadata.
205pub fn parse_help_lines_and_anchors(
206    title: impl Into<String>,
207    lines: Vec<String>,
208    anchors: Vec<HelpAnchor>,
209) -> HelpContent {
210    // HP.1: align table columns FIRST. Link ranges below are recorded
211    // against the cleaned text, so padding inserted afterwards would
212    // slide every link on a padded row and `<CR>` would follow the
213    // wrong one. Running first means extraction sees the final bytes.
214    let raw = lattice_mode::modes::table::layout::format_tables(lines).join("\n");
215    let (text, links) = extract_links_and_clean(&raw);
216    let mut buffer = Buffer::empty();
217    if !text.is_empty() {
218        let _ = buffer.apply_edit(&Edit::insert(Position::ZERO, text));
219    }
220    HelpContent {
221        buffer: HelpBuffer {
222            id: BufferId::next(),
223            title: title.into(),
224            content: buffer,
225            scroll: 0,
226            cursor: Position::ZERO,
227        },
228        metadata: HelpMetadata { links, anchors },
229    }
230}
231
232/// Overlay `Style::Link` spans on each link's label range. The
233/// markdown grammar runs against the link-stripped buffer text
234/// (see [`extract_links_and_clean`]) so it never sees `[label](url)`
235/// markup and never emits a Link capture itself. Renderers therefore
236/// can't tell a link label from prose. Walking `links` here and
237/// pushing one Link span per `range` onto the line's highlight
238/// vector restores that signal so the link-only overlay published
239/// via [`link_highlights`] is self-contained.
240///
241/// Multi-line links (a label that wraps across a row break) push one
242/// span per affected line, each clipped to that line's byte width.
243fn overlay_link_styles(highlights: &mut Vec<Vec<lattice_syntax::StyledSpan>>, links: &[HelpLink]) {
244    for link in links {
245        let r = &link.range;
246        let start_line = r.start.line as usize;
247        let end_line = r.end.line as usize;
248        for line_idx in start_line..=end_line {
249            // Skip lines outside the highlighted range; grow the
250            // vector when a link sits past the last grammar-touched
251            // line (uncommon but possible for trailing links).
252            if line_idx >= highlights.len() {
253                highlights.resize(line_idx + 1, Vec::new());
254            }
255            let start = if line_idx == start_line {
256                r.start.byte as usize
257            } else {
258                0
259            };
260            // Use `usize::MAX` on intermediate lines and clip downstream
261            // in renderers; for `end_line` use the recorded byte.
262            let end = if line_idx == end_line {
263                r.end.byte as usize
264            } else {
265                usize::MAX
266            };
267            if end <= start {
268                continue;
269            }
270            highlights[line_idx].push(lattice_syntax::StyledSpan {
271                start,
272                end,
273                style: lattice_syntax::Style::Link,
274            });
275        }
276    }
277}
278
279/// PU.1b-2b: build the per-line `Style::Link` spans for `links` with NO
280/// grammar base — the link-only overlay the host seeds into a help
281/// buffer's `ExtraHighlights` local so the cells-worker `DisplayMatrix`
282/// carries link styling (the grammar can't: the `[label](url)` markup is
283/// stripped before it parses, so it never emits a Link capture). Same
284/// per-line logic as [`overlay_link_styles`], just onto an empty base.
285/// One inline `` `code` `` span found in a help buffer's text.
286///
287/// `line` / `start` / `end` are a byte range on that line, covering the
288/// backticks as well as the text between them, so a renderer styles the
289/// whole literal rather than leaving its delimiters in prose colour.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct InlineCode {
292    pub line: usize,
293    pub start: usize,
294    pub end: usize,
295    /// The text BETWEEN the backticks — what a classifier reads.
296    pub text: String,
297}
298
299/// HP.2: find every inline `` `code` `` span in `text`.
300///
301/// **Why this exists at all.** Help pages are markdown, but the
302/// markdown *block* grammar has no `code_span` node — that lives in the
303/// inline grammar, which is not wired up. So the grammar emits nothing
304/// for `` `gr` ``, and every keybinding, command and action in every
305/// help page rendered as plain prose with visible backticks.
306///
307/// **Why it returns spans rather than styles.** Deciding whether
308/// `` `gr` `` is a key you press needs the live keymap, which lives in
309/// the host, not here. This function does the part that is pure text —
310/// where the literals are — and the host classifies each one. Same
311/// division as [`link_highlights`]: help finds the thing, the host
312/// colours it.
313///
314/// Fenced code blocks are skipped: their contents are already styled as
315/// a block, and a stray backtick inside a shell example is not a
316/// literal.
317pub fn inline_code_spans(text: &str) -> Vec<InlineCode> {
318    let mut out = Vec::new();
319    let mut in_fence = false;
320    for (line_idx, line) in text.lines().enumerate() {
321        let trimmed = line.trim_start();
322        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
323            in_fence = !in_fence;
324            continue;
325        }
326        if in_fence {
327            continue;
328        }
329        let bytes = line.as_bytes();
330        let mut i = 0;
331        while i < bytes.len() {
332            if bytes[i] != b'`' {
333                i += 1;
334                continue;
335            }
336            // A doubled backtick opens a span whose content may itself
337            // contain one (`` `x` `` in markdown). Match the same run
338            // length to close, exactly as markdown does — otherwise the
339            // span ends at the inner tick and the rest of the line is
340            // swallowed into prose.
341            let run = bytes[i..].iter().take_while(|&&b| b == b'`').count();
342            let content_start = i + run;
343            let Some(close) = find_tick_run(&bytes[content_start..], run) else {
344                i = content_start;
345                continue;
346            };
347            let content_end = content_start + close;
348            out.push(InlineCode {
349                line: line_idx,
350                start: i,
351                end: content_end + run,
352                text: line[content_start..content_end].trim().to_string(),
353            });
354            i = content_end + run;
355        }
356    }
357    out
358}
359
360/// Offset of the next run of exactly `run` backticks in `hay`.
361fn find_tick_run(hay: &[u8], run: usize) -> Option<usize> {
362    let mut i = 0;
363    while i < hay.len() {
364        if hay[i] == b'`' {
365            let here = hay[i..].iter().take_while(|&&b| b == b'`').count();
366            if here == run {
367                return Some(i);
368            }
369            i += here;
370            continue;
371        }
372        i += 1;
373    }
374    None
375}
376
377pub fn link_highlights(links: &[HelpLink]) -> Vec<Vec<lattice_syntax::StyledSpan>> {
378    let mut highlights = Vec::new();
379    overlay_link_styles(&mut highlights, links);
380    highlights
381}
382
383/// Find the metadata link whose label range contains `pos`.
384pub fn link_at(links: &[HelpLink], pos: Position) -> Option<&HelpLink> {
385    links.iter().find(|link| {
386        let r = &link.range;
387        if pos.line == r.start.line && pos.line == r.end.line {
388            return pos.byte >= r.start.byte && pos.byte < r.end.byte;
389        }
390        if pos.line < r.start.line || pos.line > r.end.line {
391            return false;
392        }
393        if pos.line == r.start.line {
394            return pos.byte >= r.start.byte;
395        }
396        if pos.line == r.end.line {
397            return pos.byte < r.end.byte;
398        }
399        true
400    })
401}
402
403/// Look up an anchor by name and return the line it points at.
404pub fn anchor_line(anchors: &[HelpAnchor], name: &str) -> Option<u32> {
405    anchors.iter().find(|a| a.name == name).map(|a| a.line)
406}
407
408impl std::fmt::Debug for HelpBuffer {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        f.debug_struct("HelpBuffer")
411            .field("id", &self.id)
412            .field("title", &self.title)
413            .field("scroll", &self.scroll)
414            .field("cursor", &self.cursor)
415            .field("line_count", &self.content.content_line_count())
416            .finish()
417    }
418}
419
420impl HelpContent {
421    /// Build a help buffer from a list of pre-formatted lines.
422    /// Lines may contain `[label](scheme:value)` markdown links --
423    /// the parser indexes them into the returned metadata's `links`
424    /// vec at the label's byte range in the joined output. No syntax
425    /// highlighting is attached -- help buffers receive their syntax
426    /// and link styling from the live cells-worker `DisplayMatrix`
427    /// (link spans seeded via [`link_highlights`] into the buffer's
428    /// `ExtraHighlights` local).
429    pub fn from_lines(title: impl Into<String>, lines: Vec<String>) -> Self {
430        parse_help_lines(title, lines)
431    }
432
433    /// Build with explicit anchors. Used by the introspection
434    /// renderer to feed `RenderedIntrospection.anchors` through.
435    pub fn from_lines_and_anchors(
436        title: impl Into<String>,
437        lines: Vec<String>,
438        anchors: Vec<HelpAnchor>,
439    ) -> Self {
440        parse_help_lines_and_anchors(title, lines, anchors)
441    }
442}
443
444impl HelpBuffer {
445    /// Number of visible content lines (the popup renderer uses this
446    /// to clamp scroll). CV.3: content space, as the name already
447    /// promised — it was reading ropey's raw count, which let the
448    /// scroll clamp reach one row past the last help line.
449    pub fn line_count(&self) -> u32 {
450        self.content.content_line_count()
451    }
452
453    /// Iterate the rendered lines top-down. Allocates -- `Buffer`
454    /// doesn't expose per-line slicing yet. Acceptable for v1; the
455    /// popup renderer only calls this on a small visible window.
456    pub fn lines(&self) -> Vec<String> {
457        self.content
458            .as_string()
459            .split('\n')
460            .map(|s| s.to_string())
461            .collect()
462    }
463
464    // PU.1a: HelpBuffer's cursor/scroll motion methods (move_cursor,
465    // jump_top/bottom, half_page_*, cursor_line_*, jump_cursor_to,
466    // adjust_scroll_to_cursor, line_byte_len) were retired. Help is
467    // now an actor-backed Document; motions come from the normal vim
468    // grammar path acting on `Editor::cursor`/`scroll`, and HelpBuffer
469    // survives only as a transient *view* (title + content + the
470    // scroll/cursor the renderer paints).
471}
472
473/// One `[[…]]` link inside a help buffer's content. `range` is the
474/// byte interval within the rendered text (NOT including the `[[`
475/// `]]` delimiters -- the renderer can highlight just the inner text
476/// or the full match depending on style).
477#[derive(Debug, Clone)]
478pub struct HelpLink {
479    pub range: ProtoRange,
480    pub target: HelpLinkTarget,
481}
482
483/// What a `[[…]]` link points at. Renderers / link-following motions
484/// dispatch on this.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub enum HelpLinkTarget {
487    /// `[[command:NAME]]` -- re-dispatches `:describe-command NAME`.
488    Command(String),
489    /// `[[key:CHORD]]` -- re-dispatches `:describe-key CHORD`.
490    Chord(String),
491    /// `[[file:PATH:LINE]]` -- opens PATH at LINE.
492    Source { path: PathBuf, line: u32 },
493    /// `[[help:TOPIC]]` -- re-dispatches `:help TOPIC`. Used by
494    /// `:describe-*` cross-references and by topic body content
495    /// itself so a topic can link to a sibling topic.
496    Topic(String),
497    /// `[label](#slug)` -- intra-document jump. Auto-generated from
498    /// markdown headings (GitHub-style slug: lowercase, non-alnum
499    /// runs collapsed to `-`, leading/trailing `-` trimmed). The
500    /// follow-link handler scrolls the *current* help buffer to
501    /// the matching anchor's line; no buffer swap.
502    Anchor(String),
503    /// `[label](exec:CMDLINE)` -- *executes* the cmdline as if the
504    /// user had typed `:CMDLINE<CR>`. Distinct from
505    /// [`Self::Command`] which describes the command instead of
506    /// running it. Used by picker-style help buffers (e.g.
507    /// `:lsp-server-log`) where each row's link should fire the
508    /// real command on Enter, not surface its docs.
509    ///
510    /// The payload is the *full* cmdline (command + args, no
511    /// leading colon). Multi-arg commands like `lsp-log rust`
512    /// pass through verbatim.
513    Execute(String),
514    /// `[label](customize:NAME)` -- re-dispatches `:customize NAME`
515    /// (M.9.1). Used by the customize picker so each group / mode
516    /// row in the no-args view follows to its own focused buffer
517    /// on `<CR>`.
518    Customize(String),
519    /// `[label](customize-edit:NAME)` -- prefills the cmdline
520    /// with `:set NAME=<current-value>` and enters Command
521    /// mode (M.9.2). Used by the customize buffer's per-row
522    /// links so `<CR>` on an option row opens an inline edit.
523    /// The actual write goes through the existing `:set`
524    /// machinery, so validation, cascade, and event-bus
525    /// publishing all run unchanged.
526    CustomizeEdit(String),
527    /// `[label](mode:NAME)` -- re-dispatches `:describe-mode NAME`.
528    /// Used by `:describe-buffer` (the "modes active here" section)
529    /// so each mode name in the list is clickable; follow-link
530    /// pushes a position-history entry so `<C-o>` walks back into
531    /// the originating help buffer.
532    Mode(String),
533    /// `[label](URL)` where URL carries a real network / app scheme
534    /// (`http://`, `https://`, `mailto:`, or any `scheme://…` such as
535    /// `slack://`, `vscode://`). Follow-link hands it to the OS handler
536    /// (`open` / `xdg-open` / `explorer`) so the default browser / app
537    /// opens it. Distinct from [`Self::Source`] (`file:` → open in-editor)
538    /// and [`Self::Unresolved`] (no handler).
539    Url(String),
540    /// `[[…]]` whose payload didn't match a known scheme. Preserved
541    /// verbatim for forward-compat -- a plugin / future scheme can
542    /// inspect the raw payload.
543    Unresolved(String),
544}
545
546/// Helper for help-content formatters. Renders a chord link in
547/// standard markdown form: `[chord](key:chord)`.
548pub fn key_link(chord: &str) -> String {
549    let c = escape_link_text(chord);
550    format!("[{c}](key:{c})")
551}
552
553/// Escape the link syntax's own punctuation — `\`, `[`, `]`, `(`, `)` — with a
554/// backslash, so a label or URL may contain it. Chords are the reason: `]f`,
555/// `di(`, `da)`, `ci]` are ordinary motions and text objects, and unescaped
556/// each one ended the label or the URL early. The parsers
557/// ([`extract_links_and_clean`], [`parse_help_links`]) unescape.
558///
559/// Every `*_link` helper applies it; a hand-built `[..](..)` whose text can
560/// contain these characters must too.
561pub fn escape_link_text(text: &str) -> String {
562    let mut out = String::with_capacity(text.len());
563    for ch in text.chars() {
564        if matches!(ch, '\\' | '[' | ']' | '(' | ')') {
565            out.push('\\');
566        }
567        out.push(ch);
568    }
569    out
570}
571
572/// Inverse of [`escape_link_text`]: a backslash makes the next char literal.
573fn unescape_link_text(text: &str) -> String {
574    let mut out = String::with_capacity(text.len());
575    let mut chars = text.chars();
576    while let Some(ch) = chars.next() {
577        match ch {
578            '\\' => out.extend(chars.next()),
579            other => out.push(other),
580        }
581    }
582    out
583}
584
585/// Byte index of the first `target` at or after `from` that is not escaped.
586/// `target` is ASCII, so a match is always a char boundary.
587fn find_unescaped(bytes: &[u8], from: usize, target: u8) -> Option<usize> {
588    let mut j = from;
589    while j < bytes.len() {
590        match bytes[j] {
591            b'\\' => j += 2,
592            b if b == target => return Some(j),
593            _ => j += 1,
594        }
595    }
596    None
597}
598
599/// `[label](url)` starting at the `[` at `open`: the byte bounds of the label
600/// and of the url, escape-aware. `None` when it is not a well-formed link.
601fn scan_link(bytes: &[u8], open: usize) -> Option<(usize, usize, usize, usize)> {
602    let label_start = open + 1;
603    let label_end = find_unescaped(bytes, label_start, b']')?;
604    if bytes.get(label_end + 1) != Some(&b'(') {
605        return None;
606    }
607    let url_start = label_end + 2;
608    let url_end = find_unescaped(bytes, url_start, b')')?;
609    Some((label_start, label_end, url_start, url_end))
610}
611
612/// Helper for help-content formatters. Renders a command link in
613/// standard markdown form: `[name](command:name)`.
614pub fn command_link(name: &str) -> String {
615    let t = escape_link_text(name);
616    format!("[{t}](command:{t})")
617}
618
619/// Helper for help-content formatters. Renders a source link in
620/// standard markdown form: `[path:line](file:path:line)`.
621pub fn source_link(file_line: &str) -> String {
622    let t = escape_link_text(file_line);
623    format!("[{t}](file:{t})")
624}
625
626/// Helper for help-content formatters. Renders a topic link in
627/// standard markdown form: `[name](help:name)`. Used by
628/// `:describe-*` cross-references.
629pub fn topic_link(name: &str) -> String {
630    let t = escape_link_text(name);
631    format!("[{t}](help:{t})")
632}
633
634/// Helper for help-content formatters. Renders a mode link in
635/// standard markdown form: `[name](mode:name)`. Used by
636/// `:describe-buffer` (the "modes active here" section).
637pub fn mode_link(name: &str) -> String {
638    let t = escape_link_text(name);
639    format!("[{t}](mode:{t})")
640}
641
642/// Strip every `[label](url)` markdown link in `text` down to just
643/// its label and return the cleaned-up text plus a [`HelpLink`] per
644/// link with its byte range computed against the CLEANED text. This
645/// is what the help-buffer constructor uses so the user reads
646/// `ex:write` instead of `[ex:write](command:ex:write)`. The link's
647/// URL still drives navigation -- it's stored on the returned
648/// [`HelpLink::target`] but the URL bytes don't appear in the
649/// rendered output.
650/// Collapse a multi-line diagnostic message to a single line.
651/// LSP messages can contain newlines (e.g. rust-analyzer's
652/// "expected `Foo`, found `Bar`\n  -- in fn::method"). The
653/// help-buffer's row layout assumes one row per entry; squash
654/// to keep visual alignment.
655pub fn one_line(s: &str) -> String {
656    s.lines().collect::<Vec<_>>().join(" / ")
657}
658
659pub fn extract_links_and_clean(text: &str) -> (String, Vec<HelpLink>) {
660    let bytes = text.as_bytes();
661    let mut out = String::with_capacity(text.len());
662    let mut links: Vec<HelpLink> = Vec::new();
663    let mut i = 0;
664    while i < bytes.len() {
665        if bytes[i] == b'[' {
666            // Try to match `[label](url)` starting at i. On any
667            // failure (no `]`, no `(`, no `)`) fall through and copy
668            // the `[` byte literally.
669            if let Some((label_start, label_end, url_start, url_end)) = scan_link(bytes, i) {
670                let label = unescape_link_text(&text[label_start..label_end]);
671                let target = classify_link_url(&unescape_link_text(&text[url_start..url_end]));
672                let label_byte_start = out.len();
673                out.push_str(&label);
674                let label_byte_end = out.len();
675                let start_pos = byte_offset_to_position(&out, label_byte_start);
676                let end_pos = byte_offset_to_position(&out, label_byte_end);
677                links.push(HelpLink {
678                    range: ProtoRange::new(start_pos, end_pos),
679                    target,
680                });
681                i = url_end + 1;
682                continue;
683            }
684        }
685        // Copy one UTF-8 codepoint.
686        let ch_end = next_char_boundary(text, i);
687        out.push_str(&text[i..ch_end]);
688        i = ch_end;
689    }
690    (out, links)
691}
692
693fn next_char_boundary(s: &str, byte: usize) -> usize {
694    let mut j = byte + 1;
695    while j < s.len() && !s.is_char_boundary(j) {
696        j += 1;
697    }
698    j
699}
700
701/// Walk `text`, locating every `[label](url)` markdown link and
702/// resolving the URL's scheme into a typed [`HelpLinkTarget`]. Each
703/// returned [`HelpLink`]'s `range` covers the LABEL bytes (what the
704/// user sees as a clickable token) -- the surrounding `[`, `]`,
705/// `(`, `)`, and URL bytes aren't part of the highlighted range.
706///
707/// Unlike [`extract_links_and_clean`] this preserves the input text
708/// verbatim and returns ranges in the ORIGINAL text. Useful when the
709/// caller wants to keep the markdown source visible (markdown editor
710/// mode); the help-buffer constructor uses `extract_links_and_clean`
711/// to render labels-only.
712///
713/// Forms recognized:
714/// - `[label](command:NAME)` -> [`HelpLinkTarget::Command`]
715/// - `[label](key:CHORD)`    -> [`HelpLinkTarget::Chord`]
716/// - `[label](file:PATH:LINE)` -> [`HelpLinkTarget::Source`]
717/// - any other URL -> [`HelpLinkTarget::Unresolved`]
718///
719/// No nested brackets; `\\` escapes the link punctuation, which the
720/// `*_link` helpers apply for you (see [`escape_link_text`]).
721pub fn parse_help_links(text: &str) -> Vec<HelpLink> {
722    let mut out = Vec::new();
723    let bytes = text.as_bytes();
724    let mut i = 0;
725    while i < bytes.len() {
726        if bytes[i] != b'[' {
727            i += 1;
728            continue;
729        }
730        // Escape-aware (see `escape_link_text`); the range stays over the
731        // label AS WRITTEN, since this parser keeps the source verbatim.
732        let Some((label_start, label_end, url_start, url_end)) = scan_link(bytes, i) else {
733            i += 1;
734            continue;
735        };
736        let target = classify_link_url(&unescape_link_text(&text[url_start..url_end]));
737        let start_pos = byte_offset_to_position(text, label_start);
738        let end_pos = byte_offset_to_position(text, label_end);
739        out.push(HelpLink {
740            range: ProtoRange::new(start_pos, end_pos),
741            target,
742        });
743        i = url_end + 1;
744    }
745    out
746}
747
748fn classify_link_url(url: &str) -> HelpLinkTarget {
749    if let Some(rest) = url.strip_prefix("command:") {
750        HelpLinkTarget::Command(rest.to_string())
751    } else if let Some(rest) = url.strip_prefix("exec:") {
752        // `[label](exec:CMDLINE)` -- runs `:CMDLINE` on Enter.
753        // Distinct from `command:` which describes the command.
754        HelpLinkTarget::Execute(rest.to_string())
755    } else if let Some(rest) = url.strip_prefix("key:") {
756        HelpLinkTarget::Chord(rest.to_string())
757    } else if let Some(rest) = url.strip_prefix("help:") {
758        HelpLinkTarget::Topic(rest.to_string())
759    } else if let Some(rest) = url.strip_prefix("customize-edit:") {
760        // Order: `customize-edit:` must precede `customize:`
761        // because both share the leading prefix.
762        HelpLinkTarget::CustomizeEdit(rest.to_string())
763    } else if let Some(rest) = url.strip_prefix("customize:") {
764        HelpLinkTarget::Customize(rest.to_string())
765    } else if let Some(rest) = url.strip_prefix("mode:") {
766        HelpLinkTarget::Mode(rest.to_string())
767    } else if let Some(rest) = url.strip_prefix('#') {
768        // Markdown intra-document anchor (`[label](#slug)`). Matches
769        // the GitHub-style slug auto-generated from headings by
770        // [`generate_heading_anchors`].
771        HelpLinkTarget::Anchor(rest.to_string())
772    } else if let Some(rest) = url.strip_prefix("file:") {
773        // `path:line` -- split at the LAST `:` so paths with colons
774        // (Windows drives, URLs) survive.
775        if let Some((path, line)) = rest.rsplit_once(':')
776            && let Ok(line) = line.parse::<u32>()
777        {
778            return HelpLinkTarget::Source {
779                path: PathBuf::from(path),
780                line,
781            };
782        }
783        HelpLinkTarget::Source {
784            path: PathBuf::from(rest),
785            line: 0,
786        }
787    } else if is_external_url(url) {
788        // A real network / app URL — opened by the OS handler on follow.
789        // Checked AFTER every known help scheme (`command:`, `exec:`,
790        // `help:`, `customize:`, `file:`, …) so those never leak here;
791        // none of them use a `scheme://` authority or the `mailto:`
792        // scheme, so this can't shadow them.
793        HelpLinkTarget::Url(url.to_string())
794    } else {
795        HelpLinkTarget::Unresolved(url.to_string())
796    }
797}
798
799/// Whether `url` carries a real network / application scheme that the OS
800/// handler should open (browser, mail client, registered app). True for
801/// any `scheme://…` authority form (`http://`, `https://`, `ftp://`, and
802/// app links like `slack://` / `vscode://`) and for `mailto:`. Bare
803/// schemeless strings (`github.com/x`, a relative path) are NOT treated as
804/// URLs — they stay `Unresolved` rather than risk shelling out on ambiguous
805/// input.
806fn is_external_url(url: &str) -> bool {
807    if url.starts_with("mailto:") {
808        return true;
809    }
810    // `scheme://authority`: a non-empty scheme of URL-safe characters
811    // followed by `://`. Guarding the scheme shape (rather than a bare
812    // `contains("://")`) keeps this from matching stray text.
813    if let Some((scheme, _)) = url.split_once("://") {
814        return !scheme.is_empty()
815            && scheme
816                .chars()
817                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
818    }
819    false
820}
821
822/// Convert a markdown heading line ("## 1. Tree-sitter, core") into
823/// the GitHub-style slug ("1-tree-sitter-core") used for intra-doc
824/// anchor links. Algorithm:
825///
826/// 1. Strip the leading `#`s + any whitespace.
827/// 2. Lowercase.
828/// 3. Drop any non-alphanumeric / non-hyphen / non-space character
829///    (punctuation, fences, parens, periods, etc.).
830/// 4. Collapse whitespace runs to a single hyphen; collapse hyphen
831///    runs to a single hyphen.
832/// 5. Trim leading / trailing hyphens.
833///
834/// Matches the slugs GitHub renders for `# Heading` blocks so links
835/// authored against rendered docs work in-editor too.
836pub fn slugify_heading(text: &str) -> String {
837    let mut s = text.trim().to_lowercase();
838    // Strip leading `#`s + whitespace.
839    s = s.trim_start_matches('#').trim_start().to_string();
840    let mut out = String::with_capacity(s.len());
841    let mut prev_hyphen = false;
842    for ch in s.chars() {
843        if ch.is_ascii_alphanumeric() {
844            out.push(ch);
845            prev_hyphen = false;
846        } else if (ch == '-' || ch.is_whitespace()) && !prev_hyphen && !out.is_empty() {
847            out.push('-');
848            prev_hyphen = true;
849        }
850        // Anything else (punctuation, backticks, parens, slashes...)
851        // is dropped, mirroring GitHub.
852    }
853    while out.ends_with('-') {
854        out.pop();
855    }
856    out
857}
858
859/// Walk `lines` for ATX-style markdown headings (`#`, `##`, ...) and
860/// emit a [`HelpAnchor`] per heading whose name is the GitHub-style
861/// slug. Used by the help-topic loader so authors can write
862/// `[label](#slug)` in markdown bodies and have the link route in-
863/// editor without manually-managed anchor lists.
864///
865/// Skips heading-shaped lines inside fenced code blocks
866/// (` ``` ` / ` ~~~ `) so a `# foo` line in a Rust example doesn't
867/// register as an anchor.
868pub fn generate_heading_anchors(lines: &[String]) -> Vec<HelpAnchor> {
869    let mut anchors = Vec::new();
870    let mut in_fence = false;
871    for (i, line) in lines.iter().enumerate() {
872        let trimmed = line.trim_start();
873        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
874            in_fence = !in_fence;
875            continue;
876        }
877        if in_fence {
878            continue;
879        }
880        if !trimmed.starts_with('#') {
881            continue;
882        }
883        // Count leading `#`s; ATX cap is 6.
884        let depth = trimmed.chars().take_while(|c| *c == '#').count();
885        if !(1..=6).contains(&depth) {
886            continue;
887        }
888        // Require at least one whitespace between hashes and content
889        // (CommonMark §4.2). Bare `###foo` is not a heading.
890        let after = &trimmed[depth..];
891        if !after.is_empty() && !after.starts_with(|c: char| c.is_whitespace()) {
892            continue;
893        }
894        let slug = slugify_heading(trimmed);
895        if slug.is_empty() {
896            continue;
897        }
898        anchors.push(HelpAnchor {
899            name: slug,
900            line: i as u32,
901        });
902    }
903    anchors
904}
905
906/// Convert a flat byte offset in `text` into a `(line, byte_in_line)`
907/// [`Position`]. Lines are split at `\n`; the byte index past EOL
908/// projects onto the start of the next line.
909fn byte_offset_to_position(text: &str, byte_offset: usize) -> Position {
910    let mut line = 0u32;
911    let mut last_nl = 0usize;
912    let bytes = text.as_bytes();
913    let stop = byte_offset.min(bytes.len());
914    for (i, b) in bytes.iter().enumerate().take(stop) {
915        if *b == b'\n' {
916            line += 1;
917            last_nl = i + 1;
918        }
919    }
920    Position::new(line, (stop - last_nl) as u32)
921}
922
923#[cfg(test)]
924mod tests {
925    #![allow(clippy::unwrap_used, clippy::panic)]
926    use super::*;
927
928    /// HP.1: **a link inside a padded table cell still points at itself.**
929    ///
930    /// Table alignment and link extraction both rewrite the text, and
931    /// the order is load-bearing rather than incidental: link ranges are
932    /// byte offsets into the *cleaned* text, so padding inserted after
933    /// extraction would slide every link on a padded row rightwards and
934    /// `<CR>` on it would resolve against whatever now occupies those
935    /// bytes. The failure is silent — the link still highlights, still
936    /// looks live, and opens the wrong page.
937    ///
938    /// **The fixture has to force a shift, and that is fiddly enough to
939    /// be worth spelling out.** Padding is appended *after* a cell's
940    /// text, so a link only moves if a cell BEFORE it on the same line
941    /// grows. Here the link's row has the narrowest first cell and
942    /// another row has a very wide one, so column 1 pads out and the
943    /// link is pushed right by that many bytes.
944    ///
945    /// A fixture where the link's row happens to be the widest passes
946    /// against the mis-ordered version too — checked by actually
947    /// reordering the pass, which is how this fixture got rewritten.
948    #[test]
949    fn a_link_in_a_table_survives_column_padding() {
950        let h = HelpContent::from_lines(
951            "t",
952            vec![
953                "| Key | Where |".into(),
954                "|---|---|".into(),
955                "| a | [core](help:magit-core-mode) |".into(),
956                "| a-much-much-longer-key | z |".into(),
957            ],
958        );
959        let text = h.buffer.content.as_string();
960        let link = h
961            .metadata
962            .links
963            .iter()
964            .find(|l| matches!(&l.target, HelpLinkTarget::Topic(t) if t == "magit-core-mode"))
965            .expect("the cross-link is extracted");
966
967        // Slice the buffer at the recorded range and require the LABEL
968        // back. Asserting the range's numbers would just restate the
969        // implementation; asserting what is at those bytes is the thing
970        // that breaks when the order is wrong.
971        let line = text
972            .lines()
973            .nth(link.range.start.line as usize)
974            .expect("the link's line exists");
975        let start = link.range.start.byte as usize;
976        let end = link.range.end.byte as usize;
977        assert_eq!(
978            &line[start..end],
979            "core",
980            "the recorded range must still cover the label; line was {line:?}"
981        );
982
983        // And confirm the fixture actually exercised padding — otherwise
984        // it would pass against a build that formats tables not at all.
985        assert!(
986            line.contains("core"),
987            "sanity: the label survives into the buffer: {line:?}"
988        );
989        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
990        assert!(
991            widths.windows(2).all(|w| w[0] == w[1]),
992            "the table was laid out, so this row WAS padded: {widths:?}"
993        );
994    }
995
996    /// HP.2: the spans cover the backticks, not just the text between
997    /// them — a literal whose delimiters stayed prose-coloured would
998    /// look like a typo rather than a boundary.
999    #[test]
1000    fn inline_code_spans_cover_the_whole_literal() {
1001        let spans = inline_code_spans("press `gr` to refresh");
1002        assert_eq!(spans.len(), 1, "{spans:?}");
1003        assert_eq!(spans[0].text, "gr");
1004        assert_eq!(
1005            &"press `gr` to refresh"[spans[0].start..spans[0].end],
1006            "`gr`",
1007            "the range includes both backticks"
1008        );
1009    }
1010
1011    /// A fenced block's contents are already styled as a block, and a
1012    /// backtick inside a shell example is not a literal.
1013    #[test]
1014    fn a_fence_hides_its_backticks() {
1015        let text = "before `a`\n```sh\necho `date`\n```\nafter `b`";
1016        let spans = inline_code_spans(text);
1017        let found: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1018        assert_eq!(found, vec!["a", "b"], "the fenced `date` is skipped");
1019    }
1020
1021    /// Markdown's doubled-backtick form exists so a literal can contain
1022    /// a backtick. Closing at the inner tick would end the span early
1023    /// and swallow the rest of the line into prose.
1024    #[test]
1025    fn a_doubled_tick_span_closes_on_a_doubled_tick() {
1026        let spans = inline_code_spans("write `` `code` `` for a literal");
1027        assert_eq!(spans.len(), 1, "one span, not three: {spans:?}");
1028        assert_eq!(spans[0].text, "`code`");
1029    }
1030
1031    /// Two literals on one line are two spans, and neither swallows the
1032    /// prose between them.
1033    #[test]
1034    fn two_literals_on_a_line_stay_separate() {
1035        let spans = inline_code_spans("`s` stages, `u` unstages");
1036        let found: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1037        assert_eq!(found, vec!["s", "u"]);
1038    }
1039
1040    /// An unclosed backtick is prose, not an unterminated span running
1041    /// to end of line.
1042    #[test]
1043    fn a_lone_backtick_is_not_a_span() {
1044        assert!(inline_code_spans("a lone ` tick").is_empty());
1045    }
1046
1047    #[test]
1048    fn from_lines_and_anchors_stores_provided_anchors() {
1049        let h = HelpContent::from_lines_and_anchors(
1050            "t",
1051            vec!["heading".into(), "body".into()],
1052            vec![HelpAnchor {
1053                name: "section:foo".into(),
1054                line: 0,
1055            }],
1056        );
1057        assert_eq!(h.metadata.anchors.len(), 1);
1058        assert_eq!(h.metadata.anchors[0].name, "section:foo");
1059    }
1060
1061    #[test]
1062    fn scroll_to_anchor_moves_to_recorded_line() {
1063        let mut h = HelpContent::from_lines_and_anchors(
1064            "t",
1065            (0..30).map(|i| format!("line {i}")).collect(),
1066            vec![HelpAnchor {
1067                name: "mid".into(),
1068                line: 15,
1069            }],
1070        );
1071        assert!(h.scroll_to_anchor("mid"));
1072        assert_eq!(h.scroll, 15);
1073    }
1074
1075    #[test]
1076    fn scroll_to_unknown_anchor_returns_false_and_leaves_scroll_alone() {
1077        let mut h = HelpContent::from_lines_and_anchors("t", vec!["a".into(), "b".into()], vec![]);
1078        h.scroll = 1;
1079        assert!(!h.scroll_to_anchor("nope"));
1080        assert_eq!(h.scroll, 1);
1081    }
1082
1083    #[test]
1084    fn from_lines_creates_buffer_without_anchors() {
1085        let h = HelpContent::from_lines("t", vec!["x".into()]);
1086        assert!(h.metadata.anchors.is_empty());
1087    }
1088
1089    #[test]
1090    fn from_lines_round_trips_through_buffer() {
1091        let h = HelpContent::from_lines("t", vec!["one".into(), "two".into(), "three".into()]);
1092        assert_eq!(h.title, "t");
1093        assert_eq!(h.line_count(), 3);
1094        let lines = h.lines();
1095        assert_eq!(lines, vec!["one", "two", "three"]);
1096    }
1097
1098    #[test]
1099    fn empty_lines_yield_empty_buffer() {
1100        let h = HelpContent::from_lines("t", vec![]);
1101        assert_eq!(h.line_count(), 1); // empty buffer reports one empty line
1102        assert!(h.metadata.links.is_empty());
1103    }
1104
1105    /// Chords that carry the link syntax's own punctuation — most bracket
1106    /// motions and text objects — must round-trip. Unescaped, `]f` closed the
1107    /// label after `[`, `di(` survived only by luck, and `da)` ended the URL
1108    /// early: `:describe-key ]f` rendered its own heading as `[]f](key:]f)`.
1109    #[test]
1110    fn a_chord_with_link_punctuation_round_trips() {
1111        for chord in ["]f", "[[", "]]", "di(", "da)", "ci]", "a[", "\\", "g\\]"] {
1112            let (clean, links) = extract_links_and_clean(&format!("see {} now", key_link(chord)));
1113            assert_eq!(
1114                clean,
1115                format!("see {chord} now"),
1116                "rendered label for `{chord}`"
1117            );
1118            assert_eq!(links.len(), 1, "one link for `{chord}`: {links:?}");
1119            match &links[0].target {
1120                HelpLinkTarget::Chord(c) => assert_eq!(c, chord, "target for `{chord}`"),
1121                other => panic!("`{chord}` resolved to {other:?}"),
1122            }
1123            // The verbatim parser (markdown mode) must agree on the target.
1124            let verbatim = parse_help_links(&key_link(chord));
1125            assert!(
1126                matches!(&verbatim[..], [l] if l.target == HelpLinkTarget::Chord(chord.into())),
1127                "verbatim parse of `{chord}`: {verbatim:?}"
1128            );
1129        }
1130    }
1131
1132    #[test]
1133    fn parse_help_links_extracts_command_link() {
1134        let links = parse_help_links("see [ex:write](command:ex:write) for details");
1135        assert_eq!(links.len(), 1);
1136        assert!(matches!(
1137            &links[0].target,
1138            HelpLinkTarget::Command(s) if s == "ex:write"
1139        ));
1140    }
1141
1142    #[test]
1143    fn parse_help_links_extracts_chord_link() {
1144        let links = parse_help_links("press [<C-d>](key:<C-d>) to scroll");
1145        assert_eq!(links.len(), 1);
1146        assert!(matches!(
1147            &links[0].target,
1148            HelpLinkTarget::Chord(s) if s == "<C-d>"
1149        ));
1150    }
1151
1152    #[test]
1153    fn parse_help_links_extracts_source_link() {
1154        let links = parse_help_links("source: [src/foo.rs:42](file:src/foo.rs:42)");
1155        assert_eq!(links.len(), 1);
1156        match &links[0].target {
1157            HelpLinkTarget::Source { path, line } => {
1158                assert_eq!(path, &PathBuf::from("src/foo.rs"));
1159                assert_eq!(*line, 42);
1160            }
1161            other => panic!("unexpected target: {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn parse_help_links_unknown_scheme_is_unresolved() {
1167        let links = parse_help_links("see [editor.line-numbers](option:editor.line-numbers)");
1168        assert_eq!(links.len(), 1);
1169        assert!(matches!(
1170            &links[0].target,
1171            HelpLinkTarget::Unresolved(s) if s == "option:editor.line-numbers"
1172        ));
1173    }
1174
1175    #[test]
1176    fn parse_help_links_handles_multiple_on_one_line() {
1177        let links = parse_help_links("[a](command:a) and [b](key:b)");
1178        assert_eq!(links.len(), 2);
1179        assert!(matches!(&links[0].target, HelpLinkTarget::Command(s) if s == "a"));
1180        assert!(matches!(&links[1].target, HelpLinkTarget::Chord(s) if s == "b"));
1181    }
1182
1183    #[test]
1184    fn parse_help_links_unmatched_bracket_is_ignored() {
1185        let links = parse_help_links("see [command](command:no-close");
1186        // No closing `)` -- ignored.
1187        assert!(links.is_empty());
1188    }
1189
1190    #[test]
1191    fn parse_help_links_label_only_is_ignored() {
1192        // Markdown link requires `(url)` after the label; a bare
1193        // `[label]` (reference-style markdown) is currently unused in
1194        // help bodies and gets ignored by the parser.
1195        let links = parse_help_links("see [foo] for details");
1196        assert!(links.is_empty());
1197    }
1198
1199    #[test]
1200    fn parse_help_links_records_byte_positions_across_lines() {
1201        let text = "first\n[x](command:x)\nthird";
1202        let links = parse_help_links(text);
1203        assert_eq!(links.len(), 1);
1204        assert_eq!(links[0].range.start.line, 1);
1205        // The label `x` starts at byte 1 on line 1 (after the `[`).
1206        assert_eq!(links[0].range.start.byte, 1);
1207    }
1208
1209    #[test]
1210    fn link_helpers_emit_standard_markdown() {
1211        assert_eq!(command_link("ex:write"), "[ex:write](command:ex:write)");
1212        assert_eq!(key_link("zo"), "[zo](key:zo)");
1213        assert_eq!(
1214            source_link("src/foo.rs:42"),
1215            "[src/foo.rs:42](file:src/foo.rs:42)"
1216        );
1217    }
1218
1219    #[test]
1220    fn key_link_helper_renders_markup() {
1221        assert_eq!(key_link("<C-d>"), "[<C-d>](key:<C-d>)");
1222    }
1223
1224    #[test]
1225    fn command_link_helper_renders_markup() {
1226        assert_eq!(command_link("ex:write"), "[ex:write](command:ex:write)");
1227    }
1228
1229    // --- Anchor links + heading slugs ---------------------------
1230
1231    #[test]
1232    fn slugify_heading_matches_github_style() {
1233        assert_eq!(slugify_heading("# Quick reference"), "quick-reference");
1234        assert_eq!(
1235            slugify_heading("## 1. Tree-sitter, core"),
1236            "1-tree-sitter-core"
1237        );
1238        assert_eq!(
1239            slugify_heading("### Step 1 -- pin the grammar crate"),
1240            "step-1-pin-the-grammar-crate"
1241        );
1242        assert_eq!(slugify_heading("### What you lose"), "what-you-lose");
1243        assert_eq!(slugify_heading("##  Trailing   space  "), "trailing-space");
1244        assert_eq!(slugify_heading("# `code` ignored?"), "code-ignored");
1245    }
1246
1247    #[test]
1248    fn classify_link_url_routes_anchor_form() {
1249        match classify_link_url("#1-tree-sitter-core") {
1250            HelpLinkTarget::Anchor(s) => assert_eq!(s, "1-tree-sitter-core"),
1251            other => panic!("expected Anchor, got {other:?}"),
1252        }
1253    }
1254
1255    #[test]
1256    fn classify_link_url_routes_customize_scheme() {
1257        // M.9.1: `customize:NAME` follows to `:customize NAME`.
1258        match classify_link_url("customize:lsp-completion-mode") {
1259            HelpLinkTarget::Customize(s) => {
1260                assert_eq!(s, "lsp-completion-mode");
1261            }
1262            other => panic!("expected Customize, got {other:?}"),
1263        }
1264        match classify_link_url("customize:editor") {
1265            HelpLinkTarget::Customize(s) => assert_eq!(s, "editor"),
1266            other => panic!("expected Customize, got {other:?}"),
1267        }
1268    }
1269
1270    #[test]
1271    fn classify_link_url_routes_customize_edit_scheme_separately() {
1272        // M.9.2: `customize-edit:NAME` is a distinct scheme
1273        // from `customize:NAME` (must come first in the parse
1274        // chain since they share a prefix).
1275        match classify_link_url("customize-edit:tabstop") {
1276            HelpLinkTarget::CustomizeEdit(s) => assert_eq!(s, "tabstop"),
1277            other => panic!("expected CustomizeEdit, got {other:?}"),
1278        }
1279        // The plain `customize:` scheme stays correct -- not
1280        // accidentally captured by the longer prefix's parse.
1281        match classify_link_url("customize:editor") {
1282            HelpLinkTarget::Customize(s) => assert_eq!(s, "editor"),
1283            other => panic!("expected Customize, got {other:?}"),
1284        }
1285    }
1286
1287    #[test]
1288    fn classify_link_url_routes_external_urls() {
1289        // Real network / app schemes → Url (opened by the OS handler).
1290        for url in [
1291            "https://github.com/dhruvasagar/lattice",
1292            "http://example.com/x",
1293            "ftp://host/file",
1294            "slack://channel?team=T&id=C",
1295            "vscode://file/abs/path",
1296            "mailto:hi@example.com",
1297        ] {
1298            match classify_link_url(url) {
1299                HelpLinkTarget::Url(s) => assert_eq!(s, url),
1300                other => panic!("expected Url for {url:?}, got {other:?}"),
1301            }
1302        }
1303    }
1304
1305    #[test]
1306    fn classify_link_url_does_not_treat_help_schemes_or_bare_text_as_urls() {
1307        // Known help schemes must NOT be captured by the URL check (it runs
1308        // last, and none use a `scheme://` authority).
1309        assert!(matches!(
1310            classify_link_url("help:modes"),
1311            HelpLinkTarget::Topic(_)
1312        ));
1313        assert!(matches!(
1314            classify_link_url("exec:tutor"),
1315            HelpLinkTarget::Execute(_)
1316        ));
1317        assert!(matches!(
1318            classify_link_url("customize:editor"),
1319            HelpLinkTarget::Customize(_)
1320        ));
1321        // Schemeless / ambiguous strings stay Unresolved — no shelling out.
1322        assert!(matches!(
1323            classify_link_url("github.com/x"),
1324            HelpLinkTarget::Unresolved(_)
1325        ));
1326        assert!(matches!(
1327            classify_link_url("just some text"),
1328            HelpLinkTarget::Unresolved(_)
1329        ));
1330        assert!(matches!(
1331            classify_link_url("://no-scheme"),
1332            HelpLinkTarget::Unresolved(_)
1333        ));
1334    }
1335
1336    #[test]
1337    fn generate_heading_anchors_emits_one_per_heading() {
1338        let lines = vec![
1339            "# Title".into(),
1340            "body".into(),
1341            "## 1. Tree-sitter, core".into(),
1342            "more".into(),
1343            "### Step 1 -- pin".into(),
1344            "## 2. Plugin".into(),
1345        ];
1346        let anchors = generate_heading_anchors(&lines);
1347        let names: Vec<&str> = anchors.iter().map(|a| a.name.as_str()).collect();
1348        assert_eq!(
1349            names,
1350            vec!["title", "1-tree-sitter-core", "step-1-pin", "2-plugin"]
1351        );
1352        assert_eq!(anchors[1].line, 2);
1353        assert_eq!(anchors[3].line, 5);
1354    }
1355
1356    #[test]
1357    fn generate_heading_anchors_skips_inside_fenced_code_blocks() {
1358        // A `# foo` line inside a code fence is example content, not
1359        // a real heading.
1360        let lines = vec![
1361            "# Real Title".into(),
1362            "```".into(),
1363            "# not a heading".into(),
1364            "```".into(),
1365            "## After".into(),
1366        ];
1367        let anchors = generate_heading_anchors(&lines);
1368        let names: Vec<&str> = anchors.iter().map(|a| a.name.as_str()).collect();
1369        assert_eq!(names, vec!["real-title", "after"]);
1370    }
1371
1372    #[test]
1373    fn from_lines_parses_anchor_link_target() {
1374        // A markdown link `[Section 1](#1-tree-sitter-core)` should
1375        // produce a HelpLink with the Anchor target so follow-link
1376        // routes to scroll_to_anchor instead of "no handler".
1377        let h = HelpContent::from_lines(
1378            "t",
1379            vec!["see [Section 1](#1-tree-sitter-core) for details".into()],
1380        );
1381        assert_eq!(h.metadata.links.len(), 1);
1382        match &h.metadata.links[0].target {
1383            HelpLinkTarget::Anchor(slug) => assert_eq!(slug, "1-tree-sitter-core"),
1384            other => panic!("expected Anchor target, got {other:?}"),
1385        }
1386    }
1387}