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 |".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 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}