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}