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}