Skip to main content

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, &registry).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, &registry).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, &registry).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, &registry)
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, &registry)
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, &registry).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, &registry).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, &registry).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}