Skip to main content

lattice_grammar/
introspect.rs

1//! Generic introspection (DESIGN.md §5.11).
2//!
3//! Every `:describe-*` target implements [`Introspectable`]; the
4//! shared [`render_introspection`] function turns one into the help
5//! body the host wraps in a `HelpBuffer`. The trait gives:
6//!
7//! - **Uniform output**: kind + identifier + doc + sources +
8//!   type-specific extras render in a consistent shape across
9//!   `:describe-command`, `:describe-key`, `:describe-option`,
10//!   `:describe-event`, `:describe-mode`.
11//! - **One place to change**: tweaking how sources or extras render
12//!   touches `render_introspection`, not every formatter.
13//! - **Plug-in for new registries**: when typed options (§5.12) /
14//!   events (§5.10) / modes (Phase 8) land, each adds an
15//!   `impl Introspectable` and the introspection surface picks it
16//!   up automatically.
17//!
18//! # Examples
19//!
20//! ```
21//! use lattice_grammar::{
22//!     HelpSection, Introspectable, SourceEntry, SourceLabel, SourceLocation,
23//!     render_introspection,
24//! };
25//!
26//! struct Tabstop(SourceLocation);
27//!
28//! impl Introspectable for Tabstop {
29//!     fn kind_label(&self) -> &'static str {
30//!         "option"
31//!     }
32//!     fn identifier(&self) -> String {
33//!         "editor.tabstop".into()
34//!     }
35//!     fn doc(&self) -> &str {
36//!         "Display width of a tab character."
37//!     }
38//!     fn sources(&self) -> Vec<SourceEntry<'_>> {
39//!         vec![SourceEntry { label: SourceLabel::DefinedAt, source: &self.0 }]
40//!     }
41//!     fn extra_sections(&self) -> Vec<HelpSection> {
42//!         vec![HelpSection {
43//!             heading: "Value:".into(),
44//!             lines: vec!["       8".into()],
45//!             anchor: Some("value".into()),
46//!         }]
47//!     }
48//! }
49//!
50//! let out = render_introspection(&Tabstop(SourceLocation::builtin_file("config.rs", 12)));
51//! assert_eq!(out.lines[0], "editor.tabstop =");   // identifier + kind icon
52//! assert_eq!(out.lines[2], "Display width of a tab character.");
53//! assert_eq!(out.anchors[0].name, "value");
54//! assert_eq!(out.lines[out.anchors[0].line as usize], "Value:");
55//! assert!(out.lines.last().unwrap().starts_with("Defined at: [config.rs:12]"));
56//! ```
57
58use crate::command::kind_icon;
59use crate::source::SourceLocation;
60
61/// A registered / bound / set thing, queryable from `:describe-*`.
62pub trait Introspectable {
63    /// Kind label for the help-buffer heading: `"command"`, `"key"`,
64    /// `"option"`, `"event"`, `"mode"`, `"buffer"`.
65    fn kind_label(&self) -> &'static str;
66
67    /// User-facing identifier: `"ex:write"`, `"j"`,
68    /// `"editor.line-numbers"`.
69    fn identifier(&self) -> String;
70
71    /// Multi-line documentation. May be empty (the renderer prints a
72    /// `(no documentation)` placeholder).
73    fn doc(&self) -> &str;
74
75    /// One or more provenance entries. Empty means "no recorded
76    /// origin" (common for synthesised buffers / runtime values
77    /// without a trace).
78    fn sources(&self) -> Vec<SourceEntry<'_>>;
79
80    /// Type-specific blocks: args list for commands, mode-grouped
81    /// bindings for keys, value/type for options, payload for events,
82    /// keymap chain for modes. Default empty.
83    fn extra_sections(&self) -> Vec<HelpSection> {
84        Vec::new()
85    }
86}
87
88/// One labeled provenance link in a help body.
89pub struct SourceEntry<'a> {
90    /// How the location relates to the item (defined, bound, last set, …).
91    pub label: SourceLabel,
92    /// Where it happened; rendered as a followable link.
93    pub source: &'a SourceLocation,
94}
95
96/// Human-readable label rendered before the link. Each variant maps
97/// to a concrete prose phrase.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SourceLabel {
100    /// Where the item was registered (a command, option, event, mode).
101    DefinedAt,
102    /// Where a key binding was declared.
103    BoundAt,
104    /// Where an event subscription was made.
105    SubscribedAt,
106    /// Where an option's current value was last written.
107    LastSetAt,
108    /// Where a layered value (e.g. a buffer-local option) overrode the
109    /// one beneath it.
110    OverriddenAt,
111    /// Where a mode was activated on the buffer.
112    ActivatedAt,
113}
114
115impl SourceLabel {
116    /// The phrase rendered before the link: `"Defined at"`, `"Bound at"`, …
117    pub fn as_prose(self) -> &'static str {
118        match self {
119            SourceLabel::DefinedAt => "Defined at",
120            SourceLabel::BoundAt => "Bound at",
121            SourceLabel::SubscribedAt => "Subscribed at",
122            SourceLabel::LastSetAt => "Last set at",
123            SourceLabel::OverriddenAt => "Overridden at",
124            SourceLabel::ActivatedAt => "Activated at",
125        }
126    }
127}
128
129/// One named block of body lines. Rendered after the doc and before
130/// the source links. Used by impls to surface type-specific structure
131/// (e.g. `:describe-command` renders an "Arguments:" section from
132/// `args_schema`).
133///
134/// `anchor` (DESIGN.md §5.11) lets cross-references jump to the
135/// section. Convention is `kind:name` -- e.g. `arg:path`,
136/// `args` (the parent section), `section:examples`. The anchor
137/// name is recorded against the section's heading line in the
138/// rendered output so a follower can scroll directly to it.
139pub struct HelpSection {
140    /// The section's heading line, rendered verbatim (e.g. `"Arguments:"`).
141    pub heading: String,
142    /// Body lines under the heading, rendered verbatim — impls indent them
143    /// themselves.
144    pub lines: Vec<String>,
145    /// Anchor name recorded against the heading line, or `None` for a
146    /// section nothing links to.
147    pub anchor: Option<String>,
148}
149
150/// Anchor extracted by `render_introspection`. The line index points
151/// at the section's heading row in the rendered body; a follower
152/// scrolls the help buffer to (or near) this row.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct RenderedAnchor {
155    /// The anchor name from [`HelpSection::anchor`].
156    pub name: String,
157    /// 0-based index into [`RenderedIntrospection::lines`] of the heading.
158    pub line: u32,
159}
160
161/// Output of [`render_introspection`]. Returns both the rendered
162/// lines AND any anchors recorded during rendering. Hosts wrap
163/// `lines` into a HelpBuffer's content and feed `anchors` into the
164/// HelpBuffer's anchor index.
165#[derive(Debug, Clone)]
166pub struct RenderedIntrospection {
167    /// The help body, one entry per line, no trailing newlines.
168    pub lines: Vec<String>,
169    /// Every anchored section's heading position, in render order.
170    pub anchors: Vec<RenderedAnchor>,
171}
172
173/// Render an [`Introspectable`] into help-body lines + anchors.
174/// The generic shape every `:describe-*` produces:
175///
176/// ```text
177/// {identifier} {kind icon}               ← icon from `kind_icon(kind_label)`
178///
179/// {doc}
180///
181/// {extra_section_heading}                ← anchor: section.anchor
182///   {extra_section_lines}
183///
184/// {label}: {source.as_link()}  ({source.layer.label()})
185/// ```
186///
187/// An empty doc renders as `(no documentation)`; the sources block is
188/// omitted when [`Introspectable::sources`] is empty.
189///
190/// Anchor positions point at each section's heading line. Hosts that
191/// don't need anchors can read `result.lines` and ignore
192/// `result.anchors`.
193pub fn render_introspection(item: &dyn Introspectable) -> RenderedIntrospection {
194    render_introspection_with(item, &|_| None)
195}
196
197/// [`render_introspection`], with a plugin-name resolver.
198///
199/// Every `:describe-*` view goes through here, so resolving at THIS point
200/// names plugins on all of them at once — rather than each registration path
201/// having to stamp a name it may not hold. `:list-commands` already resolved
202/// ids this way; this brings the describe views into line.
203pub fn render_introspection_with(
204    item: &dyn Introspectable,
205    resolve_plugin: &dyn Fn(u32) -> Option<String>,
206) -> RenderedIntrospection {
207    let mut lines = Vec::new();
208    let mut anchors = Vec::new();
209    lines.push(format!(
210        "{} {}",
211        item.identifier(),
212        kind_icon(item.kind_label())
213    ));
214    lines.push(String::new());
215    let doc = item.doc();
216    if doc.is_empty() {
217        lines.push("(no documentation)".to_string());
218    } else {
219        for l in doc.lines() {
220            lines.push(l.to_string());
221        }
222    }
223    for section in item.extra_sections() {
224        lines.push(String::new());
225        let heading_line = lines.len() as u32;
226        if let Some(name) = section.anchor {
227            anchors.push(RenderedAnchor {
228                name,
229                line: heading_line,
230            });
231        }
232        lines.push(section.heading);
233        for l in section.lines {
234            lines.push(l);
235        }
236    }
237    let sources = item.sources();
238    if !sources.is_empty() {
239        lines.push(String::new());
240        for SourceEntry { label, source } in sources {
241            lines.push(format!(
242                "{}: {}  ({})",
243                label.as_prose(),
244                source.as_link_with(resolve_plugin),
245                source.layer.label(),
246            ));
247        }
248    }
249    RenderedIntrospection { lines, anchors }
250}
251
252/// Convenience for callers that only want the rendered lines (no
253/// anchor follow-up). Wraps `render_introspection` and discards
254/// anchors.
255pub fn render_introspection_lines(item: &dyn Introspectable) -> Vec<String> {
256    render_introspection(item).lines
257}
258
259#[cfg(test)]
260mod tests {
261    #![allow(clippy::unwrap_used, clippy::panic)]
262    use super::*;
263    use crate::source::{SourceKind, SourceLayer};
264
265    struct StubItem {
266        ident: String,
267        doc: String,
268        source: SourceLocation,
269    }
270
271    impl Introspectable for StubItem {
272        fn kind_label(&self) -> &'static str {
273            "stub"
274        }
275        fn identifier(&self) -> String {
276            self.ident.clone()
277        }
278        fn doc(&self) -> &str {
279            &self.doc
280        }
281        fn sources(&self) -> Vec<SourceEntry<'_>> {
282            vec![SourceEntry {
283                label: SourceLabel::DefinedAt,
284                source: &self.source,
285            }]
286        }
287    }
288
289    #[test]
290    fn rendered_output_starts_with_identifier_and_kind() {
291        let item = StubItem {
292            ident: "ex:write".into(),
293            doc: "Write the buffer.".into(),
294            source: SourceLocation::builtin_file("foo.rs", 7),
295        };
296        let result = render_introspection(&item);
297        assert_eq!(result.lines[0], "ex:write ·");
298        assert!(result.lines.iter().any(|l| l.contains("Write the buffer.")));
299    }
300
301    #[test]
302    fn rendered_output_emits_source_link_with_layer_label() {
303        let item = StubItem {
304            ident: "x".into(),
305            doc: "doc".into(),
306            source: SourceLocation::builtin_file("a/b.rs", 99),
307        };
308        let result = render_introspection(&item);
309        let last = result.lines.last().unwrap();
310        assert!(last.contains("Defined at:"));
311        assert!(last.contains("[a/b.rs:99](file:a/b.rs:99)"));
312        assert!(last.contains("(built-in)"));
313    }
314
315    #[test]
316    fn empty_doc_renders_placeholder() {
317        let item = StubItem {
318            ident: "x".into(),
319            doc: String::new(),
320            source: SourceLocation::builtin_file("a.rs", 1),
321        };
322        let result = render_introspection(&item);
323        assert!(result.lines.iter().any(|l| l == "(no documentation)"));
324    }
325
326    #[test]
327    fn no_sources_omits_the_block() {
328        struct NoSources;
329        impl Introspectable for NoSources {
330            fn kind_label(&self) -> &'static str {
331                "stub"
332            }
333            fn identifier(&self) -> String {
334                "x".into()
335            }
336            fn doc(&self) -> &str {
337                "doc"
338            }
339            fn sources(&self) -> Vec<SourceEntry<'_>> {
340                Vec::new()
341            }
342        }
343        let result = render_introspection(&NoSources);
344        assert!(result.lines.iter().all(|l| !l.contains("Defined at")));
345    }
346
347    #[test]
348    fn extra_sections_render_after_doc() {
349        struct WithSection {
350            source: SourceLocation,
351        }
352        impl Introspectable for WithSection {
353            fn kind_label(&self) -> &'static str {
354                "stub"
355            }
356            fn identifier(&self) -> String {
357                "x".into()
358            }
359            fn doc(&self) -> &str {
360                "the doc"
361            }
362            fn sources(&self) -> Vec<SourceEntry<'_>> {
363                vec![SourceEntry {
364                    label: SourceLabel::DefinedAt,
365                    source: &self.source,
366                }]
367            }
368            fn extra_sections(&self) -> Vec<HelpSection> {
369                vec![HelpSection {
370                    heading: "Arguments:".into(),
371                    lines: vec!["  1. path".into()],
372                    anchor: None,
373                }]
374            }
375        }
376        let item = WithSection {
377            source: SourceLocation::builtin_file("a.rs", 1),
378        };
379        let result = render_introspection(&item);
380        let heading_idx = result.lines.iter().position(|l| l == "Arguments:").unwrap();
381        let arg_idx = result.lines.iter().position(|l| l == "  1. path").unwrap();
382        let source_idx = result
383            .lines
384            .iter()
385            .position(|l| l.contains("Defined at:"))
386            .unwrap();
387        assert!(heading_idx < arg_idx);
388        assert!(arg_idx < source_idx);
389    }
390
391    #[test]
392    fn anchored_sections_record_anchor_at_heading_line() {
393        struct WithAnchors {
394            source: SourceLocation,
395        }
396        impl Introspectable for WithAnchors {
397            fn kind_label(&self) -> &'static str {
398                "stub"
399            }
400            fn identifier(&self) -> String {
401                "x".into()
402            }
403            fn doc(&self) -> &str {
404                "doc"
405            }
406            fn sources(&self) -> Vec<SourceEntry<'_>> {
407                vec![SourceEntry {
408                    label: SourceLabel::DefinedAt,
409                    source: &self.source,
410                }]
411            }
412            fn extra_sections(&self) -> Vec<HelpSection> {
413                vec![
414                    HelpSection {
415                        heading: "Arguments:".into(),
416                        lines: vec!["  1. path".into()],
417                        anchor: Some("args".into()),
418                    },
419                    HelpSection {
420                        heading: "  1. path: String".into(),
421                        lines: vec!["       File path".into()],
422                        anchor: Some("arg:path".into()),
423                    },
424                ]
425            }
426        }
427        let item = WithAnchors {
428            source: SourceLocation::builtin_file("a.rs", 1),
429        };
430        let result = render_introspection(&item);
431        // Two anchors recorded.
432        assert_eq!(result.anchors.len(), 2);
433        // First anchor's name + line points at "Arguments:".
434        let args_anchor = result.anchors.iter().find(|a| a.name == "args").unwrap();
435        assert_eq!(result.lines[args_anchor.line as usize], "Arguments:");
436        // Second anchor for the per-arg subsection.
437        let arg_path = result
438            .anchors
439            .iter()
440            .find(|a| a.name == "arg:path")
441            .unwrap();
442        assert_eq!(result.lines[arg_path.line as usize], "  1. path: String");
443    }
444
445    #[test]
446    fn sections_without_anchor_dont_pollute_anchor_list() {
447        struct NoAnchorSection {
448            source: SourceLocation,
449        }
450        impl Introspectable for NoAnchorSection {
451            fn kind_label(&self) -> &'static str {
452                "stub"
453            }
454            fn identifier(&self) -> String {
455                "x".into()
456            }
457            fn doc(&self) -> &str {
458                "doc"
459            }
460            fn sources(&self) -> Vec<SourceEntry<'_>> {
461                vec![SourceEntry {
462                    label: SourceLabel::DefinedAt,
463                    source: &self.source,
464                }]
465            }
466            fn extra_sections(&self) -> Vec<HelpSection> {
467                vec![HelpSection {
468                    heading: "Examples:".into(),
469                    lines: vec!["  echo".into()],
470                    anchor: None,
471                }]
472            }
473        }
474        let item = NoAnchorSection {
475            source: SourceLocation::builtin_file("a.rs", 1),
476        };
477        let result = render_introspection(&item);
478        assert!(result.anchors.is_empty());
479    }
480
481    #[test]
482    fn render_introspection_lines_helper_drops_anchors() {
483        let item = StubItem {
484            ident: "x".into(),
485            doc: "d".into(),
486            source: SourceLocation::builtin_file("a.rs", 1),
487        };
488        let lines = render_introspection_lines(&item);
489        assert!(!lines.is_empty());
490    }
491
492    #[test]
493    fn dot_repeat_chained_source_serialises_as_link() {
494        let inner = SourceLocation::builtin_file("a.rs", 5);
495        let s = SourceLocation {
496            layer: SourceLayer::Runtime,
497            kind: SourceKind::DotRepeat(Box::new(inner)),
498        };
499        assert!(s.as_link().contains("dot-repeat-of"));
500        assert!(s.as_link().contains("a.rs:5"));
501    }
502}