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}