lattice_plugin_host/boundary_decoration.rs
1//! The decoration/ui boundary conversions (plugin-host.md §5 `decorations`/`ui`,
2//! PH7.9a).
3//!
4//! Mirrors `Mode::gutter_decorations` + `GutterDecoration` (lattice-mode) — the
5//! per-line gutter data a plugin decoration provider produces. Two directions:
6//!
7//! - **`GutterDecoration`** crosses **guest→host** (the producer's return): a
8//! `WitBoundary` round-trip (compiler-exhaustive both ways — a new arm can't
9//! land without a mapping; the `effect` precedent). Per-line scalars only; no
10//! draw calls cross.
11//! - **`decoration-context`** crosses **host→guest** (one-way, the grammar
12//! `project_*` precedent). The native `DecorationCtx` is `buffer_id` + a
13//! `ServiceRegistry` of render-state snapshots (host-owned, can't cross), and
14//! a plugin producer runs OFF the render path anyway — so the host builds the
15//! owned context from buffer metadata (id / path / line count) when it
16//! triggers the producer. Bulk buffer text rides `host-services` / the
17//! deferred `document` handle, not this record.
18//!
19//! The `ui` emit surface **had** a modeline half here that was type-mirror-only,
20//! and OC.3 / ML.6 gave it a real producer — `ui.wit` plus [`ui_host`], which
21//! builds native `ModelineElement` / `ModelineElementUpdate` values directly
22//! rather than round-tripping a record, so nothing here converts for it. The
23//! `ui-segment` record that used to be mirrored is gone with it: building the
24//! producer against a real consumer showed it conflated the descriptor's zone
25//! with the content's text (see the note in `types.wit`).
26//!
27//! `ui-notification` is still mirror-only and still has no producer — a plugin
28//! notifies via `effect.echo` — so the smoke test below keeps sizing it for the
29//! freeze.
30//!
31//! [`ui_host`]: crate::ui_host
32
33use crate::WitBoundary;
34use crate::lattice::plugin_host::types::{
35 DecorationContext as WitDecorationContext, GutterDecoration as WitGutterDecoration,
36 GutterDiffKind as WitGutterDiffKind, GutterSeverityLevel as WitGutterSeverityLevel,
37 GutterSign as WitGutterSign,
38};
39use lattice_mode::{
40 GutterDecoration as NativeGutterDecoration, GutterDiffKind as NativeGutterDiffKind,
41 GutterSeverityLevel as NativeGutterSeverityLevel,
42};
43
44impl WitBoundary for NativeGutterDiffKind {
45 type Wit = WitGutterDiffKind;
46
47 fn to_wit(&self) -> Result<WitGutterDiffKind, String> {
48 Ok(match self {
49 NativeGutterDiffKind::Add => WitGutterDiffKind::Add,
50 NativeGutterDiffKind::Remove => WitGutterDiffKind::Remove,
51 NativeGutterDiffKind::Change => WitGutterDiffKind::Change,
52 NativeGutterDiffKind::Conflict => WitGutterDiffKind::Conflict,
53 })
54 }
55
56 fn from_wit(wit: WitGutterDiffKind) -> Result<Self, String> {
57 Ok(match wit {
58 WitGutterDiffKind::Add => NativeGutterDiffKind::Add,
59 WitGutterDiffKind::Remove => NativeGutterDiffKind::Remove,
60 WitGutterDiffKind::Change => NativeGutterDiffKind::Change,
61 WitGutterDiffKind::Conflict => NativeGutterDiffKind::Conflict,
62 })
63 }
64}
65
66impl WitBoundary for NativeGutterSeverityLevel {
67 type Wit = WitGutterSeverityLevel;
68
69 fn to_wit(&self) -> Result<WitGutterSeverityLevel, String> {
70 Ok(match self {
71 NativeGutterSeverityLevel::Hint => WitGutterSeverityLevel::Hint,
72 NativeGutterSeverityLevel::Info => WitGutterSeverityLevel::Info,
73 NativeGutterSeverityLevel::Warning => WitGutterSeverityLevel::Warning,
74 NativeGutterSeverityLevel::Error => WitGutterSeverityLevel::Error,
75 })
76 }
77
78 fn from_wit(wit: WitGutterSeverityLevel) -> Result<Self, String> {
79 Ok(match wit {
80 WitGutterSeverityLevel::Hint => NativeGutterSeverityLevel::Hint,
81 WitGutterSeverityLevel::Info => NativeGutterSeverityLevel::Info,
82 WitGutterSeverityLevel::Warning => NativeGutterSeverityLevel::Warning,
83 WitGutterSeverityLevel::Error => NativeGutterSeverityLevel::Error,
84 })
85 }
86}
87
88impl WitBoundary for NativeGutterDecoration {
89 type Wit = WitGutterDecoration;
90
91 /// SG.4b: every native decoration is a `Sign` now, and a sign carries an
92 /// interned id whose NAME only the registry knows. This context-free
93 /// conversion does not have one, so both directions refuse by naming the
94 /// registry-aware pair rather than dropping the placement.
95 ///
96 /// The impl is kept rather than deleted because it is what makes that
97 /// refusal a compiler-checked total function: a future arm still has to
98 /// decide here, and "needs a registry" is a decision worth being told
99 /// about rather than discovered as a missing glyph.
100 fn to_wit(&self) -> Result<WitGutterDecoration, String> {
101 match self {
102 NativeGutterDecoration::Sign { .. } => Err(
103 "a gutter sign placement needs the sign registry to name it — \
104 use `decoration_to_wit`"
105 .to_string(),
106 ),
107 }
108 }
109
110 fn from_wit(wit: WitGutterDecoration) -> Result<Self, String> {
111 match wit {
112 // `diff` and `severity` are sugar for a built-in sign's NAME, and
113 // a name needs the registry exactly as much as the `sign` arm
114 // does — so all three refuse identically here.
115 WitGutterDecoration::Diff(_)
116 | WitGutterDecoration::Severity(_)
117 | WitGutterDecoration::Sign(_) => {
118 Err("a gutter decoration needs the sign registry to resolve — \
119 use `decoration_from_wit`"
120 .to_string())
121 }
122 }
123 }
124}
125
126/// SG.3b — the registry-aware guest→host conversion, and the one the producer
127/// call site uses.
128///
129/// `Ok(None)` means the placement is SKIPPED, which happens for exactly one
130/// reason: the guest named a sign nothing has defined. That is the same answer
131/// the native render path gives an unknown id — paint nothing — and it is
132/// deliberately not an `Err`, because an `Err` fails the whole batch and would
133/// take the plugin's diff and severity marks down with it over one unregistered
134/// name. A definition that has not registered yet is recoverable; a malformed
135/// record is not, and those still fail.
136///
137/// The resolution happens HERE, at the boundary, off the render path — which is
138/// the whole reason a native placement can stay `Copy` and carry no per-line
139/// `String`.
140pub fn decoration_from_wit(
141 wit: WitGutterDecoration,
142 registry: &lattice_mode::SignRegistry,
143) -> Result<Option<NativeGutterDecoration>, String> {
144 // SG.4b: the three arms differ only in how the sign is NAMED — a `sign`
145 // arm names it directly, the other two name a built-in. Past that they are
146 // the same placement, which is the whole point of the unification.
147 let (line, name): (u32, &str) = match &wit {
148 WitGutterDecoration::Diff(d) => (d.line, builtin_sign_name_for_diff(d.kind)),
149 WitGutterDecoration::Severity(s) => (s.line, builtin_sign_name_for_severity(s.level)),
150 WitGutterDecoration::Sign(s) => (s.line, s.name.as_str()),
151 };
152 let Some(sign) = registry.id_of(name) else {
153 // `debug!`, not `warn!`: a decoration producer runs on every refresh,
154 // so a guest with one bad name would flood the log at keystroke rate
155 // and bury everything else.
156 //
157 // A `diff` / `severity` arm reaching here means the host never
158 // registered its built-ins — a stripped harness rather than a guest
159 // bug — and the same skip is the right answer either way: no mark,
160 // rather than a mark resolving to something else.
161 tracing::debug!(
162 sign = %name,
163 line,
164 "gutter sign placement skipped: no such sign is defined"
165 );
166 return Ok(None);
167 };
168 Ok(Some(NativeGutterDecoration::Sign { line, sign }))
169}
170
171/// SG.4b — the built-in sign a WIT `diff` arm names.
172///
173/// The wire keeps `diff` and `severity` as sugar. A guest saying "line 4 is an
174/// addition" should not have to know the host spells that `diff.add`, and
175/// deleting the arms would break every decoration plugin for a change entirely
176/// internal to the host. The mapping lives here, at the boundary, so the native
177/// side has exactly one kind of gutter decoration.
178fn builtin_sign_name_for_diff(kind: WitGutterDiffKind) -> &'static str {
179 match kind {
180 WitGutterDiffKind::Add => "diff.add",
181 WitGutterDiffKind::Remove => "diff.remove",
182 WitGutterDiffKind::Change => "diff.change",
183 WitGutterDiffKind::Conflict => "diff.conflict",
184 }
185}
186
187/// The built-in sign a WIT `severity` arm names. Peer of
188/// [`builtin_sign_name_for_diff`].
189fn builtin_sign_name_for_severity(level: WitGutterSeverityLevel) -> &'static str {
190 match level {
191 WitGutterSeverityLevel::Hint => "diagnostic.hint",
192 WitGutterSeverityLevel::Info => "diagnostic.info",
193 WitGutterSeverityLevel::Warning => "diagnostic.warning",
194 WitGutterSeverityLevel::Error => "diagnostic.error",
195 }
196}
197
198/// SG.3b — the registry-aware host→guest conversion.
199///
200/// The mirror of [`decoration_from_wit`], for the direction that has no
201/// consumer yet: nothing in the host sends native decorations to a guest. It
202/// exists so the round trip is testable as a round trip — a name that survives
203/// out and back is the property that matters, and testing only one direction
204/// would not catch an id/name mapping that silently disagreed with itself.
205///
206/// A retired id has no name to send, so it converts to `None` rather than an
207/// error, matching the inbound direction's treatment of an unknown name.
208pub fn decoration_to_wit(
209 deco: &NativeGutterDecoration,
210 registry: &lattice_mode::SignRegistry,
211) -> Result<Option<WitGutterDecoration>, String> {
212 match deco {
213 NativeGutterDecoration::Sign { line, sign } => {
214 let Some(def) = registry.get(*sign) else {
215 return Ok(None);
216 };
217 // Always the `sign` arm, even for a built-in: the wire's `diff` /
218 // `severity` arms are inbound sugar, and answering with the name
219 // keeps the round trip exact rather than lossy through a second
220 // spelling of the same thing.
221 Ok(Some(WitGutterDecoration::Sign(WitGutterSign {
222 line: *line,
223 name: def.name.clone(),
224 })))
225 }
226 }
227}
228
229/// Build the owned `decoration-context` the host hands a producer (host→guest,
230/// one-way). The host has the buffer metadata off the render path when it
231/// triggers the producer; the guest computes per-line decorations from these
232/// scalars (+ `host-services` for external data like git HEAD). A non-UTF-8 path
233/// is dropped to `None` (a decoration producer keys off the *buffer*, not the
234/// path text — losing an un-representable path degrades gracefully rather than
235/// failing the whole trigger, unlike an event delivery).
236pub fn project_decoration_context(
237 buffer_id: u64,
238 path: Option<&std::path::Path>,
239 line_count: u32,
240) -> WitDecorationContext {
241 WitDecorationContext {
242 buffer_id,
243 path: path.and_then(|p| p.to_str().map(str::to_string)),
244 line_count,
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 #![allow(clippy::unwrap_used, clippy::panic)]
251
252 use super::*;
253 // SG.4b: the `diff` / `severity` payload records are only CONSTRUCTED by
254 // tests now — the production path names a built-in sign rather than
255 // building one — so they are imported here rather than at module scope.
256 use crate::lattice::plugin_host::types::{
257 EchoLevel, GutterDiff as WitGutterDiff, GutterSeverity as WitGutterSeverity, UiNotification,
258 };
259
260 #[test]
261 fn gutter_diff_kind_round_trips_every_arm() {
262 for k in [
263 NativeGutterDiffKind::Add,
264 NativeGutterDiffKind::Remove,
265 NativeGutterDiffKind::Change,
266 NativeGutterDiffKind::Conflict,
267 ] {
268 assert_eq!(
269 NativeGutterDiffKind::from_wit(k.to_wit().unwrap()).unwrap(),
270 k
271 );
272 }
273 }
274
275 #[test]
276 fn gutter_severity_level_round_trips_every_arm() {
277 for l in [
278 NativeGutterSeverityLevel::Hint,
279 NativeGutterSeverityLevel::Info,
280 NativeGutterSeverityLevel::Warning,
281 NativeGutterSeverityLevel::Error,
282 ] {
283 assert_eq!(
284 NativeGutterSeverityLevel::from_wit(l.to_wit().unwrap()).unwrap(),
285 l
286 );
287 }
288 }
289
290 /// SG.4b: the wire keeps its `diff` and `severity` arms, and they resolve
291 /// to the BUILT-IN signs. A guest saying "line 4 is an addition" does not
292 /// have to know the host spells that `diff.add` — which is the whole
293 /// reason the arms were kept as sugar rather than deleted with the native
294 /// variants.
295 #[test]
296 fn the_wire_sugar_arms_resolve_to_builtin_signs() {
297 let mut registry = lattice_mode::SignRegistry::new();
298 let ids = lattice_mode::register_builtin_signs(
299 &mut registry,
300 lattice_mode::DiagnosticGlyphs::default(),
301 );
302
303 let diff = WitGutterDecoration::Diff(WitGutterDiff {
304 line: 12,
305 kind: WitGutterDiffKind::Change,
306 });
307 assert_eq!(
308 decoration_from_wit(diff, ®istry).unwrap(),
309 Some(NativeGutterDecoration::Sign {
310 line: 12,
311 sign: ids.diff_change
312 })
313 );
314
315 let sev = WitGutterDecoration::Severity(WitGutterSeverity {
316 line: 3,
317 level: WitGutterSeverityLevel::Error,
318 });
319 assert_eq!(
320 decoration_from_wit(sev, ®istry).unwrap(),
321 Some(NativeGutterDecoration::Sign {
322 line: 3,
323 sign: ids.diagnostic_error
324 })
325 );
326 }
327
328 /// A host that never registered its built-ins skips a sugar arm rather
329 /// than resolving it to something else — a stripped harness, not a guest
330 /// bug, and no mark is the honest answer.
331 #[test]
332 fn a_sugar_arm_without_registered_builtins_is_skipped() {
333 let registry = lattice_mode::SignRegistry::new();
334 let diff = WitGutterDecoration::Diff(WitGutterDiff {
335 line: 1,
336 kind: WitGutterDiffKind::Add,
337 });
338 assert!(decoration_from_wit(diff, ®istry).unwrap().is_none());
339 }
340
341 fn sign_registry_with(names: &[(&str, i32)]) -> lattice_mode::SignRegistry {
342 let mut r = lattice_mode::SignRegistry::new();
343 for (name, priority) in names {
344 r.define(lattice_mode::SignDefinition {
345 name: (*name).to_string(),
346 text: "\u{f111}".into(),
347 fallback: "●".into(),
348 theme_element: format!("{name}.element"),
349 priority: *priority,
350 column: lattice_mode::SIGN_COLUMN_MARK.to_string(),
351 });
352 }
353 r
354 }
355
356 /// SG.3b: the whole point of the wire format is that a NAME survives out
357 /// and back as the SAME interned id. Testing one direction would not catch
358 /// an id↔name mapping that silently disagreed with itself.
359 #[test]
360 fn a_sign_placement_round_trips_through_its_name() {
361 let registry = sign_registry_with(&[("debugger.breakpoint", 20)]);
362 let id = registry.id_of("debugger.breakpoint").unwrap();
363 let native = NativeGutterDecoration::Sign { line: 7, sign: id };
364
365 let wit = decoration_to_wit(&native, ®istry)
366 .unwrap()
367 .expect("a live id has a name to send");
368 match &wit {
369 WitGutterDecoration::Sign(s) => {
370 assert_eq!(s.line, 7);
371 assert_eq!(
372 s.name, "debugger.breakpoint",
373 "the NAME crosses, not the id"
374 );
375 }
376 other => panic!("expected a sign arm, got {other:?}"),
377 }
378
379 let back = decoration_from_wit(wit, ®istry)
380 .unwrap()
381 .expect("a defined name resolves");
382 assert_eq!(back, native, "and it resolves to the SAME interned id");
383 }
384
385 /// A name nothing has defined is SKIPPED, not an error — because an error
386 /// fails the whole batch and would take the plugin's diff and severity
387 /// marks down with it over one unregistered name.
388 #[test]
389 fn an_unknown_sign_name_is_skipped_and_the_batch_survives() {
390 let mut registry = sign_registry_with(&[("debugger.breakpoint", 20)]);
391 // SG.4b: the neighbour below is a `diff` arm, which is SUGAR for a
392 // built-in sign's name — so the built-ins have to be registered for it
393 // to resolve at all. Without them this test would assert "the batch
394 // survives" against a batch where nothing survived.
395 lattice_mode::register_builtin_signs(
396 &mut registry,
397 lattice_mode::DiagnosticGlyphs::default(),
398 );
399 let unknown = WitGutterDecoration::Sign(WitGutterSign {
400 line: 3,
401 name: "debugger.nope".to_string(),
402 });
403 assert!(decoration_from_wit(unknown, ®istry).unwrap().is_none());
404
405 // The neighbour in the same batch still crosses — this is the half
406 // that would be lost if the unknown name had been an `Err`.
407 let diff = WitGutterDecoration::Diff(WitGutterDiff {
408 line: 4,
409 kind: WitGutterDiffKind::Add,
410 });
411 assert!(decoration_from_wit(diff, ®istry).unwrap().is_some());
412 }
413
414 /// A retired id has no name to send. `None` rather than an error, matching
415 /// how the inbound direction treats an unknown name — and matching the
416 /// render path, where a retired id paints nothing.
417 #[test]
418 fn a_retired_id_has_no_name_to_send() {
419 let mut registry = sign_registry_with(&[("debugger.breakpoint", 20)]);
420 let id = registry.id_of("debugger.breakpoint").unwrap();
421 registry.undefine("debugger.breakpoint");
422 let native = NativeGutterDecoration::Sign { line: 1, sign: id };
423 assert!(decoration_to_wit(&native, ®istry).unwrap().is_none());
424 }
425
426 /// The context-free `WitBoundary` conversions cannot spell a sign, and say
427 /// so by name rather than dropping it. The boundary's contract is that a
428 /// new arm forces a decision at every site; "needs the registry" is a
429 /// decision worth being told about rather than discovering as a missing
430 /// glyph.
431 #[test]
432 fn the_registry_free_conversions_refuse_a_sign_by_name() {
433 let registry = sign_registry_with(&[("p.mark", 5)]);
434 let id = registry.id_of("p.mark").unwrap();
435 let err = NativeGutterDecoration::Sign { line: 0, sign: id }
436 .to_wit()
437 .expect_err("no registry, no name");
438 assert!(err.contains("decoration_to_wit"), "{err}");
439
440 let err = NativeGutterDecoration::from_wit(WitGutterDecoration::Sign(WitGutterSign {
441 line: 0,
442 name: "p.mark".to_string(),
443 }))
444 .expect_err("no registry, no id");
445 assert!(err.contains("decoration_from_wit"), "{err}");
446 }
447
448 #[test]
449 fn decoration_context_projects_metadata() {
450 let ctx = project_decoration_context(9, Some(std::path::Path::new("src/lib.rs")), 240);
451 assert_eq!(ctx.buffer_id, 9);
452 assert_eq!(ctx.path.as_deref(), Some("src/lib.rs"));
453 assert_eq!(ctx.line_count, 240);
454
455 // A pathless (scratch) buffer projects `None`.
456 let scratch = project_decoration_context(1, None, 0);
457 assert!(scratch.path.is_none());
458 }
459
460 #[test]
461 fn ui_type_mirror_is_constructible() {
462 // Still mirror-only (no emit producer): assert the record exists and is
463 // shaped correctly so the ABI stays sized for the freeze. The modeline
464 // half of `ui` grew a real producer at OC.3 and is covered by
465 // `ui_host`'s own tests and `tests/modeline_seam.rs`.
466 let note = UiNotification {
467 level: EchoLevel::Warn,
468 message: "plugin loaded with reduced function".to_string(),
469 };
470 assert!(matches!(note.level, EchoLevel::Warn));
471 }
472}