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}