Skip to main content

lattice_mode/
contributions.rs

1//! Declarative contributions on [`crate::Mode`].
2//!
3//! `Keymap` and `KeymapBinding` moved to `lattice-keymap::contribution`
4//! in K.3 (2026-06-07) — re-exported here for backward compatibility.
5//!
6//! Stubs still pending real impls:
7//! - [`DecorationProvider`] -- M.4 / decoration registry.
8
9use lattice_core::BufferId;
10
11use crate::services::ServiceRegistry;
12
13pub use lattice_keymap::{Keymap, KeymapBinding};
14
15/// RAII subscription handle. Unsubscribes from the event bus on drop.
16///
17/// Acquire in `Mode::on_activate` via `ctx.events_handle()` +
18/// `EventBus::subscribe_typed`; store in the mode's `Guard` struct so
19/// deactivation cleanup is compiler-enforced. Modes with conditional
20/// subscriptions (e.g. skip when no URI) use `Option<Subscription>`.
21///
22/// MO.4.c: replaces the `_private:()` stub; `Mode::subscriptions()`
23/// removed — `on_activate` + Guard IS the subscription mechanism.
24pub struct Subscription {
25    bus: std::sync::Arc<lattice_runtime::EventBus>,
26    id: lattice_runtime::SubscriptionId,
27}
28
29impl Subscription {
30    /// Wrap the `id` returned by one of `bus`'s subscribe calls so that
31    /// dropping this value calls [`EventBus::unsubscribe`] on it.
32    ///
33    /// Takes an owned `Arc` because the handle outlives the activation that
34    /// created it: it is moved into the mode's [`Guard`](crate::Mode::Guard),
35    /// and the Guard is dropped whenever the mode deactivates.
36    ///
37    /// [`EventBus::unsubscribe`]: lattice_runtime::EventBus::unsubscribe
38    pub fn new(
39        bus: std::sync::Arc<lattice_runtime::EventBus>,
40        id: lattice_runtime::SubscriptionId,
41    ) -> Self {
42        Self { bus, id }
43    }
44}
45
46impl Drop for Subscription {
47    fn drop(&mut self) {
48        self.bus.unsubscribe(self.id);
49    }
50}
51
52/// Stub. Reserved for the WIT plugin-facing contribution surface
53/// (M.10). Not used by the `Mode` trait today — see
54/// `Mode::gutter_decorations` for the live decoration path.
55#[derive(Debug, Clone)]
56pub struct DecorationProvider {
57    _private: (),
58}
59
60/// Renderer-agnostic diff-sign kind for the gutter diff column.
61/// Mirrors `DiffSignKind` without importing `lattice-host`.
62#[derive(Copy, Clone, Debug, PartialEq, Eq)]
63pub enum GutterDiffKind {
64    /// Line added relative to the diff base (`+`, sign `diff.add`).
65    Add,
66    /// Line(s) removed at this position (`-`, sign `diff.remove`).
67    Remove,
68    /// Line modified relative to the diff base (`~`, sign `diff.change`).
69    Change,
70    /// Line inside an unresolved merge conflict (`?`, sign `diff.conflict`).
71    Conflict,
72}
73
74/// Renderer-agnostic diagnostic severity level for the gutter
75/// severity column. Ordered ascending by severity so `max()` selects
76/// the most severe: `Hint < Info < Warning < Error`.
77#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
78pub enum GutterSeverityLevel {
79    /// Lowest severity; sign `diagnostic.hint`.
80    Hint,
81    /// Informational; sign `diagnostic.info`.
82    Info,
83    /// Warning; sign `diagnostic.warning`.
84    Warning,
85    /// Error — the most severe; sign `diagnostic.error`.
86    Error,
87}
88
89/// A single gutter decoration contributed by a [`crate::Mode`].
90/// Each variant maps to one physical gutter column.
91#[derive(Copy, Clone, Debug, PartialEq, Eq)]
92pub enum GutterDecoration {
93    /// A sign placement — vim's `:sign place`, and since SG.4b the ONLY
94    /// kind of gutter decoration there is (SG.1).
95    ///
96    /// `Diff` and `Severity` used to sit beside this. They were deleted rather
97    /// than deprecated because leaving them would have left the host with two
98    /// privileged gutter paths and a mechanism pretending to be general — the
99    /// exact half-migration shape that makes a "generic" seam quietly untrue.
100    /// A diff mark and a diagnostic are now definitions in the same registry a
101    /// plugin writes, named by their producers.
102    ///
103    /// Carries only the line and the definition's NAME; the glyph, its theme
104    /// element and its priority live in the [`SignRegistry`]. A placement is
105    /// produced per visible line on every refresh, so it stays the cheap half
106    /// of the pair on purpose.
107    ///
108    /// An unknown id resolves to nothing and paints nothing — a provider
109    /// placing a sign it never defined is a provider bug, and refusing to paint
110    /// is the answer that keeps the gutter honest rather than inventing a
111    /// glyph for it.
112    Sign {
113        /// Zero-based source line the sign is placed on.
114        line: u32,
115        /// The registered definition to paint; resolved through the
116        /// [`SignRegistry`].
117        sign: SignId,
118    },
119}
120
121/// Render-time carrier for `compilation-mode`'s severity gutter
122/// marks. The off-thread compilation drain builds a per-buffer severity
123/// index and ships it to the host (`AppEffect::CompilationGutterSet`),
124/// which stores it in `render_state`; the renderer reads that slot for the
125/// pane's buffer and registers this into the [`DecorationCtx`]'s
126/// `ServiceRegistry`. `CompilationMode::gutter_decorations` then pulls it
127/// and maps each `(line, level)` to a [`GutterDecoration::Sign`] naming the
128/// built-in diagnostic sign for that level
129/// ([`BuiltinSignIds::for_severity`]) — the same mark an LSP diagnostic
130/// paints (CM.3c; before SG.4b this was a dedicated `Severity` variant).
131///
132/// Deliberately lives here (in `lattice-mode`), NOT in `lattice-compilation`,
133/// so neither renderer needs a `lattice-compilation` dependency to inject it
134/// — the same dependency-inversion the `Mode::gutter_decorations` seam uses
135/// for `LspDiagnosticsData` / `DiffDecorationData`. `entries` is shared
136/// (`Arc`) so the render-path read is an O(1) pointer clone.
137pub struct CompilationSeverityData {
138    /// `(zero-based line, severity)` for every marked line of the
139    /// `*compilation*` buffer, built off-thread by the compilation drain.
140    pub entries: std::sync::Arc<Vec<(u32, GutterSeverityLevel)>>,
141}
142
143/// Read-only context passed to [`crate::Mode::gutter_decorations`].
144///
145/// Dependency inversion: the renderer builds a per-pane
146/// [`ServiceRegistry`] of typed render-state snapshots (diff sign map, LSP
147/// diagnostics, [`CompilationSeverityData`], [`BuiltinSignIds`], …) and
148/// each mode pulls only the data it knows about through
149/// [`service`](Self::service). The renderer therefore never depends on the
150/// crate that owns a mode, and a mode never sees another mode's data.
151///
152/// Built once per pane per frame, on the render path: a
153/// `gutter_decorations` implementation must do nothing heavier than a map
154/// over data already computed off-thread.
155///
156/// # Examples
157///
158/// ```
159/// use lattice_core::BufferId;
160/// use lattice_mode::{DecorationCtx, ServiceRegistry};
161///
162/// struct MyMarks(Vec<u32>);
163///
164/// let mut services = ServiceRegistry::new();
165/// services.register(MyMarks(vec![3, 7]));
166/// let ctx = DecorationCtx::new(BufferId(1), &services);
167///
168/// assert_eq!(ctx.service::<MyMarks>().unwrap().0, vec![3, 7]);
169/// // A snapshot nobody injected is simply absent: contribute nothing.
170/// assert!(ctx.service::<String>().is_none());
171/// ```
172pub struct DecorationCtx<'a> {
173    /// The buffer shown in the pane being decorated.
174    pub buffer_id: BufferId,
175    services: &'a ServiceRegistry,
176}
177
178impl<'a> DecorationCtx<'a> {
179    /// Build a context over `services` for the pane showing `buffer_id`.
180    /// Called by the renderers, not by modes.
181    pub fn new(buffer_id: BufferId, services: &'a ServiceRegistry) -> Self {
182        Self {
183            buffer_id,
184            services,
185        }
186    }
187
188    /// Typed lookup of a render-state snapshot the renderer injected.
189    /// `None` when nothing of type `T` was registered for this frame — a
190    /// stripped render path or data not yet produced — and the mode should
191    /// then contribute nothing rather than fail. Same `TypeId` rule as
192    /// [`ServiceRegistry::get`]: look up with exactly the registered type.
193    pub fn service<T: std::any::Any + Send + Sync>(&self) -> Option<std::sync::Arc<T>> {
194        self.services.get::<T>()
195    }
196}
197
198// ML.3: `StatusLineItem` + `StatusLineCtx` retired with the
199// `Mode::status_line_items` trait. Modes contribute modeline content as
200// registered elements pushed over the event bus
201// (`crate::ModelineElementUpdate`), not via a render-path service pull.
202
203// ── SG.1: generic gutter signs ──────────────────────────────────────────────
204
205/// A sign **definition**: what it looks like, how it is styled, how it
206/// competes for its cell (SG.1).
207///
208/// vim's `:sign define` / `:sign place` split, and the split is load-bearing
209/// rather than historical. A definition is registered once and carries the
210/// expensive, reusable parts — the glyph and its theme element. A *placement*
211/// is `(line, name)` and happens per keystroke, per visible line, on every
212/// refresh. Folding the two together would re-carry a glyph and a theme key
213/// across the boundary for every marked line of every refresh, to say something
214/// that was already true at load.
215///
216/// The host knows what a sign IS and nothing about what any particular sign
217/// MEANS — which is what makes this a mechanism rather than a feature. A
218/// provider's marks, a plugin's breakpoints and a future built-in all place
219/// signs through the same registry and are styled through the same theme.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct SignDefinition {
222    /// The name placements refer to. A provider's own namespace by convention
223    /// (`org-agenda-mark`), unenforced — last definition wins, as with every
224    /// other registry here.
225    pub name: String,
226    /// The glyph when `ui.nerd_fonts` is on. **One cell** — SG.2b put signs
227    /// in the gutter's single shared mark cell, so anything wider would push
228    /// every line of content right. [`Self::glyph_char`] is what the
229    /// renderers paint and it takes the first character.
230    pub text: String,
231    /// The BMP fallback, used when it is off — **the same cell width**, per the
232    /// icon-degradation rule, so toggling the option cannot shift the gutter's
233    /// geometry.
234    pub fallback: String,
235    /// The theme element the glyph is painted in (`gutter.sign.*` by
236    /// convention). Resolved by the renderer through the ordinary theme
237    /// registry, so a user or a theme retunes a plugin's signs without either
238    /// knowing about the other.
239    pub theme_element: String,
240    /// Which sign wins when two land on one line **of the same column**.
241    /// Higher wins; ties break on name so the answer is stable rather than
242    /// incidental to hash order.
243    ///
244    /// One cell, one sign: a cell that stacked them would either grow
245    /// unpredictably or silently drop one, and vim's answer — priority — is the
246    /// one users already know.
247    pub priority: i32,
248    /// Which gutter column this sign paints in (SG.4a).
249    ///
250    /// Columns exist because contention is only meaningful between marks that
251    /// mean comparable things. A diagnostic and a git-diff mark are both
252    /// "something is true of this line", but they answer different questions,
253    /// and a single contended cell would drop the git gutter on exactly the
254    /// lines a diagnostic touches — the lines a user is most likely to be
255    /// looking at. Vim's single `signcolumn` accepts that trade; Helix and Zed
256    /// do not, and neither does this.
257    ///
258    /// A name the host does not paint falls back to the FIRST column rather
259    /// than vanishing, on the same principle as the `gutter.sign` theme
260    /// fallback: a sign was placed to say something, and the failure mode that
261    /// loses the information entirely is the worst one available.
262    ///
263    /// Use [`SIGN_COLUMN_MARK`] / [`SIGN_COLUMN_DIFF`] for the built-ins.
264    pub column: String,
265}
266
267/// The leftmost gutter column: diagnostics, compilation severity, and
268/// any sign that does not name a column of its own. Vim's `signcolumn` (SG.4a).
269pub const SIGN_COLUMN_MARK: &str = "mark";
270
271/// The git-diff column, between the mark column and the line numbers.
272/// Separate from [`SIGN_COLUMN_MARK`] so a diagnostic cannot hide a hunk mark (SG.4a).
273pub const SIGN_COLUMN_DIFF: &str = "diff";
274
275/// The built-in gutter columns, left to right (SG.4a).
276///
277/// The host owns the ORDER (a gutter whose columns moved per buffer would be
278/// unreadable) but nothing about what goes in each one — that is entirely the
279/// registry's answer. Making this list user-configurable is the obvious next
280/// step and is deliberately not taken here: it changes the gutter's WIDTH,
281/// which every scroll, wrap and cursor-column calculation reads.
282pub const BUILTIN_SIGN_COLUMNS: [&str; 2] = [SIGN_COLUMN_MARK, SIGN_COLUMN_DIFF];
283
284impl SignDefinition {
285    /// The glyph for the current palette. Not a theme question — the theme
286    /// decides the COLOUR, the font capability decides the GLYPH, and
287    /// conflating them is how a themed editor renders tofu.
288    pub fn glyph(&self, nerd_fonts: bool) -> &str {
289        if nerd_fonts && !self.text.is_empty() {
290            &self.text
291        } else {
292            &self.fallback
293        }
294    }
295
296    /// The single character the renderers paint into the gutter's
297    /// mark cell (SG.2b).
298    ///
299    /// The cell is one column, so this TRUNCATES rather than trusting a
300    /// producer to have obeyed the one-cell rule. A definition that ignores
301    /// it loses its tail; the alternative is a gutter that silently widens
302    /// and pushes every line of content sideways, which is a pixel change to
303    /// content the user did not edit and costs far more than the glyph.
304    /// An empty definition paints a blank, so a producer can place a sign
305    /// that reserves the cell without drawing in it.
306    pub fn glyph_char(&self, nerd_fonts: bool) -> char {
307        self.glyph(nerd_fonts).chars().next().unwrap_or(' ')
308    }
309}
310
311/// A definition's interned handle (SG.1).
312///
313/// **Placements carry this, not a name**, and the reason is the render path:
314/// `GutterDecoration` is `Copy` and one placement exists per visible marked
315/// line per refresh, so a `String` there would be both a clone per line and the
316/// end of `Copy` for every consumer. The name→id resolution happens ONCE, where
317/// a placement is produced — at the WASM boundary, off the render path.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
319pub struct SignId(pub u32);
320
321/// The registered sign definitions (SG.1).
322///
323/// Read on the render path (one lookup per placed line) and written rarely (a
324/// provider registering at load), which is the `ArcSwap` shape every other
325/// contribution registry here uses.
326/// `Clone` because the `ArcSwap` write path is copy-on-write: a producer
327/// defining a sign clones the current snapshot, mutates, and stores. The
328/// clone is `Vec<Option<Arc<_>>>` + the name index — Arc bumps, not glyph
329/// copies — and it happens at registration, never on the render path.
330#[derive(Debug, Default, Clone)]
331pub struct SignRegistry {
332    /// Indexed by [`SignId`]. Never shrinks: an id handed out must keep
333    /// resolving, or an in-flight placement from a producer that ran before an
334    /// `undefine` would paint some LATER sign's glyph. A removed definition
335    /// leaves a `None` hole instead.
336    defs: Vec<Option<std::sync::Arc<SignDefinition>>>,
337    by_name: std::collections::HashMap<String, SignId>,
338}
339
340impl SignRegistry {
341    /// An empty registry. The host registers the built-ins into it with
342    /// [`register_builtin_signs`] at boot.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use lattice_mode::{SignDefinition, SignRegistry, SIGN_COLUMN_MARK};
348    ///
349    /// let mut signs = SignRegistry::new();
350    /// let id = signs.define(SignDefinition {
351    ///     name: "my-plugin.breakpoint".into(),
352    ///     text: "\u{f111}".into(), // Nerd Font glyph
353    ///     fallback: "●".into(),   // BMP fallback, same cell width
354    ///     theme_element: "gutter.sign.breakpoint".into(),
355    ///     priority: 10,
356    ///     column: SIGN_COLUMN_MARK.into(),
357    /// });
358    /// assert_eq!(signs.id_of("my-plugin.breakpoint"), Some(id));
359    /// assert_eq!(signs.get(id).unwrap().glyph_char(false), '●');
360    ///
361    /// // Removal retires the id: a stale placement paints nothing.
362    /// signs.undefine_prefix("my-plugin.");
363    /// assert!(signs.get(id).is_none());
364    /// assert!(signs.is_empty());
365    /// ```
366    pub fn new() -> Self {
367        Self::default()
368    }
369
370    /// Define a sign, or redefine one of the same name.
371    ///
372    /// A redefinition KEEPS the id, so a plugin reloading with a new glyph does
373    /// not orphan placements already in flight — they simply start painting the
374    /// new glyph, which is what "redefine" should mean.
375    pub fn define(&mut self, def: SignDefinition) -> SignId {
376        let def = std::sync::Arc::new(def);
377        if let Some(&id) = self.by_name.get(&def.name) {
378            self.defs[id.0 as usize] = Some(def);
379            return id;
380        }
381        let id = SignId(self.defs.len() as u32);
382        self.by_name.insert(def.name.clone(), id);
383        self.defs.push(Some(def));
384        id
385    }
386
387    /// The id a name resolves to, for a producer turning its own vocabulary
388    /// into placements.
389    pub fn id_of(&self, name: &str) -> Option<SignId> {
390        self.by_name.get(name).copied()
391    }
392
393    /// Forget one, by name. What a plugin's teardown reverses. The id is
394    /// retired rather than reused, so an in-flight placement from a producer
395    /// that ran before the removal paints nothing instead of painting some
396    /// LATER sign's glyph.
397    pub fn undefine(&mut self, name: &str) {
398        if let Some(id) = self.by_name.remove(name) {
399            self.defs[id.0 as usize] = None;
400        }
401    }
402
403    /// Forget every sign a namespace defined — `org.` removes `org.mark` and
404    /// its peers. The unload path, since a plugin's definitions are not tracked
405    /// individually anywhere else.
406    pub fn undefine_prefix(&mut self, prefix: &str) {
407        let names: Vec<String> = self
408            .by_name
409            .keys()
410            .filter(|n| n.starts_with(prefix))
411            .cloned()
412            .collect();
413        for name in names {
414            self.undefine(&name);
415        }
416    }
417
418    /// The definition behind a placement, or `None` for a retired id — which
419    /// paints nothing.
420    pub fn get(&self, id: SignId) -> Option<&std::sync::Arc<SignDefinition>> {
421        self.defs.get(id.0 as usize)?.as_ref()
422    }
423
424    /// Every live definition with its id, for a consumer that has to
425    /// pre-resolve something per definition — the publish path turning each
426    /// `theme_element` into an `ElementId` (SG.2b). Skips retired ids, so a
427    /// caller never has to re-check `get`. Order is id order, which is
428    /// definition order; nothing here depends on it, but it is stable rather
429    /// than hash-dependent, which is the property that keeps such a caller's
430    /// tests from reseeding.
431    pub fn iter(&self) -> impl Iterator<Item = (SignId, &std::sync::Arc<SignDefinition>)> {
432        self.defs
433            .iter()
434            .enumerate()
435            .filter_map(|(i, d)| d.as_ref().map(|d| (SignId(i as u32), d)))
436    }
437
438    /// How many definitions are live. Retired ids do not count.
439    pub fn len(&self) -> usize {
440        self.by_name.len()
441    }
442
443    /// True when no definition is live (every id retired or none defined).
444    pub fn is_empty(&self) -> bool {
445        self.by_name.is_empty()
446    }
447}
448
449/// Register **and** look up with this exact alias (the `ServiceRegistry` TypeId
450/// rule).
451pub type SignRegistryHandle = std::sync::Arc<arc_swap::ArcSwap<SignRegistry>>;
452
453// ── SG.4a: the built-in signs ───────────────────────────────────────────────
454//
455// Diagnostics and diff marks are signs like any other. Nothing about them is
456// privileged in the host any more: they are definitions in the same registry a
457// plugin writes, painted through the same theme elements, contended by the
458// same priority rule. What used to be two hardcoded gutter paths is now two
459// producers naming what they mean.
460//
461// Their priorities span the diagnostic severity order, because "most severe
462// wins" was the semantics the old `Severity` arm's `max()` gave and it has to
463// survive the unification. `10` is vim's default sign priority and the floor:
464// a plugin sign shipping the vim default ties with a HINT (broken by name) and
465// loses to everything above it. Outranking an ERROR now means exceeding
466// `DIAGNOSTIC_ERROR_PRIORITY`, which is a real change from SG.2b — where any
467// priority above 10 did it — and the stricter reading is the right one. A
468// sign that displaces a compiler error had better mean it.
469
470/// The lowest diagnostic, level with vim's default sign priority.
471pub const DIAGNOSTIC_HINT_PRIORITY: i32 = 10;
472/// Priority of the built-in `diagnostic.info` sign: above a hint, below a
473/// warning.
474pub const DIAGNOSTIC_INFO_PRIORITY: i32 = 20;
475/// Priority of the built-in `diagnostic.warning` sign: above info, below an
476/// error.
477pub const DIAGNOSTIC_WARNING_PRIORITY: i32 = 30;
478/// The highest built-in. A sign must EXCEED this to take the cell from an
479/// error.
480pub const DIAGNOSTIC_ERROR_PRIORITY: i32 = 40;
481
482/// Diff marks never contend with each other — a line belongs to at most one
483/// hunk — so they share one priority, and it is the vim default because
484/// nothing about a hunk mark argues for out-ranking a plugin's sign in a
485/// column plugins do not normally use.
486pub const DIFF_SIGN_PRIORITY: i32 = 10;
487
488/// The interned ids of the built-in signs (SG.4a).
489///
490/// The `BuiltinElementIds` shape, for the same reason: a producer emitting a
491/// mark per visible line must not hash a string per line to say which mark it
492/// is. Interned once at registration, published, and read as a field.
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub struct BuiltinSignIds {
495    /// `diagnostic.error` (mark column, [`DIAGNOSTIC_ERROR_PRIORITY`]).
496    pub diagnostic_error: SignId,
497    /// `diagnostic.warning` (mark column, [`DIAGNOSTIC_WARNING_PRIORITY`]).
498    pub diagnostic_warning: SignId,
499    /// `diagnostic.info` (mark column, [`DIAGNOSTIC_INFO_PRIORITY`]).
500    pub diagnostic_info: SignId,
501    /// `diagnostic.hint` (mark column, [`DIAGNOSTIC_HINT_PRIORITY`]).
502    pub diagnostic_hint: SignId,
503    /// `diff.add`, glyph `+` (diff column).
504    pub diff_add: SignId,
505    /// `diff.change`, glyph `~` (diff column).
506    pub diff_change: SignId,
507    /// `diff.remove`, glyph `-` (diff column).
508    pub diff_remove: SignId,
509    /// `diff.conflict`, glyph `?` (diff column).
510    pub diff_conflict: SignId,
511}
512
513impl Default for BuiltinSignIds {
514    /// Every id `SignId(u32::MAX)`, which resolves to nothing.
515    ///
516    /// A test fixture or a stripped host that never registered the built-ins
517    /// then paints NO marks, rather than painting whatever happens to sit at
518    /// id 0 — which would be some plugin's sign, silently, and only in the
519    /// configurations nobody looks at.
520    fn default() -> Self {
521        let none = SignId(u32::MAX);
522        Self {
523            diagnostic_error: none,
524            diagnostic_warning: none,
525            diagnostic_info: none,
526            diagnostic_hint: none,
527            diff_add: none,
528            diff_change: none,
529            diff_remove: none,
530            diff_conflict: none,
531        }
532    }
533}
534
535impl BuiltinSignIds {
536    /// The id for a severity level — what an LSP or compilation producer calls
537    /// instead of building a `Severity` decoration.
538    pub fn for_severity(&self, level: GutterSeverityLevel) -> SignId {
539        match level {
540            GutterSeverityLevel::Error => self.diagnostic_error,
541            GutterSeverityLevel::Warning => self.diagnostic_warning,
542            GutterSeverityLevel::Info => self.diagnostic_info,
543            GutterSeverityLevel::Hint => self.diagnostic_hint,
544        }
545    }
546
547    /// The id for a diff kind — what the diff mode calls instead of building a
548    /// `Diff` decoration.
549    pub fn for_diff(&self, kind: GutterDiffKind) -> SignId {
550        match kind {
551            GutterDiffKind::Add => self.diff_add,
552            GutterDiffKind::Change => self.diff_change,
553            GutterDiffKind::Remove => self.diff_remove,
554            GutterDiffKind::Conflict => self.diff_conflict,
555        }
556    }
557}
558
559/// Register the built-in signs into `registry` and return their ids (SG.4a).
560///
561/// `glyphs` supplies the four diagnostic glyphs, which are live typed options
562/// (`ui.diagnostic-*-glyph`) rather than constants — so this is called again
563/// when one changes. Redefinition KEEPS the id (SG.1), which is what makes
564/// re-registering safe with placements already in flight: they simply start
565/// painting the new glyph. That property was built before anything needed it;
566/// this is the thing that needed it.
567///
568/// Diff glyphs stay `+ ~ - ?` — cross-editor convention, and no option has
569/// ever exposed them.
570///
571/// # Examples
572///
573/// ```
574/// use lattice_mode::{
575///     register_builtin_signs, DiagnosticGlyphs, GutterSeverityLevel, SignRegistry,
576///     SIGN_COLUMN_DIFF,
577/// };
578///
579/// let mut signs = SignRegistry::new();
580/// let ids = register_builtin_signs(&mut signs, DiagnosticGlyphs::default());
581/// let error = signs.get(ids.for_severity(GutterSeverityLevel::Error)).unwrap();
582/// assert_eq!(error.glyph_char(false), '■');
583/// assert_eq!(signs.get(ids.diff_add).unwrap().column, SIGN_COLUMN_DIFF);
584///
585/// // Re-registering with a new glyph keeps every id.
586/// let glyphs = DiagnosticGlyphs { error: 'E', ..DiagnosticGlyphs::default() };
587/// assert_eq!(register_builtin_signs(&mut signs, glyphs), ids);
588/// assert_eq!(signs.get(ids.diagnostic_error).unwrap().glyph_char(false), 'E');
589/// ```
590pub fn register_builtin_signs(
591    registry: &mut SignRegistry,
592    glyphs: DiagnosticGlyphs,
593) -> BuiltinSignIds {
594    let mut sev = |name: &str, glyph: char, element: &str, priority: i32| {
595        let g = glyph.to_string();
596        registry.define(SignDefinition {
597            name: name.to_string(),
598            // The SAME glyph for both palettes: these are the user's own
599            // configured characters, and silently substituting a different one
600            // when `ui.nerd_fonts` flips would be a surprise no option asked
601            // for. A user wanting a Nerd Font diagnostic glyph sets the option
602            // to one.
603            text: g.clone(),
604            fallback: g,
605            theme_element: element.to_string(),
606            priority,
607            column: SIGN_COLUMN_MARK.to_string(),
608        })
609    };
610    let diagnostic_error = sev(
611        "diagnostic.error",
612        glyphs.error,
613        "diagnostic.error",
614        DIAGNOSTIC_ERROR_PRIORITY,
615    );
616    let diagnostic_warning = sev(
617        "diagnostic.warning",
618        glyphs.warning,
619        "diagnostic.warning",
620        DIAGNOSTIC_WARNING_PRIORITY,
621    );
622    let diagnostic_info = sev(
623        "diagnostic.info",
624        glyphs.info,
625        "diagnostic.info",
626        DIAGNOSTIC_INFO_PRIORITY,
627    );
628    let diagnostic_hint = sev(
629        "diagnostic.hint",
630        glyphs.hint,
631        "diagnostic.hint",
632        DIAGNOSTIC_HINT_PRIORITY,
633    );
634    let mut diff = |name: &str, glyph: char, element: &str| {
635        let g = glyph.to_string();
636        registry.define(SignDefinition {
637            name: name.to_string(),
638            text: g.clone(),
639            fallback: g,
640            theme_element: element.to_string(),
641            priority: DIFF_SIGN_PRIORITY,
642            column: SIGN_COLUMN_DIFF.to_string(),
643        })
644    };
645    let diff_add = diff("diff.add", '+', "diff.add.sign");
646    let diff_change = diff("diff.change", '~', "diff.change.sign");
647    let diff_remove = diff("diff.remove", '-', "diff.remove.sign");
648    let diff_conflict = diff("diff.conflict", '?', "diff.conflict.sign");
649    BuiltinSignIds {
650        diagnostic_error,
651        diagnostic_warning,
652        diagnostic_info,
653        diagnostic_hint,
654        diff_add,
655        diff_change,
656        diff_remove,
657        diff_conflict,
658    }
659}
660
661/// The four `ui.diagnostic-*-glyph` values, read once by the caller so
662/// `lattice-mode` needs no typed-options dependency.
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub struct DiagnosticGlyphs {
665    /// `ui.diagnostic-error-glyph` (default `■`).
666    pub error: char,
667    /// `ui.diagnostic-warning-glyph` (default `▲`).
668    pub warning: char,
669    /// `ui.diagnostic-info-glyph` (default `●`).
670    pub info: char,
671    /// `ui.diagnostic-hint-glyph` (default `·`).
672    pub hint: char,
673}
674
675impl Default for DiagnosticGlyphs {
676    /// The same defaults the renderers carried before the unification, so a
677    /// host that reads no options still paints what it used to.
678    fn default() -> Self {
679        Self {
680            error: '■',
681            warning: '▲',
682            info: '●',
683            hint: '·',
684        }
685    }
686}
687
688/// Pick the winner when several signs land on one line (SG.1).
689///
690/// Higher priority wins; equal priorities break on name. The tiebreak is not
691/// arbitrary politeness — without it the painted glyph depends on iteration
692/// order, so the same buffer renders differently between runs and a test that
693/// passes today fails when a `HashMap` reseeds.
694pub fn winning_sign<'a>(
695    a: &'a std::sync::Arc<SignDefinition>,
696    b: &'a std::sync::Arc<SignDefinition>,
697) -> &'a std::sync::Arc<SignDefinition> {
698    match a.priority.cmp(&b.priority) {
699        std::cmp::Ordering::Greater => a,
700        std::cmp::Ordering::Less => b,
701        std::cmp::Ordering::Equal => {
702            if a.name <= b.name {
703                a
704            } else {
705                b
706            }
707        }
708    }
709}
710
711#[cfg(test)]
712mod sign_tests {
713    #![allow(clippy::unwrap_used)]
714    use super::*;
715
716    fn def(name: &str, priority: i32) -> SignDefinition {
717        SignDefinition {
718            name: name.to_string(),
719            text: "\u{f111}".to_string(),
720            fallback: "\u{25cf}".to_string(),
721            theme_element: format!("gutter.sign.{name}"),
722            priority,
723            column: SIGN_COLUMN_MARK.to_string(),
724        }
725    }
726
727    #[test]
728    fn a_definition_resolves_by_its_id() {
729        let mut r = SignRegistry::new();
730        let id = r.define(def("mark", 10));
731        assert_eq!(r.id_of("mark"), Some(id));
732        assert_eq!(r.get(id).unwrap().name, "mark");
733        assert_eq!(r.len(), 1);
734    }
735
736    /// A redefinition KEEPS the id, so placements already in flight start
737    /// painting the new glyph rather than being orphaned.
738    #[test]
739    fn redefining_keeps_the_id() {
740        let mut r = SignRegistry::new();
741        let first = r.define(def("mark", 10));
742        let second = r.define(def("mark", 99));
743        assert_eq!(first, second);
744        assert_eq!(r.get(first).unwrap().priority, 99);
745        assert_eq!(r.len(), 1, "and does not accumulate a second definition");
746    }
747
748    /// An id is RETIRED, never reused. A placement produced before the removal
749    /// must paint nothing — reusing the slot would make it paint some later
750    /// sign's glyph, which is a wrong answer where nothing is the right one.
751    #[test]
752    fn a_removed_id_is_retired_rather_than_reused() {
753        let mut r = SignRegistry::new();
754        let old = r.define(def("mark", 10));
755        r.undefine("mark");
756        assert!(r.get(old).is_none(), "the stale placement paints nothing");
757        let new = r.define(def("other", 10));
758        assert_ne!(old, new, "the slot is not handed to a different sign");
759        assert!(r.get(old).is_none());
760    }
761
762    #[test]
763    fn a_namespace_can_be_removed_at_once() {
764        let mut r = SignRegistry::new();
765        let a = r.define(def("org.mark", 1));
766        let b = r.define(def("org.flag", 1));
767        let keep = r.define(def("dap.breakpoint", 1));
768        r.undefine_prefix("org.");
769        assert!(r.get(a).is_none() && r.get(b).is_none());
770        assert!(
771            r.get(keep).is_some(),
772            "another plugin's signs are untouched"
773        );
774        assert_eq!(r.len(), 1);
775    }
776
777    /// Higher priority wins. One cell, one sign — a column that stacked them
778    /// would grow unpredictably or drop one silently.
779    #[test]
780    fn priority_decides_which_sign_paints() {
781        let mut r = SignRegistry::new();
782        let lo = r.define(def("low", 1));
783        let hi = r.define(def("high", 100));
784        let (lo, hi) = (r.get(lo).unwrap(), r.get(hi).unwrap());
785        assert_eq!(winning_sign(lo, hi).name, "high");
786        assert_eq!(winning_sign(hi, lo).name, "high", "and it is symmetric");
787    }
788
789    /// Ties break on NAME, not on iteration order. Without it the painted glyph
790    /// depends on hash seeding — the same buffer renders differently between
791    /// runs, and a test that passes today fails when the map reseeds.
792    #[test]
793    fn equal_priorities_break_on_name_not_on_luck() {
794        let mut r = SignRegistry::new();
795        let a = r.define(def("aaa", 5));
796        let z = r.define(def("zzz", 5));
797        let (a, z) = (r.get(a).unwrap(), r.get(z).unwrap());
798        assert_eq!(winning_sign(a, z).name, "aaa");
799        assert_eq!(winning_sign(z, a).name, "aaa");
800    }
801
802    /// The theme decides the COLOUR; the font capability decides the GLYPH.
803    /// Conflating them is how a themed editor renders tofu.
804    #[test]
805    fn the_palette_decides_the_glyph_not_the_theme() {
806        let d = def("mark", 1);
807        assert_eq!(d.glyph(true), "\u{f111}");
808        assert_eq!(d.glyph(false), "\u{25cf}");
809        assert_eq!(
810            d.glyph(true).chars().count(),
811            d.glyph(false).chars().count(),
812            "both palettes occupy the same cell width, so toggling \
813             `ui.nerd_fonts` cannot shift the gutter"
814        );
815    }
816
817    /// SG.2b: the mark cell is one column, so a definition that ignores the
818    /// one-cell rule loses its tail rather than widening the gutter and
819    /// pushing every line of content sideways.
820    #[test]
821    fn a_wide_glyph_is_truncated_rather_than_widening_the_gutter() {
822        let mut d = def("wide", 1);
823        d.text = "ab".into();
824        d.fallback = "cd".into();
825        assert_eq!(d.glyph_char(true), 'a');
826        assert_eq!(d.glyph_char(false), 'c');
827    }
828
829    /// An empty definition reserves the cell without drawing in it.
830    #[test]
831    fn an_empty_glyph_paints_a_blank() {
832        let mut d = def("blank", 1);
833        d.text = String::new();
834        d.fallback = String::new();
835        assert_eq!(d.glyph_char(true), ' ');
836        assert_eq!(d.glyph_char(false), ' ');
837    }
838
839    /// SG.4b: a sign and a diagnostic contend by ORDINARY priority now —
840    /// there is no separate "does this beat a severity" rule, because a
841    /// diagnostic IS a sign. A plugin shipping vim's default (10) ties with a
842    /// hint and loses to everything above it.
843    #[test]
844    fn a_vim_default_sign_sits_at_the_bottom_of_the_diagnostics() {
845        let mut r = SignRegistry::new();
846        let ids = register_builtin_signs(&mut r, DiagnosticGlyphs::default());
847        let plugin = std::sync::Arc::new(def("a-plugin.mark", DIAGNOSTIC_HINT_PRIORITY));
848        let hint = r.get(ids.diagnostic_hint).unwrap();
849        let error = r.get(ids.diagnostic_error).unwrap();
850        // Ties with the hint (broken by name, deterministically) and loses to
851        // the error outright.
852        assert_eq!(plugin.priority, hint.priority);
853        assert_eq!(winning_sign(error, &plugin).name, error.name);
854    }
855
856    /// The publish path pre-resolves one theme element per DEFINITION, so it
857    /// needs to walk them — and must not be handed a retired id, which
858    /// resolves to nothing.
859    #[test]
860    fn iter_yields_live_definitions_and_skips_retired_ids() {
861        let mut r = SignRegistry::new();
862        let a = r.define(def("a", 1));
863        let b = r.define(def("b", 2));
864        r.undefine("a");
865        let seen: Vec<SignId> = r.iter().map(|(id, _)| id).collect();
866        assert_eq!(seen, vec![b], "the retired id must not be walked");
867        assert!(r.get(a).is_none());
868    }
869}