Skip to main content

lattice_mode/modes/
messages.rs

1//! `messages-mode` -- major mode for the editor's `*messages*`
2//! audit-log buffer (design.md §5.10.6).
3//!
4//! Single buffer in the editor (`*messages*`). The mode's job
5//! is small but architecturally important:
6//!
7//! - **Identity:** the buffer's major mode IS `messages-mode`,
8//!   not `text-mode + read-only-mode`. Symmetric with
9//!   `lsp-log-mode` for `*lsp*`. The renderer can branch on
10//!   `messages-mode` if it ever needs to (today it doesn't).
11//! - **Read-only contribution:** the mode contributes
12//!   `ReadOnly = true` so the modal dispatcher gates user
13//!   keystrokes; subsystem writes bypass via
14//!   `apply_edit_batch_blocking`.
15//! - **Future home for the tracing-subscriber lifecycle**
16//!   (deferred to v1.1). msg-mode.1 installs the global
17//!   subscriber once at App boot; making the subscriber's
18//!   enable/disable mode-driven is a refinement that lives in
19//!   the mode's `Guard` once it lands.
20//!
21//! Marker mode for v1: `type Guard = ();`, trivial
22//! `on_activate`. The work the spec attributes to
23//! "registers a `tracing::Subscriber` at activate time" is
24//! split across the App boot path
25//! (`lattice_runtime::install_messages_subscriber`) for v1
26//! simplicity. The mode-driven lifecycle binding lands when
27//! reload-based subscriber control is wired in v1.1.
28
29use crate::{CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind};
30use lattice_cells::style::{Style, StyledSpan};
31use lattice_config::OptionOverrideSet;
32use lattice_core::BufferKind;
33
34/// The width of the timestamp field `format_message_record` writes
35/// (`HH:MM:SS.mmm`), and the level field that follows it.
36///
37/// The record format lives in `lattice-host`; these two constants are the
38/// mode's half of that contract, and [`line_spans`] degrades to plain text
39/// whenever a line does not match — a misformatted record stays readable
40/// instead of being coloured from the middle.
41const TIMESTAMP_LEN: usize = 12;
42const LEVEL_LEN: usize = 5;
43
44/// Syntax-highlight one `*messages*` line.
45///
46/// **The mode owns this, not a renderer.** The TUI used to compose message
47/// bodies itself, behind `if is_messages_buffer`, and GPUI had no equivalent —
48/// so the log was coloured in one renderer and plain in the other. A
49/// kind-specific body composer in a renderer is the shape the architecture
50/// rules forbid, and the TUI's own comment said so ("the deeper issue is that
51/// this branch exists at all"). Emitting renderer-neutral [`StyledSpan`]s
52/// through the synthetic-highlight pipeline is what makes both peers agree
53/// without either of them knowing what a log line is.
54///
55/// The styles are the `Messages*` family, which resolves to the
56/// `messages.timestamp` / `messages.error` / … theme elements that already
57/// exist and that a user can already set. Folding them into the `Diagnostic*`
58/// colours would have been fewer variants and would have silently orphaned
59/// that vocabulary. Both renderers resolve the family through the one
60/// `theme_style` map, so neither needs an arm of its own.
61///
62/// Returns an empty vec for a line that is not a record; the renderer then
63/// paints it plain, which is what an unparseable line should look like.
64pub fn line_spans(line: &str) -> Vec<StyledSpan> {
65    let line = line.strip_suffix('\n').unwrap_or(line);
66    let level_start = TIMESTAMP_LEN + 1;
67    let body_start = level_start + LEVEL_LEN + 1;
68    if line.len() < body_start.saturating_sub(1)
69        || !line.is_char_boundary(TIMESTAMP_LEN)
70        || line.as_bytes().get(TIMESTAMP_LEN) != Some(&b' ')
71        || line.as_bytes().get(level_start + LEVEL_LEN) != Some(&b' ')
72    {
73        return Vec::new();
74    }
75    let level_style = match &line[level_start..level_start + LEVEL_LEN] {
76        "ERROR" => Style::MessagesError,
77        " WARN" => Style::MessagesWarn,
78        " INFO" => Style::MessagesInfo,
79        "DEBUG" => Style::MessagesDebug,
80        "TRACE" => Style::MessagesTrace,
81        // An unrecognised level means this is not one of our records after
82        // all. Plain, rather than coloured from the middle.
83        _ => return Vec::new(),
84    };
85    vec![
86        StyledSpan {
87            start: 0,
88            end: TIMESTAMP_LEN,
89            style: Style::MessagesTimestamp,
90        },
91        StyledSpan {
92            start: level_start,
93            end: level_start + LEVEL_LEN,
94            style: level_style,
95        },
96    ]
97}
98
99/// [`line_spans`] for a whole buffer, one entry per line.
100pub fn buffer_spans(text: &str) -> Vec<Vec<StyledSpan>> {
101    text.lines().map(line_spans).collect()
102}
103
104/// Major mode for the `*messages*` buffer.
105pub struct MessagesMode;
106
107impl MessagesMode {
108    /// The canonical id, `"messages-mode"` — what [`Mode::id`](crate::Mode::id)
109    /// returns. Use it to name this mode without an instance (activation,
110    /// `implies`, keymap layers, tests).
111    pub fn mode_id() -> ModeId {
112        ModeId::new("messages-mode")
113    }
114}
115
116impl Mode for MessagesMode {
117    type Guard = ();
118    fn id(&self) -> ModeId {
119        Self::mode_id()
120    }
121    fn kind(&self) -> ModeKind {
122        ModeKind::Major
123    }
124    /// `*messages*` and any future `BufferKind::Messages`
125    /// buffer dispatches to this major via the registry's kind
126    /// index (H.2).
127    fn target_buffer_kind(&self) -> Option<BufferKind> {
128        Some(BufferKind::Messages)
129    }
130    /// MG.RO: `read-only-mode` is where the operator gate actually is.
131    ///
132    /// `ReadOnly = true` above stops Insert-mode TYPING and nothing else — it
133    /// is read by `read_only_edit_rejected`, which guards the char path, while
134    /// a `Document`'s grammar dispatch applies its own edits and hands the host
135    /// an already-applied `Effect::Edits`. So `x` deleted a character out of
136    /// this buffer while it reported itself read-only. Verified, not inferred.
137    ///
138    /// `read-only-mode` carries the option AND the `invocation_runner`
139    /// (`Editor::run_read_only_motion`): motions move, `:` and `/` fall
140    /// through, mutating operators echo instead of silently editing. Declared
141    /// on the MAJOR because an implied mode is followed from the mode being
142    /// activated.
143    fn implies(&self) -> &[ModeId] {
144        static IMPLIED: std::sync::OnceLock<Vec<ModeId>> = std::sync::OnceLock::new();
145        IMPLIED.get_or_init(|| vec![crate::modes::ReadOnlyMode::mode_id()])
146    }
147
148    fn options(&self) -> OptionOverrideSet {
149        // User keystrokes can't mutate `*messages*` -- the
150        // subsystem owns the content. Subsystem writes route
151        // through `apply_edit_batch_blocking` which bypasses
152        // the dispatcher's read-only gate.
153        //
154        // `NoFile = true`: `*messages*` is a transcript, not an
155        // on-disk file. `:q` must not warn about unsaved
156        // changes; `:w` is a no-op.
157        lattice_config::overrides! {
158            lattice_config::ReadOnly = true,
159            lattice_config::NoFile = true,
160        }
161    }
162    fn required_capabilities(&self) -> CapabilitySet {
163        CapabilitySet::empty()
164    }
165    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
166        Box::pin(async { Ok(()) })
167    }
168}
169
170#[cfg(test)]
171mod highlight_tests {
172    use super::*;
173
174    fn record(level: &str, body: &str) -> String {
175        format!("12:34:56.789 {level} {body}")
176    }
177
178    /// Every level the formatter writes gets a style, and the severity ones
179    /// map onto the vocabulary both renderers already resolve.
180    #[test]
181    fn each_level_is_styled() {
182        for (level, expected) in [
183            ("ERROR", Style::MessagesError),
184            (" WARN", Style::MessagesWarn),
185            (" INFO", Style::MessagesInfo),
186            ("DEBUG", Style::MessagesDebug),
187            ("TRACE", Style::MessagesTrace),
188        ] {
189            let spans = line_spans(&record(level, "something happened"));
190            assert_eq!(spans.len(), 2, "{level}: timestamp + level");
191            assert_eq!(spans[0].style, Style::MessagesTimestamp, "{level}");
192            assert_eq!((spans[0].start, spans[0].end), (0, 12));
193            assert_eq!(spans[1].style, expected, "{level}");
194            assert_eq!((spans[1].start, spans[1].end), (13, 18), "{level}");
195        }
196    }
197
198    /// The body is left unstyled — it is the message, not syntax.
199    #[test]
200    fn the_body_carries_no_span() {
201        let spans = line_spans(&record("ERROR", "boom"));
202        assert!(
203            spans.iter().all(|s| s.end <= 18),
204            "no span may cover the message body"
205        );
206    }
207
208    /// A line that is not a record renders plain rather than coloured from
209    /// the middle — a misformatted entry stays readable.
210    #[test]
211    fn a_non_record_line_is_left_alone() {
212        for line in [
213            "",
214            "just some text",
215            "12:34:56.789 HUH   unknown level",
216            "short",
217            "12:34:56.789|WARN no space where one is required",
218        ] {
219            assert!(
220                line_spans(line).is_empty(),
221                "{line:?} must not be highlighted"
222            );
223        }
224    }
225
226    /// A trailing newline is the buffer's, not the record's.
227    #[test]
228    fn a_trailing_newline_does_not_change_the_spans() {
229        assert_eq!(
230            line_spans(&record("ERROR", "boom")),
231            line_spans(&format!("{}\n", record("ERROR", "boom")))
232        );
233    }
234
235    /// Multi-byte bodies must not panic the byte-offset scan.
236    #[test]
237    fn a_utf8_body_is_safe() {
238        let spans = line_spans(&record(" INFO", "héllo — wörld"));
239        assert_eq!(spans.len(), 2);
240    }
241
242    #[test]
243    fn buffer_spans_is_one_entry_per_line() {
244        let text = format!("{}\n{}\n", record("ERROR", "a"), record(" INFO", "b"));
245        let spans = buffer_spans(&text);
246        assert_eq!(spans.len(), 2);
247        assert_eq!(spans[0][1].style, Style::MessagesError);
248        assert_eq!(spans[1][1].style, Style::MessagesInfo);
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn id_and_kind() {
258        assert_eq!(MessagesMode.id(), MessagesMode::mode_id());
259        assert_eq!(MessagesMode::mode_id().as_str(), "messages-mode");
260        assert_eq!(MessagesMode.kind(), ModeKind::Major);
261    }
262
263    #[test]
264    fn contributes_read_only_and_no_file() {
265        let opts = <MessagesMode as Mode>::options(&MessagesMode);
266        assert_eq!(
267            opts.iter().count(),
268            2,
269            "expected ReadOnly + NoFile contributions",
270        );
271    }
272}