Skip to main content

lattice_grammar/
source.rs

1//! Provenance metadata (DESIGN.md §5.11).
2//!
3//! Every registered / bound / set thing carries a `SourceLocation`.
4//! `:describe-*` formatters render it as a `[[file:...]]`-style link
5//! the user can follow to inspect or edit the source.
6//!
7//! Forgery prevention is structural: there is no public function that
8//! takes a `SourceLocation` parameter and stores it. Built-in
9//! registrations capture the call site via `#[track_caller]`; static
10//! slices use declarative macros (`keymap_entry!`, ...) that inject
11//! the location at each row's site. Trusted subsystems (config
12//! loader, plugin host bridge, runtime dispatcher) reach the
13//! `pub(crate) insert_*` registry methods directly and construct
14//! sources from their own ground truth.
15
16use std::path::PathBuf;
17
18/// Where a registered command, binding or option came from: *who* put it
19/// there ([`SourceLayer`]) and *where to look* ([`SourceKind`]).
20///
21/// # Examples
22///
23/// ```
24/// use lattice_grammar::{SourceLayer, SourceLocation};
25///
26/// let src = SourceLocation::builtin_file("crates/lattice-grammar/src/builtins.rs", 42);
27/// assert_eq!(src.layer, SourceLayer::Builtin);
28/// assert_eq!(
29///     src.as_link(),
30///     "[crates/lattice-grammar/src/builtins.rs:42](file:crates/lattice-grammar/src/builtins.rs:42)",
31/// );
32///
33/// // A plugin source renders its manifest name when the caller can resolve it.
34/// let p = SourceLocation::plugin(3);
35/// assert_eq!(p.as_link(), "[<plugin:3>](synthetic:plugin:3)");
36/// assert_eq!(
37///     p.as_link_with(&|id| (id == 3).then(|| "comment".to_string())),
38///     "[plugin:comment](synthetic:plugin:comment)",
39/// );
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct SourceLocation {
43    /// Who contributed the item; rendered as [`SourceLayer::label`].
44    pub layer: SourceLayer,
45    /// Where the link follower should go.
46    pub kind: SourceKind,
47}
48
49/// Why this thing exists -- editor binary, user config, plugin, etc.
50/// Renders as a one-word label next to the `[[link]]` in help output.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
52pub enum SourceLayer {
53    /// Compiled into the editor binary.
54    Builtin,
55    /// `~/.config/lattice/config.toml`.
56    UserConfig,
57    /// `.lattice/config.toml` at workspace root.
58    ProjectConfig,
59    /// Per-buffer modeline / `:setlocal` directive.
60    Modeline,
61    /// Typed at `:`, replayed by macro, or `.` re-dispatch.
62    Runtime,
63    /// WASM plugin (Phase 7). The `u32` is the plugin id issued by
64    /// the host.
65    Plugin(u32),
66}
67
68impl SourceLayer {
69    /// The one-or-two-word label shown beside the source link in help
70    /// output (`"built-in"`, `"user config"`, `"plugin"`, ...). A plugin's
71    /// id is not included; see [`SourceLocation::as_link_with`] for naming.
72    pub fn label(self) -> &'static str {
73        match self {
74            SourceLayer::Builtin => "built-in",
75            SourceLayer::UserConfig => "user config",
76            SourceLayer::ProjectConfig => "project config",
77            SourceLayer::Modeline => "modeline",
78            SourceLayer::Runtime => "runtime",
79            SourceLayer::Plugin(_) => "plugin",
80        }
81    }
82}
83
84/// Where to look. Most cases are a real file with a line; a few are
85/// synthetic origins that the link follower routes to a different
86/// kind of buffer (command-history, macro buffer, transitive
87/// dot-repeat).
88#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89pub enum SourceKind {
90    /// Concrete file location, optionally with a line.
91    File {
92        /// Path as recorded -- for builtins, the compile-time path from
93        /// `Location::caller()` (workspace-relative); for config, the
94        /// config file's path.
95        path: PathBuf,
96        /// 1-based line (as `Location::caller()` reports it); `None` when
97        /// only the file is known.
98        line: Option<u32>,
99    },
100    /// `:` invocation. Link follower opens the command-history
101    /// buffer at `history_index`.
102    CommandLine {
103        /// Index into the command-line history.
104        history_index: usize,
105    },
106    /// Replayed from a recorded macro. Link follower opens the
107    /// `*macro:<reg>*` buffer at `step`.
108    MacroReplay {
109        /// The register the macro was recorded into.
110        register: char,
111        /// Position of the replayed invocation within the macro.
112        step: u32,
113    },
114    /// `.` re-dispatch. Boxes the originating source so chains of
115    /// dot-repeats trace back to where the change actually came
116    /// from. The link follower follows the inner source.
117    DotRepeat(Box<SourceLocation>),
118    /// Synthetic origin like `<initial-load>` or `<test-fixture>`.
119    /// Renders inert. Owned `String` (not `&'static`) so the type
120    /// can derive Deserialize for plugin-bridge wire transport.
121    Synthetic(String),
122}
123
124impl SourceLocation {
125    /// Construct a `Builtin` source from a `(file, line)` pair.
126    /// Almost always called via `Location::caller()` in a
127    /// `#[track_caller]` registration method.
128    pub fn builtin_file(file: &str, line: u32) -> Self {
129        Self {
130            layer: SourceLayer::Builtin,
131            kind: SourceKind::File {
132                path: PathBuf::from(file),
133                line: Some(line),
134            },
135        }
136    }
137
138    /// Synthetic source for tests + the rare runtime case where no
139    /// concrete origin exists.
140    pub fn synthetic(tag: impl Into<String>) -> Self {
141        Self {
142            layer: SourceLayer::Runtime,
143            kind: SourceKind::Synthetic(tag.into()),
144        }
145    }
146
147    /// [`Self::plugin`], with the plugin's manifest name -- which is what a
148    /// reader wants. `plugin:comment` answers "where did `gc` come from";
149    /// `plugin:1` makes them go and look the number up.
150    ///
151    /// Separate from [`Self::plugin`] because the name is not always in hand:
152    /// `register_plugin_*` runs in the drain with only an id, while a caller
153    /// that holds the manifest can do better.
154    pub fn plugin_named(plugin_id: u32, name: &str) -> Self {
155        Self {
156            layer: SourceLayer::Plugin(plugin_id),
157            kind: SourceKind::Synthetic(format!("plugin:{name}")),
158        }
159    }
160
161    /// Provenance for a WASM-plugin contribution (Phase 7). The layer is
162    /// [`SourceLayer::Plugin`] carrying the **host-issued** `plugin_id` (§6 —
163    /// the guest never supplies it, so a plugin cannot forge a builtin/user
164    /// provenance); the kind is a synthetic `plugin:N` tag (a plugin has no
165    /// file/line the link follower could open — the plugin-manager view is the
166    /// eventual target). This is the *only* forgery-safe way a cross-crate
167    /// trusted subsystem stamps `Plugin` provenance: the public
168    /// `CommandRegistry::register_plugin_*` methods take a `u32`, never a
169    /// `SourceLocation`, and route through here.
170    ///
171    /// Display sites that can resolve the id to a manifest name do so through
172    /// [`Self::as_link_with`]; prefer [`Self::plugin_named`] when the name is
173    /// already known.
174    pub fn plugin(plugin_id: u32) -> Self {
175        Self {
176            layer: SourceLayer::Plugin(plugin_id),
177            // NOT pre-wrapped in `<>`: `as_link` renders a `Synthetic` tag as
178            // `[<tag>](synthetic:tag)`, so a tag that already carries angle
179            // brackets shows up as `<<plugin:1>>`.
180            kind: SourceKind::Synthetic(format!("plugin:{plugin_id}")),
181        }
182    }
183
184    /// Render as a markdown link for inclusion in a help body.
185    /// Format: `[label](scheme:value)`. The link's URL portion is
186    /// what `parse_help_links` (in `lattice-ui-tui::help`)
187    /// classifies into a typed `lattice_ui_tui::help::HelpLinkTarget`; the label
188    /// is what the user sees.
189    pub fn as_link(&self) -> String {
190        self.as_link_with(&|_| None)
191    }
192
193    /// [`Self::as_link`], with a way to name a plugin.
194    ///
195    /// The LAYER is authoritative about a source being a plugin's — the
196    /// `kind` is only a tag, and one stamped by whichever registration path
197    /// happened to run. So the resolver is consulted on `SourceLayer::Plugin`
198    /// and its answer wins, which means every display site gets the manifest
199    /// name regardless of what was stamped, and `plugin:1` survives only where
200    /// the plugin genuinely cannot be named (unloaded, or a harness with no
201    /// meta registry).
202    pub fn as_link_with(&self, resolve_plugin: &dyn Fn(u32) -> Option<String>) -> String {
203        if let SourceLayer::Plugin(pid) = self.layer
204            && let Some(name) = resolve_plugin(pid)
205        {
206            return format!("[plugin:{name}](synthetic:plugin:{name})");
207        }
208        match &self.kind {
209            SourceKind::File {
210                path,
211                line: Some(n),
212            } => {
213                let label = escape_link_text(&format!("{}:{}", path.display(), n));
214                format!("[{label}](file:{label})")
215            }
216            SourceKind::File { path, line: None } => {
217                let label = escape_link_text(&path.display().to_string());
218                format!("[{label}](file:{label})")
219            }
220            SourceKind::CommandLine { history_index } => {
221                format!("[command:{history_index}](history:command:{history_index})")
222            }
223            SourceKind::MacroReplay { register, step } => {
224                format!("[macro:{register}:{step}](macro:{register}:step:{step})")
225            }
226            SourceKind::DotRepeat(inner) => {
227                // The inner source already renders as a markdown link;
228                // wrap it as the label of an outer link whose URL is
229                // a synthetic dot-repeat-of: scheme. Consumers can
230                // peel one level off if they care.
231                format!("[dot-repeat-of:{}](dot-repeat-of:inner)", inner.as_link())
232            }
233            SourceKind::Synthetic(tag) => format!("[<{tag}>](synthetic:{tag})"),
234        }
235    }
236}
237
238/// Backslash-escape the link syntax's own punctuation (`\ [ ] ( )`), as
239/// `lattice_help::escape_link_text` does; the help parsers unescape. A
240/// Windows path is why: unescaped, `crates\lattice-host\src` lost every
241/// separator, in the label and the `file:` URL alike.
242fn escape_link_text(text: &str) -> String {
243    let mut out = String::with_capacity(text.len());
244    for ch in text.chars() {
245        if matches!(ch, '\\' | '[' | ']' | '(' | ')') {
246            out.push('\\');
247        }
248        out.push(ch);
249    }
250    out
251}
252
253#[cfg(test)]
254mod tests {
255    #![allow(clippy::unwrap_used, clippy::panic)]
256    use super::*;
257
258    #[test]
259    fn layer_labels_are_human_readable() {
260        assert_eq!(SourceLayer::Builtin.label(), "built-in");
261        assert_eq!(SourceLayer::UserConfig.label(), "user config");
262        assert_eq!(SourceLayer::Plugin(7).label(), "plugin");
263    }
264
265    #[test]
266    fn builtin_file_renders_as_file_link_with_line() {
267        let s = SourceLocation::builtin_file("src/foo.rs", 42);
268        assert_eq!(s.as_link(), "[src/foo.rs:42](file:src/foo.rs:42)");
269    }
270
271    #[test]
272    fn file_link_escapes_backslashes_and_brackets() {
273        // A Windows `file!()` path; unescaped, the help parser ate the `\`s.
274        let s = SourceLocation::builtin_file(r"crates\x\src\a(b).rs", 3);
275        assert_eq!(
276            s.as_link(),
277            r"[crates\\x\\src\\a\(b\).rs:3](file:crates\\x\\src\\a\(b\).rs:3)"
278        );
279    }
280
281    #[test]
282    fn file_without_line_renders_path_only() {
283        let s = SourceLocation {
284            layer: SourceLayer::Builtin,
285            kind: SourceKind::File {
286                path: PathBuf::from("foo.rs"),
287                line: None,
288            },
289        };
290        assert_eq!(s.as_link(), "[foo.rs](file:foo.rs)");
291    }
292
293    #[test]
294    fn command_line_kind_renders_history_link() {
295        let s = SourceLocation {
296            layer: SourceLayer::Runtime,
297            kind: SourceKind::CommandLine { history_index: 3 },
298        };
299        assert_eq!(s.as_link(), "[command:3](history:command:3)");
300    }
301
302    #[test]
303    fn macro_replay_kind_renders_macro_link() {
304        let s = SourceLocation {
305            layer: SourceLayer::Runtime,
306            kind: SourceKind::MacroReplay {
307                register: 'q',
308                step: 5,
309            },
310        };
311        assert_eq!(s.as_link(), "[macro:q:5](macro:q:step:5)");
312    }
313
314    /// CM.5: the LAYER decides a source is a plugin's; the resolver names it.
315    ///
316    /// Keying on the layer rather than the `kind` tag is what makes this
317    /// uniform — the tag is whatever the registration path stamped, and the
318    /// paths disagree: the chord wiring knows the manifest name while
319    /// `register_plugin_operator` holds only an id.
320    #[test]
321    fn a_plugin_source_renders_its_manifest_name_when_one_resolves() {
322        let s = SourceLocation::plugin(7);
323
324        // No resolver: the id, and NOT double-wrapped — `as_link` adds the
325        // angle brackets, so a tag carrying its own produced `<<plugin:7>>`.
326        assert_eq!(s.as_link(), "[<plugin:7>](synthetic:plugin:7)");
327
328        // With one: the name, whatever the tag says.
329        assert_eq!(
330            s.as_link_with(&|pid| (pid == 7).then(|| "comment".to_string())),
331            "[plugin:comment](synthetic:plugin:comment)"
332        );
333
334        // A plugin the host cannot name falls back rather than inventing one.
335        assert_eq!(
336            s.as_link_with(&|_| None),
337            "[<plugin:7>](synthetic:plugin:7)",
338            "an unloaded plugin still renders, by id"
339        );
340    }
341
342    /// A non-plugin source is untouched by the resolver — it must not start
343    /// claiming provenance it does not have.
344    #[test]
345    fn a_builtin_source_ignores_the_plugin_resolver() {
346        let s = SourceLocation::builtin_file("src/foo.rs", 42);
347        assert_eq!(
348            s.as_link_with(&|_| Some("comment".to_string())),
349            "[src/foo.rs:42](file:src/foo.rs:42)"
350        );
351    }
352
353    #[test]
354    fn dot_repeat_chains_through_inner_source() {
355        let inner = SourceLocation::builtin_file("src/foo.rs", 7);
356        let s = SourceLocation {
357            layer: SourceLayer::Runtime,
358            kind: SourceKind::DotRepeat(Box::new(inner)),
359        };
360        assert_eq!(
361            s.as_link(),
362            "[dot-repeat-of:[src/foo.rs:7](file:src/foo.rs:7)](dot-repeat-of:inner)"
363        );
364    }
365
366    #[test]
367    fn synthetic_kind_renders_tagged_link() {
368        let s = SourceLocation::synthetic("initial-load");
369        assert_eq!(s.as_link(), "[<initial-load>](synthetic:initial-load)");
370    }
371}